feat: improve SEO foundations and canonicalise locale URLs while adding language-switcher in Footer as planned (#20294)
#### SEO
- Heading default flipped from h1 → h2; only Hero.Heading defaults to
h1. Eliminates accidental multi-h1 pages, which was confusing search
engines about the primary topic.
- Titles and descriptions in static-website-routes.ts rewritten to be
keyword-led and unique per page.
- Added buildFaqPageJsonLd (used on /, /pricing) and
buildReleaseListJsonLd (used on /releases).
- ReleaseEntry now renders id={release} so JSON-LD @id fragments resolve
to anchors.
#### Footer language switcher
- New LocaleSwitcher.tsx (plain React popover — useState + useRef +
outside-click). Trigger renders globe icon + native language name
(Français); popover lists all enabled locales with native + English
names side-by-side.
- Intl.DisplayNames-based name resolution in locale-display-names.ts.
- Plumbed into the footer's bottom row next to copyright.
Translations have not been pulled from Crowdin yet, so French pages
currently show English copy.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { proxy } from '@/proxy';
|
||||
|
||||
const SITE_ORIGIN = 'https://example.test';
|
||||
|
||||
type Cookies = Record<string, string>;
|
||||
type Headers = Record<string, string>;
|
||||
|
||||
const buildRequest = (
|
||||
pathname: string,
|
||||
{ cookies = {}, headers = {} }: { cookies?: Cookies; headers?: Headers } = {},
|
||||
): NextRequest => {
|
||||
const url = new URL(pathname, SITE_ORIGIN);
|
||||
const cookieHeader = Object.entries(cookies)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('; ');
|
||||
const allHeaders = new Headers(headers);
|
||||
if (cookieHeader.length > 0) {
|
||||
allHeaders.set('cookie', cookieHeader);
|
||||
}
|
||||
return new NextRequest(url, { headers: allHeaders });
|
||||
};
|
||||
|
||||
const getSetCookie = (response: Response): string | null =>
|
||||
response.headers.get('set-cookie');
|
||||
|
||||
describe('proxy: locale routing', () => {
|
||||
describe('canonicalisation of explicit prefixes', () => {
|
||||
it('301s /en/foo to /foo so the source locale never appears in URLs', () => {
|
||||
const response = proxy(buildRequest('/en/pricing'));
|
||||
|
||||
expect(response.status).toBe(301);
|
||||
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`);
|
||||
});
|
||||
|
||||
it('301s the bare /en root to /', () => {
|
||||
const response = proxy(buildRequest('/en'));
|
||||
|
||||
expect(response.status).toBe(301);
|
||||
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/`);
|
||||
});
|
||||
|
||||
it('preserves the query string on /en canonicalisation', () => {
|
||||
const response = proxy(buildRequest('/en/pricing?utm_source=newsletter'));
|
||||
|
||||
expect(response.status).toBe(301);
|
||||
expect(response.headers.get('location')).toBe(
|
||||
`${SITE_ORIGIN}/pricing?utm_source=newsletter`,
|
||||
);
|
||||
});
|
||||
|
||||
it('308s an unsupported but recognised locale prefix down to the canonical path', () => {
|
||||
const response = proxy(buildRequest('/de-DE/pricing'));
|
||||
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`);
|
||||
});
|
||||
|
||||
it('308s the legacy fr-FR URL form to bare path (the public segment is /fr now)', () => {
|
||||
const response = proxy(buildRequest('/fr-FR/pricing'));
|
||||
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get('location')).toBe(`${SITE_ORIGIN}/pricing`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical published-locale segments render directly', () => {
|
||||
it('renders /fr/foo without any redirect or rewrite', () => {
|
||||
const response = proxy(buildRequest('/fr/pricing'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not write a NEXT_LOCALE cookie on locale-prefixed visits', () => {
|
||||
const response = proxy(buildRequest('/fr/pricing'));
|
||||
|
||||
expect(getSetCookie(response)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bare URLs always render the source locale', () => {
|
||||
it('rewrites /pricing to the internal /en/pricing for anonymous visitors', () => {
|
||||
const response = proxy(buildRequest('/pricing'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBe(
|
||||
`${SITE_ORIGIN}/en/pricing`,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores Accept-Language entirely — French browsers still see English at /pricing', () => {
|
||||
const response = proxy(
|
||||
buildRequest('/pricing', {
|
||||
headers: { 'accept-language': 'fr-FR,fr;q=0.9' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBe(
|
||||
`${SITE_ORIGIN}/en/pricing`,
|
||||
);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores stored NEXT_LOCALE cookie — bare URLs are never cookie-redirected', () => {
|
||||
const response = proxy(
|
||||
buildRequest('/pricing', { cookies: { NEXT_LOCALE: 'fr-FR' } }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBe(
|
||||
`${SITE_ORIGIN}/en/pricing`,
|
||||
);
|
||||
expect(response.headers.get('location')).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites the bare root / to the internal /en path', () => {
|
||||
const response = proxy(buildRequest('/'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBe(
|
||||
`${SITE_ORIGIN}/en`,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves query strings when rewriting bare URLs', () => {
|
||||
const response = proxy(buildRequest('/pricing?utm_source=newsletter'));
|
||||
|
||||
expect(response.headers.get('x-middleware-rewrite')).toBe(
|
||||
`${SITE_ORIGIN}/en/pricing?utm_source=newsletter`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ import { Menu } from '@/sections/Menu/components';
|
||||
import { Problem } from '@/sections/Problem/components';
|
||||
import { Testimonials } from '@/sections/Testimonials/components';
|
||||
import { ThreeCards } from '@/sections/ThreeCards/components';
|
||||
import { buildRouteMetadata } from '@/lib/seo';
|
||||
import { buildFaqPageJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
|
||||
import { TrustedBy } from '@/sections/TrustedBy/components';
|
||||
import { theme } from '@/theme';
|
||||
import { css } from '@linaria/core';
|
||||
@@ -111,6 +111,7 @@ export default async function HomePage({ params }: HomePageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={buildFaqPageJsonLd(FAQ_DATA.questions, renderText)} />
|
||||
{/*
|
||||
* Above-the-fold home hero background texture. Preload warms the
|
||||
* HTTP cache so it is ready by the time HomeBackgroundHalftone
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { getSiteUrl } from '@/lib/seo';
|
||||
import {
|
||||
buildOrganizationJsonLd,
|
||||
buildSoftwareApplicationJsonLd,
|
||||
getSiteUrl,
|
||||
JsonLd,
|
||||
} from '@/lib/seo';
|
||||
import { DRACO_DECODER_ORIGIN } from '@/lib/visual-runtime/draco-decoder-path';
|
||||
import { theme } from '@/theme';
|
||||
import { cssVariables } from '@/theme/css-variables';
|
||||
@@ -15,6 +20,7 @@ import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descr
|
||||
import {
|
||||
I18nProvider,
|
||||
PUBLIC_APP_LOCALE_LIST,
|
||||
localeToUrlSegment,
|
||||
resolveLocaleParam,
|
||||
} from '@/lib/i18n';
|
||||
import { getLocaleMessages } from '@/lib/i18n/messages-by-locale';
|
||||
@@ -82,9 +88,9 @@ const StyledMain = styled.main`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const SITE_TITLE = 'Twenty | #1 open source CRM';
|
||||
const SITE_TITLE = 'Twenty | #1 Open Source CRM';
|
||||
const SITE_DESCRIPTION =
|
||||
'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.';
|
||||
'The #1 Open Source CRM for modern teams. Modular, scalable, and built to fit your business.';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(getSiteUrl()),
|
||||
@@ -115,7 +121,9 @@ type LocaleLayoutParams = { locale: string };
|
||||
export const dynamicParams = false;
|
||||
|
||||
export const generateStaticParams = (): LocaleLayoutParams[] =>
|
||||
PUBLIC_APP_LOCALE_LIST.map((locale) => ({ locale }));
|
||||
PUBLIC_APP_LOCALE_LIST.map((locale) => ({
|
||||
locale: localeToUrlSegment(locale),
|
||||
}));
|
||||
|
||||
const LocaleLayout = async ({
|
||||
children,
|
||||
@@ -138,6 +146,9 @@ const LocaleLayout = async ({
|
||||
href={DRACO_DECODER_ORIGIN}
|
||||
rel="preconnect"
|
||||
/>
|
||||
<JsonLd
|
||||
data={[buildOrganizationJsonLd(), buildSoftwareApplicationJsonLd()]}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable}`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const HERO_COPY = {
|
||||
body: msg`We're building the #1 open source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.`,
|
||||
body: msg`We're building the #1 Open Source CRM, but we can't do it alone. Join our partner ecosystem and grow with us.`,
|
||||
};
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export const THREE_CARDS_ILLUSTRATION_DATA: ThreeCardsIllustrationDataType = {
|
||||
{
|
||||
heading: { text: msg`Content & Community Partners`, fontFamily: 'sans' },
|
||||
body: {
|
||||
text: msg`Share Twenty with your audience and help shape the future of the #1 open source CRM. We're looking for creators, educators, and community builders who want to showcase great software.`,
|
||||
text: msg`Share Twenty with your audience and help shape the future of the #1 Open Source CRM. We're looking for creators, educators, and community builders who want to showcase great software.`,
|
||||
},
|
||||
benefits: [
|
||||
{ text: msg`Revenue share for referred customers`, icon: 'tag' },
|
||||
|
||||
@@ -25,7 +25,7 @@ import { PricingStateProvider } from '@/sections/Plans/context/PricingStateConte
|
||||
import { PlanTable } from '@/sections/PlanTable/components';
|
||||
import { Salesforce } from '@/sections/Salesforce/components';
|
||||
import { theme } from '@/theme';
|
||||
import { buildRouteMetadata } from '@/lib/seo';
|
||||
import { buildFaqPageJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
const PricingPlansContainer = styled.div`
|
||||
@@ -56,6 +56,7 @@ export default async function PricingPage({ params }: PricingPageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={buildFaqPageJsonLd(FAQ_DATA.questions, renderText)} />
|
||||
<Menu.Root
|
||||
backgroundColor="#F3F3F3"
|
||||
scheme="primary"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const RELEASE_NOTES_HERO_COPY = {
|
||||
body: msg`Discover the newest features and improvements in Twenty,\nthe #1 open source CRM.`,
|
||||
body: msg`Discover the newest features and improvements in Twenty,\nthe #1 Open Source CRM.`,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Hero } from '@/sections/Hero/components';
|
||||
import { Menu } from '@/sections/Menu/components';
|
||||
import { ReleaseNotes } from '@/sections/ReleaseNotes/components';
|
||||
import { theme } from '@/theme';
|
||||
import { buildRouteMetadata } from '@/lib/seo';
|
||||
import { buildReleaseListJsonLd, buildRouteMetadata, JsonLd } from '@/lib/seo';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export const generateMetadata = buildRouteMetadata('releases');
|
||||
@@ -43,6 +43,9 @@ export default async function ReleasesPage({ params }: ReleasesPageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleNotes.length > 0 ? (
|
||||
<JsonLd data={buildReleaseListJsonLd(visibleNotes)} />
|
||||
) : null}
|
||||
{/*
|
||||
* Above-the-fold milestone scene texture. Preload kicks off the
|
||||
* fetch in parallel with the JS chunk download.
|
||||
|
||||
@@ -9,21 +9,22 @@ describe('sitemap', () => {
|
||||
|
||||
expect(pathnames).toContain('/');
|
||||
expect(pathnames).toContain('/product');
|
||||
expect(pathnames).toContain('/fr-FR/product');
|
||||
expect(pathnames).toContain('/fr/product');
|
||||
expect(pathnames).not.toContain('/fr-FR/product');
|
||||
expect(pathnames).toContain('/customers/9dots');
|
||||
expect(pathnames).not.toContain('/de-DE/product');
|
||||
expect(pathnames).not.toContain('/halftone');
|
||||
expect(pathnames).not.toContain('/enterprise/activate');
|
||||
});
|
||||
|
||||
it('emits hreflang alternates only for published website locales', () => {
|
||||
it('emits hreflang alternates with AppLocale keys but URL-segment paths', () => {
|
||||
const productEntry = sitemap().find(
|
||||
(entry) => pathnameOf(entry.url) === '/product',
|
||||
);
|
||||
|
||||
expect(productEntry?.alternates?.languages).toMatchObject({
|
||||
en: expect.stringMatching(/\/product$/),
|
||||
'fr-FR': expect.stringMatching(/\/fr-FR\/product$/),
|
||||
'fr-FR': expect.stringMatching(/\/fr\/product$/),
|
||||
'x-default': expect.stringMatching(/\/product$/),
|
||||
});
|
||||
expect(productEntry?.alternates?.languages).not.toHaveProperty('de-DE');
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { PUBLIC_APP_LOCALE_LIST } from '@/lib/i18n';
|
||||
import { PUBLIC_APP_LOCALE_LIST, localeToUrlSegment } from '@/lib/i18n';
|
||||
import { getSiteUrl } from '@/lib/seo';
|
||||
import { getIndexedWebsiteRoutes } from '@/lib/website-routing';
|
||||
|
||||
const SITE_URL = getSiteUrl();
|
||||
|
||||
const buildLocalizedUrl = (locale: AppLocale, path: string): string => {
|
||||
const prefix = locale === SOURCE_LOCALE ? '' : `/${locale}`;
|
||||
const prefix =
|
||||
locale === SOURCE_LOCALE ? '' : `/${localeToUrlSegment(locale)}`;
|
||||
const tail = path === '/' ? '' : path;
|
||||
return `${SITE_URL}${prefix}${tail}`;
|
||||
};
|
||||
|
||||
@@ -146,7 +146,7 @@ export type HeadingProps<TText = ReactNode> = {
|
||||
} & HeadingTextRenderer<TText>;
|
||||
|
||||
export function Heading<TText = ReactNode>({
|
||||
as: Tag = 'h1',
|
||||
as: Tag = 'h2',
|
||||
children,
|
||||
inlineSegmentSeparator = ' ',
|
||||
renderText,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { Heading } from '@/design-system/components';
|
||||
|
||||
describe('design-system Heading defaults', () => {
|
||||
it('renders <h2> by default', () => {
|
||||
const html = renderToStaticMarkup(<Heading>Section title</Heading>);
|
||||
expect(html).toMatch(/^<h2[^>]*>.*Section title.*<\/h2>$/s);
|
||||
});
|
||||
|
||||
it.each(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const)(
|
||||
'honours an explicit as=%s override',
|
||||
(tag) => {
|
||||
const html = renderToStaticMarkup(<Heading as={tag}>Override</Heading>);
|
||||
expect(html).toContain(`<${tag}`);
|
||||
expect(html).toContain(`</${tag}>`);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
getEnglishLocaleName,
|
||||
getNativeLocaleName,
|
||||
} from '../locale-display-names';
|
||||
|
||||
describe('getNativeLocaleName', () => {
|
||||
it('returns the language name in its own language with the first character upper-cased', () => {
|
||||
expect(getNativeLocaleName('en')).toBe('English');
|
||||
expect(getNativeLocaleName('fr-FR')).toBe('Français');
|
||||
});
|
||||
|
||||
it('does not break on scripts that have no concept of letter case', () => {
|
||||
const value = getNativeLocaleName('ja-JP');
|
||||
expect(value).not.toBe('');
|
||||
expect(value).not.toContain('(');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnglishLocaleName', () => {
|
||||
it('returns the English name for the language regardless of the input locale', () => {
|
||||
expect(getEnglishLocaleName('en')).toBe('English');
|
||||
expect(getEnglishLocaleName('fr-FR')).toBe('French');
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,14 @@ describe('localizeHref', () => {
|
||||
expect(localizeHref('de-DE', '/pricing')).toBe('/pricing');
|
||||
});
|
||||
|
||||
it('emits locale prefixes for published non-default locales', () => {
|
||||
expect(localizeHref('fr-FR', '/pricing')).toBe('/fr-FR/pricing');
|
||||
expect(localizeHref('fr-FR', '/')).toBe('/fr-FR');
|
||||
it('emits the URL-segment prefix (not the AppLocale) for published non-default locales', () => {
|
||||
expect(localizeHref('fr-FR', '/pricing')).toBe('/fr/pricing');
|
||||
expect(localizeHref('fr-FR', '/')).toBe('/fr');
|
||||
});
|
||||
|
||||
it('strips the canonical /fr URL segment when re-localising to the default locale', () => {
|
||||
expect(localizeHref('en', '/fr/pricing')).toBe('/pricing');
|
||||
expect(localizeHref('en', '/fr')).toBe('/');
|
||||
});
|
||||
|
||||
it('returns paths unprefixed for the default locale (English at root)', () => {
|
||||
@@ -28,11 +33,6 @@ describe('localizeHref', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('strips legacy locale prefixes when targeting an unpublished locale', () => {
|
||||
expect(localizeHref('de-DE', '/fr-FR/why-twenty')).toBe('/why-twenty');
|
||||
expect(localizeHref('de-DE', '/de-DE/pricing')).toBe('/pricing');
|
||||
});
|
||||
|
||||
it('strips a redundant /en prefix when targeting the default locale', () => {
|
||||
expect(localizeHref('en', '/en/why-twenty')).toBe('/why-twenty');
|
||||
expect(localizeHref('en', '/en')).toBe('/');
|
||||
@@ -68,33 +68,33 @@ describe('localizeHref', () => {
|
||||
expect(localizeHref('en', '../parent')).toBe('../parent');
|
||||
});
|
||||
|
||||
it('handles a locale segment immediately followed by a query string', () => {
|
||||
it('handles a published locale segment immediately followed by a query string', () => {
|
||||
expect(localizeHref('en', '/en?ref=hero')).toBe('/?ref=hero');
|
||||
expect(localizeHref('de-DE', '/en?ref=hero')).toBe('/?ref=hero');
|
||||
expect(localizeHref('de-DE', '/fr-FR?ref=hero')).toBe('/?ref=hero');
|
||||
expect(localizeHref('en', '/fr?ref=hero')).toBe('/?ref=hero');
|
||||
});
|
||||
|
||||
it('handles a locale segment immediately followed by a hash fragment', () => {
|
||||
it('handles a published locale segment immediately followed by a hash fragment', () => {
|
||||
expect(localizeHref('en', '/en#anchor')).toBe('/#anchor');
|
||||
expect(localizeHref('de-DE', '/en#anchor')).toBe('/#anchor');
|
||||
expect(localizeHref('de-DE', '/fr-FR#anchor')).toBe('/#anchor');
|
||||
expect(localizeHref('en', '/fr#anchor')).toBe('/#anchor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripLocale', () => {
|
||||
it('removes a known locale prefix from the pathname', () => {
|
||||
it('removes a published-locale URL-segment prefix from the pathname', () => {
|
||||
expect(stripLocale('/en/why-twenty')).toBe('/why-twenty');
|
||||
expect(stripLocale('/fr-FR/customers/9dots')).toBe('/customers/9dots');
|
||||
expect(stripLocale('/fr/customers/9dots')).toBe('/customers/9dots');
|
||||
});
|
||||
|
||||
it('returns the root path when the pathname is just the locale segment', () => {
|
||||
expect(stripLocale('/en')).toBe('/');
|
||||
expect(stripLocale('/zh-CN')).toBe('/');
|
||||
expect(stripLocale('/fr')).toBe('/');
|
||||
});
|
||||
|
||||
it('returns the pathname unchanged when no known locale prefix is present', () => {
|
||||
it('returns the pathname unchanged when no published locale prefix is present', () => {
|
||||
expect(stripLocale('/why-twenty')).toBe('/why-twenty');
|
||||
expect(stripLocale('/')).toBe('/');
|
||||
expect(stripLocale('/fr-FR/foo')).toBe('/fr-FR/foo');
|
||||
expect(stripLocale('/zh-CN')).toBe('/zh-CN');
|
||||
});
|
||||
|
||||
it('returns the pathname unchanged when the input does not start with a slash', () => {
|
||||
@@ -104,8 +104,8 @@ describe('stripLocale', () => {
|
||||
|
||||
it('preserves query and hash when the locale segment is immediately followed by them', () => {
|
||||
expect(stripLocale('/en?ref=hero')).toBe('/?ref=hero');
|
||||
expect(stripLocale('/fr-FR#anchor')).toBe('/#anchor');
|
||||
expect(stripLocale('/fr-FR/customers?ref=hero#top')).toBe(
|
||||
expect(stripLocale('/fr#anchor')).toBe('/#anchor');
|
||||
expect(stripLocale('/fr/customers?ref=hero#top')).toBe(
|
||||
'/customers?ref=hero#top',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,15 @@ export { LocaleContext } from './LocaleContext';
|
||||
export { LocalizedLink } from './LocalizedLink';
|
||||
export { LocalizedLinkButton } from './LocalizedLinkButton';
|
||||
export { localizeHref, stripLocale } from './localize-href';
|
||||
export {
|
||||
getEnglishLocaleName,
|
||||
getNativeLocaleName,
|
||||
} from './locale-display-names';
|
||||
export { resolveLocaleParam } from './resolve-locale-param';
|
||||
export {
|
||||
LOCALE_BY_URL_SEGMENT,
|
||||
localeToUrlSegment,
|
||||
} from './website-locale-segments';
|
||||
export { useLocale } from './use-locale';
|
||||
export { useRenderMessage } from './use-render-message';
|
||||
export { useUnlocalizedPathname } from './use-unlocalized-pathname';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
const languageCode = (locale: AppLocale): string => locale.split('-')[0];
|
||||
|
||||
const capitalizeFirstChar = (value: string): string =>
|
||||
value.length === 0
|
||||
? value
|
||||
: value.charAt(0).toLocaleUpperCase() + value.slice(1);
|
||||
|
||||
export const getNativeLocaleName = (locale: AppLocale): string => {
|
||||
const display = new Intl.DisplayNames([locale], { type: 'language' });
|
||||
const name = display.of(languageCode(locale)) ?? locale;
|
||||
return capitalizeFirstChar(name);
|
||||
};
|
||||
|
||||
export const getEnglishLocaleName = (locale: AppLocale): string => {
|
||||
const display = new Intl.DisplayNames(['en'], { type: 'language' });
|
||||
return display.of(languageCode(locale)) ?? locale;
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { isPublicAppLocale } from './app-locale-set';
|
||||
import {
|
||||
KNOWN_PUBLIC_APP_LOCALE_BY_RAW,
|
||||
isPublicAppLocale,
|
||||
} from './app-locale-set';
|
||||
LOCALE_BY_URL_SEGMENT,
|
||||
localeToUrlSegment,
|
||||
} from './website-locale-segments';
|
||||
|
||||
const findFirstSegmentEnd = (path: string): number => {
|
||||
for (let i = 1; i < path.length; i += 1) {
|
||||
@@ -20,23 +21,25 @@ const buildTailFromSegmentEnd = (path: string, segmentEnd: number): string => {
|
||||
return tail;
|
||||
};
|
||||
|
||||
const isLocalePrefixSegment = (segment: string): boolean =>
|
||||
LOCALE_BY_URL_SEGMENT.has(segment);
|
||||
|
||||
export const localizeHref = (locale: AppLocale, href: string): string => {
|
||||
if (!href.startsWith('/') || href.startsWith('//')) return href;
|
||||
|
||||
const segmentEnd = findFirstSegmentEnd(href);
|
||||
const firstSegment = href.slice(1, segmentEnd);
|
||||
const existingLocale = KNOWN_PUBLIC_APP_LOCALE_BY_RAW.get(firstSegment);
|
||||
|
||||
const unprefixed =
|
||||
existingLocale !== undefined
|
||||
? buildTailFromSegmentEnd(href, segmentEnd)
|
||||
: href;
|
||||
const unprefixed = isLocalePrefixSegment(firstSegment)
|
||||
? buildTailFromSegmentEnd(href, segmentEnd)
|
||||
: href;
|
||||
|
||||
if (locale === SOURCE_LOCALE || !isPublicAppLocale(locale)) {
|
||||
return unprefixed;
|
||||
}
|
||||
|
||||
return unprefixed === '/' ? `/${locale}` : `/${locale}${unprefixed}`;
|
||||
const segment = localeToUrlSegment(locale);
|
||||
return unprefixed === '/' ? `/${segment}` : `/${segment}${unprefixed}`;
|
||||
};
|
||||
|
||||
export const stripLocale = (pathname: string): string => {
|
||||
@@ -44,7 +47,7 @@ export const stripLocale = (pathname: string): string => {
|
||||
|
||||
const segmentEnd = findFirstSegmentEnd(pathname);
|
||||
const firstSegment = pathname.slice(1, segmentEnd);
|
||||
if (!KNOWN_PUBLIC_APP_LOCALE_BY_RAW.has(firstSegment)) return pathname;
|
||||
if (!isLocalePrefixSegment(firstSegment)) return pathname;
|
||||
|
||||
return buildTailFromSegmentEnd(pathname, segmentEnd);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { APP_LOCALE_BY_RAW } from './app-locale-set';
|
||||
import { LOCALE_BY_URL_SEGMENT } from './website-locale-segments';
|
||||
|
||||
export const resolveLocaleParam = (raw: string): AppLocale => {
|
||||
const locale = APP_LOCALE_BY_RAW.get(raw);
|
||||
const locale = LOCALE_BY_URL_SEGMENT.get(raw);
|
||||
if (locale === undefined) notFound();
|
||||
return locale;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { WEBSITE_LOCALE_LIST } from './website-locale-list';
|
||||
|
||||
const URL_SEGMENT_OVERRIDES: Partial<Record<AppLocale, string>> = {
|
||||
'fr-FR': 'fr',
|
||||
};
|
||||
|
||||
export const localeToUrlSegment = (locale: AppLocale): string =>
|
||||
URL_SEGMENT_OVERRIDES[locale] ?? locale;
|
||||
|
||||
export const LOCALE_BY_URL_SEGMENT: ReadonlyMap<string, AppLocale> = new Map(
|
||||
WEBSITE_LOCALE_LIST.map((locale) => [localeToUrlSegment(locale), locale]),
|
||||
);
|
||||
@@ -56,23 +56,23 @@ describe('buildPageMetadata', () => {
|
||||
path: '/',
|
||||
title: {
|
||||
id: 'seo.test.title',
|
||||
message: 'Twenty | #1 open source CRM',
|
||||
message: 'Twenty | #1 Open Source CRM',
|
||||
},
|
||||
description: {
|
||||
id: 'seo.test.description',
|
||||
message: 'The #1 open source CRM for modern teams.',
|
||||
message: 'The #1 Open Source CRM for modern teams.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(metadata.title).toEqual({
|
||||
absolute: 'Twenty | #1 open source CRM',
|
||||
absolute: 'Twenty | #1 Open Source CRM',
|
||||
});
|
||||
expect(metadata.description).toBe(
|
||||
'The #1 open source CRM for modern teams.',
|
||||
'The #1 Open Source CRM for modern teams.',
|
||||
);
|
||||
expect(metadata.openGraph).toMatchObject({
|
||||
title: 'Twenty | #1 open source CRM',
|
||||
description: 'The #1 open source CRM for modern teams.',
|
||||
title: 'Twenty | #1 Open Source CRM',
|
||||
description: 'The #1 Open Source CRM for modern teams.',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('buildPageMetadata', () => {
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
expect(languages?.en).toBe('/pricing');
|
||||
expect(languages?.['fr-FR']).toBe('/fr-FR/pricing');
|
||||
expect(languages?.['fr-FR']).toBe('/fr/pricing');
|
||||
expect(languages?.['zh-CN']).toBeUndefined();
|
||||
expect(languages?.['x-default']).toBe('/pricing');
|
||||
});
|
||||
@@ -120,7 +120,7 @@ describe('buildPageMetadata', () => {
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
expect(languages?.['de-DE']).toBeUndefined();
|
||||
expect(languages?.['fr-FR']).toBe('/fr-FR');
|
||||
expect(languages?.['fr-FR']).toBe('/fr');
|
||||
expect(languages?.en).toBe('/');
|
||||
expect(languages?.['x-default']).toBe('/');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
buildFaqPageJsonLd,
|
||||
buildOrganizationJsonLd,
|
||||
buildReleaseListJsonLd,
|
||||
buildSoftwareApplicationJsonLd,
|
||||
} from '@/lib/seo';
|
||||
|
||||
const ORIGINAL_SITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL;
|
||||
|
||||
const descriptor = (message: string) => ({ id: message, message });
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL = 'https://example.test';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL = ORIGINAL_SITE_URL;
|
||||
});
|
||||
|
||||
describe('buildOrganizationJsonLd', () => {
|
||||
it('emits the canonical Organization shape with the configured site URL', () => {
|
||||
const data = buildOrganizationJsonLd();
|
||||
|
||||
expect(data).toMatchObject({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Twenty',
|
||||
url: 'https://example.test',
|
||||
logo: 'https://example.test/images/core/logo.svg',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSoftwareApplicationJsonLd', () => {
|
||||
it('declares Twenty as a CRM SoftwareApplication with the pricing page as its Offer URL', () => {
|
||||
const data = buildSoftwareApplicationJsonLd() as Record<string, unknown>;
|
||||
|
||||
expect(data['@type']).toBe('SoftwareApplication');
|
||||
expect(data['applicationCategory']).toBe('BusinessApplication');
|
||||
expect(data['applicationSubCategory']).toBe(
|
||||
'Customer Relationship Management',
|
||||
);
|
||||
expect(data['offers']).toMatchObject({
|
||||
url: 'https://example.test/pricing',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFaqPageJsonLd', () => {
|
||||
const renderText = (d: { message?: string; id: string }) => d.message ?? d.id;
|
||||
|
||||
it('produces a FAQPage with one Question per input, each carrying an Answer', () => {
|
||||
const questions = [
|
||||
{
|
||||
question: { fontFamily: 'sans' as const, text: descriptor('Q1?') },
|
||||
answer: { text: descriptor('A1.') },
|
||||
},
|
||||
{
|
||||
question: { fontFamily: 'sans' as const, text: descriptor('Q2?') },
|
||||
answer: { text: descriptor('A2.') },
|
||||
},
|
||||
];
|
||||
|
||||
const data = buildFaqPageJsonLd(questions, renderText) as {
|
||||
'@type': string;
|
||||
mainEntity: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
expect(data['@type']).toBe('FAQPage');
|
||||
expect(data.mainEntity).toHaveLength(2);
|
||||
expect(data.mainEntity[0]).toMatchObject({
|
||||
'@type': 'Question',
|
||||
name: 'Q1?',
|
||||
acceptedAnswer: { '@type': 'Answer', text: 'A1.' },
|
||||
});
|
||||
expect(data.mainEntity[1]).toMatchObject({
|
||||
name: 'Q2?',
|
||||
acceptedAnswer: { text: 'A2.' },
|
||||
});
|
||||
});
|
||||
|
||||
it('renders descriptors through the supplied renderText, not the descriptor id', () => {
|
||||
const data = buildFaqPageJsonLd(
|
||||
[
|
||||
{
|
||||
question: {
|
||||
text: { id: 'faq.q', message: 'Resolved question' },
|
||||
},
|
||||
answer: { text: { id: 'faq.a', message: 'Resolved answer' } },
|
||||
},
|
||||
],
|
||||
renderText,
|
||||
) as { mainEntity: Array<Record<string, unknown>> };
|
||||
|
||||
expect(data.mainEntity[0]).toMatchObject({
|
||||
name: 'Resolved question',
|
||||
acceptedAnswer: { text: 'Resolved answer' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReleaseListJsonLd', () => {
|
||||
it('builds a descending ItemList of TechArticles, anchored to /releases#<version>', () => {
|
||||
const notes = [
|
||||
{
|
||||
slug: '1.18.0',
|
||||
release: '1.18.0',
|
||||
date: '2026-04-01',
|
||||
content: '# Highlight one\n\nBody text\n\n# Highlight two\n',
|
||||
},
|
||||
{
|
||||
slug: '1.17.0',
|
||||
release: '1.17.0',
|
||||
date: '2026-03-15',
|
||||
content: '## Smaller heading\n\n# Real headline\n',
|
||||
},
|
||||
];
|
||||
|
||||
const data = buildReleaseListJsonLd(notes) as {
|
||||
'@type': string;
|
||||
numberOfItems: number;
|
||||
itemListElement: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
expect(data['@type']).toBe('ItemList');
|
||||
expect(data.numberOfItems).toBe(2);
|
||||
expect(data.itemListElement[0]).toMatchObject({
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
url: 'https://example.test/releases#1.18.0',
|
||||
item: {
|
||||
'@type': 'TechArticle',
|
||||
'@id': 'https://example.test/releases#1.18.0',
|
||||
headline: 'Highlight one',
|
||||
name: 'Twenty 1.18.0',
|
||||
datePublished: '2026-04-01',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to "Twenty <release>" as the headline when the body has no h1', () => {
|
||||
const data = buildReleaseListJsonLd([
|
||||
{
|
||||
slug: '0.1.0',
|
||||
release: '0.1.0',
|
||||
date: '2025-01-01',
|
||||
content: 'Just paragraphs, no headings.\n',
|
||||
},
|
||||
]) as { itemListElement: Array<Record<string, { headline: string }>> };
|
||||
|
||||
expect(data.itemListElement[0].item.headline).toBe('Twenty 0.1.0');
|
||||
});
|
||||
|
||||
it('omits datePublished when the frontmatter date is missing', () => {
|
||||
const data = buildReleaseListJsonLd([
|
||||
{
|
||||
slug: '0.2.0',
|
||||
release: '0.2.0',
|
||||
date: '',
|
||||
content: '# Headline\n',
|
||||
},
|
||||
]) as {
|
||||
itemListElement: Array<Record<string, Record<string, unknown>>>;
|
||||
};
|
||||
|
||||
expect(data.itemListElement[0].item).not.toHaveProperty('datePublished');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/lib/i18n/app-locale-set';
|
||||
import { createI18nInstance } from '@/lib/i18n/create-i18n-instance';
|
||||
import { createMessageDescriptorRenderer } from '@/lib/i18n/create-message-descriptor-renderer';
|
||||
import { localeToUrlSegment } from '@/lib/i18n/website-locale-segments';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { getSiteUrl } from './site-url';
|
||||
@@ -31,7 +32,10 @@ const localizePath = (locale: AppLocale, normalizedPath: string): string => {
|
||||
if (locale === SOURCE_LOCALE || !isPublicAppLocale(locale)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
return normalizedPath === '/' ? `/${locale}` : `/${locale}${normalizedPath}`;
|
||||
const segment = localeToUrlSegment(locale);
|
||||
return normalizedPath === '/'
|
||||
? `/${segment}`
|
||||
: `/${segment}${normalizedPath}`;
|
||||
};
|
||||
|
||||
const buildLanguageAlternates = (
|
||||
|
||||
@@ -4,4 +4,11 @@ export {
|
||||
type BuildPageMetadataInput,
|
||||
} from './build-page-metadata';
|
||||
export { buildRouteMetadata } from './build-route-metadata';
|
||||
export {
|
||||
buildFaqPageJsonLd,
|
||||
buildOrganizationJsonLd,
|
||||
buildReleaseListJsonLd,
|
||||
buildSoftwareApplicationJsonLd,
|
||||
JsonLd,
|
||||
} from './json-ld';
|
||||
export { getAbsoluteUrl, getSiteUrl } from './site-url';
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import type { LocalReleaseNote } from '@/lib/releases/types';
|
||||
|
||||
import { getSiteUrl } from './site-url';
|
||||
|
||||
type FaqEntryLike = {
|
||||
question: { text: MessageDescriptor };
|
||||
answer: { text: MessageDescriptor };
|
||||
};
|
||||
|
||||
type JsonLdPrimitive = boolean | number | string | null;
|
||||
|
||||
export type JsonLdValue =
|
||||
| JsonLdPrimitive
|
||||
| JsonLdValue[]
|
||||
| { [key: string]: JsonLdValue | undefined };
|
||||
|
||||
const serializeJsonLd = (data: JsonLdValue): string =>
|
||||
JSON.stringify(data).replace(/</g, '\\u003c');
|
||||
|
||||
export function JsonLd({ data }: { data: JsonLdValue }) {
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
||||
type="application/ld+json"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const buildOrganizationJsonLd = (): JsonLdValue => {
|
||||
const siteUrl = getSiteUrl();
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Twenty',
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/images/core/logo.svg`,
|
||||
sameAs: [
|
||||
'https://github.com/twentyhq/twenty',
|
||||
'https://www.linkedin.com/company/twenty',
|
||||
'https://x.com/twentycrm',
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSoftwareApplicationJsonLd = (): JsonLdValue => {
|
||||
const siteUrl = getSiteUrl();
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Twenty',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
applicationSubCategory: 'Customer Relationship Management',
|
||||
operatingSystem: 'Web',
|
||||
description:
|
||||
'Twenty is an open source CRM for teams that want a modern, customizable, and extensible customer platform.',
|
||||
url: siteUrl,
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
priceCurrency: 'USD',
|
||||
url: `${siteUrl}/pricing`,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'Twenty',
|
||||
url: siteUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type RenderText = (descriptor: MessageDescriptor) => string;
|
||||
|
||||
export const buildFaqPageJsonLd = (
|
||||
questions: readonly FaqEntryLike[],
|
||||
renderText: RenderText,
|
||||
): JsonLdValue => ({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: questions.map((question) => ({
|
||||
'@type': 'Question',
|
||||
name: renderText(question.question.text),
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: renderText(question.answer.text),
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const extractReleaseHeadline = (note: LocalReleaseNote): string => {
|
||||
const match = note.content.match(/^\s*#\s+(.+?)\s*$/m);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
return `Twenty ${note.release}`;
|
||||
};
|
||||
|
||||
export const buildReleaseListJsonLd = (
|
||||
notes: readonly LocalReleaseNote[],
|
||||
): JsonLdValue => {
|
||||
const siteUrl = getSiteUrl();
|
||||
const releasesUrl = `${siteUrl}/releases`;
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Twenty Releases',
|
||||
url: releasesUrl,
|
||||
itemListOrder: 'https://schema.org/ItemListOrderDescending',
|
||||
numberOfItems: notes.length,
|
||||
itemListElement: notes.map((note, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: `${releasesUrl}#${note.release}`,
|
||||
item: {
|
||||
'@type': 'TechArticle',
|
||||
'@id': `${releasesUrl}#${note.release}`,
|
||||
headline: extractReleaseHeadline(note),
|
||||
name: `Twenty ${note.release}`,
|
||||
url: `${releasesUrl}#${note.release}`,
|
||||
...(note.date ? { datePublished: note.date } : {}),
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Twenty',
|
||||
url: siteUrl,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'Twenty',
|
||||
url: siteUrl,
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -6,8 +6,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'home',
|
||||
path: '/',
|
||||
title: msg`Twenty | #1 open source CRM`,
|
||||
description: msg`The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.`,
|
||||
title: msg`Twenty | #1 Open Source CRM`,
|
||||
description: msg`The #1 Open Source CRM for modern teams. Modular, scalable, and built to fit your business.`,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1,
|
||||
indexed: true,
|
||||
@@ -15,8 +15,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'whyTwenty',
|
||||
path: '/why-twenty',
|
||||
title: msg`Why Twenty | Twenty`,
|
||||
description: msg`Most packaged software makes companies more similar. Learn why the future of CRM is built, not bought.`,
|
||||
title: msg`Why Twenty — The Open Source CRM Built to Be Customised`,
|
||||
description: msg`Packaged CRMs make every company look the same. Twenty is the open source CRM teams shape around their workflow, with a modern UI and a developer-first platform.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
indexed: true,
|
||||
@@ -24,8 +24,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'product',
|
||||
path: '/product',
|
||||
title: msg`Product | Twenty`,
|
||||
description: msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
|
||||
title: msg`Twenty CRM Features — Modern Open Source CRM Platform`,
|
||||
description: msg`Pipelines, custom objects, AI assistants, and a native API on top of Postgres. Twenty is the open source CRM with the modern UX teams actually want to use.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
indexed: true,
|
||||
@@ -33,8 +33,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'pricing',
|
||||
path: '/pricing',
|
||||
title: msg`Pricing | Twenty`,
|
||||
description: msg`Plans that scale with your team. Compare tiers of the #1 open source CRM.`,
|
||||
title: msg`Twenty CRM Pricing — Plans from $9 per User per Month`,
|
||||
description: msg`Cloud Pro starts at $9/user/month with unlimited custom objects. Self-host the open source core for free, or upgrade to Organization for SSO and row-level permissions.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.9,
|
||||
indexed: true,
|
||||
@@ -42,8 +42,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'partners',
|
||||
path: '/partners',
|
||||
title: msg`Partners | Twenty`,
|
||||
description: msg`Join our partner ecosystem and grow with us as we build the #1 open source CRM.`,
|
||||
title: msg`Twenty Partners — Certified Open Source CRM Implementers`,
|
||||
description: msg`Find a certified Twenty partner to migrate, customise, and operate your open source CRM, or join the ecosystem and grow your practice with us.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
indexed: true,
|
||||
@@ -51,8 +51,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'releases',
|
||||
path: '/releases',
|
||||
title: msg`Releases | Twenty`,
|
||||
description: msg`Discover the newest features and improvements in Twenty, the #1 open source CRM.`,
|
||||
title: msg`Twenty Releases — What's New in the Open Source CRM`,
|
||||
description: msg`Every new release of Twenty, the #1 Open Source CRM, with changelogs, demos, and the highlights teams care about most.`,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.7,
|
||||
indexed: true,
|
||||
@@ -60,8 +60,8 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'customers',
|
||||
path: '/customers',
|
||||
title: msg`Customers | Twenty`,
|
||||
description: msg`Meet the teams running their business on Twenty. Real customer stories on how they shaped the CRM to fit their workflow.`,
|
||||
title: msg`Twenty Customers — How Modern Teams Run Their CRM`,
|
||||
description: msg`Real customer stories from teams running their business on Twenty: how they migrated, what they customised, and what changed once their CRM finally fit.`,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
indexed: true,
|
||||
@@ -97,7 +97,7 @@ export const STATIC_WEBSITE_ROUTES = [
|
||||
{
|
||||
id: 'enterpriseActivate',
|
||||
path: '/enterprise/activate',
|
||||
title: msg`Enterprise activation | Twenty`,
|
||||
title: msg`Enterprise Activation | Twenty`,
|
||||
description: msg`Complete activation for your Twenty self-hosted enterprise license.`,
|
||||
changeFrequency: 'yearly',
|
||||
priority: 0,
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import {
|
||||
APP_LOCALE_BY_RAW,
|
||||
KNOWN_PUBLIC_APP_LOCALE_BY_RAW,
|
||||
} from '@/lib/i18n/app-locale-set';
|
||||
import { LOCALE_COOKIE_NAME, detectLocale } from '@/lib/i18n/detect-locale';
|
||||
|
||||
const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
const setLocaleCookie = (response: NextResponse, locale: AppLocale) => {
|
||||
response.cookies.set({
|
||||
name: LOCALE_COOKIE_NAME,
|
||||
value: locale,
|
||||
path: '/',
|
||||
maxAge: LOCALE_COOKIE_MAX_AGE,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
};
|
||||
import { KNOWN_PUBLIC_APP_LOCALE_BY_RAW } from '@/lib/i18n/app-locale-set';
|
||||
import { LOCALE_BY_URL_SEGMENT } from '@/lib/i18n/website-locale-segments';
|
||||
|
||||
export const proxy = (request: NextRequest) => {
|
||||
const { pathname, search } = request.nextUrl;
|
||||
@@ -25,55 +10,32 @@ export const proxy = (request: NextRequest) => {
|
||||
const firstSlash = pathname.indexOf('/', 1);
|
||||
const firstSegment =
|
||||
firstSlash === -1 ? pathname.slice(1) : pathname.slice(1, firstSlash);
|
||||
const localeFromPath = APP_LOCALE_BY_RAW.get(firstSegment);
|
||||
const knownLocaleFromPath = KNOWN_PUBLIC_APP_LOCALE_BY_RAW.get(firstSegment);
|
||||
const tail = firstSlash === -1 ? '/' : pathname.slice(firstSlash);
|
||||
|
||||
if (localeFromPath === SOURCE_LOCALE) {
|
||||
const canonicalPath = firstSlash === -1 ? '/' : pathname.slice(firstSlash);
|
||||
const localeFromSegment = LOCALE_BY_URL_SEGMENT.get(firstSegment);
|
||||
|
||||
if (localeFromSegment === SOURCE_LOCALE) {
|
||||
const target = request.nextUrl.clone();
|
||||
target.pathname = canonicalPath;
|
||||
target.pathname = tail;
|
||||
target.search = search;
|
||||
return NextResponse.redirect(target, 301);
|
||||
}
|
||||
|
||||
if (localeFromPath !== undefined) {
|
||||
const cookieLocale = request.cookies.get(LOCALE_COOKIE_NAME)?.value;
|
||||
if (cookieLocale === localeFromPath) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
const response = NextResponse.next();
|
||||
setLocaleCookie(response, localeFromPath);
|
||||
return response;
|
||||
if (localeFromSegment !== undefined) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (knownLocaleFromPath !== undefined) {
|
||||
const canonicalPath = firstSlash === -1 ? '/' : pathname.slice(firstSlash);
|
||||
if (KNOWN_PUBLIC_APP_LOCALE_BY_RAW.has(firstSegment)) {
|
||||
const target = request.nextUrl.clone();
|
||||
target.pathname = canonicalPath;
|
||||
target.pathname = tail;
|
||||
target.search = search;
|
||||
return NextResponse.redirect(target, 308);
|
||||
}
|
||||
|
||||
const detected = detectLocale({
|
||||
cookieValue: request.cookies.get(LOCALE_COOKIE_NAME)?.value,
|
||||
acceptLanguageHeader: request.headers.get('accept-language') ?? undefined,
|
||||
});
|
||||
|
||||
if (detected === SOURCE_LOCALE) {
|
||||
const target = request.nextUrl.clone();
|
||||
target.pathname = `/${SOURCE_LOCALE}${pathname === '/' ? '' : pathname}`;
|
||||
const response = NextResponse.rewrite(target);
|
||||
const cookieLocale = request.cookies.get(LOCALE_COOKIE_NAME)?.value;
|
||||
if (cookieLocale !== SOURCE_LOCALE) {
|
||||
setLocaleCookie(response, SOURCE_LOCALE);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const target = request.nextUrl.clone();
|
||||
target.pathname = `/${detected}${pathname === '/' ? '' : pathname}`;
|
||||
target.pathname = `/${SOURCE_LOCALE}${pathname === '/' ? '' : pathname}`;
|
||||
target.search = search;
|
||||
return NextResponse.redirect(target, 308);
|
||||
return NextResponse.rewrite(target);
|
||||
};
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const FAQ_DATA: FaqDataType = {
|
||||
fontFamily: 'sans',
|
||||
},
|
||||
answer: {
|
||||
text: msg`Yes. Twenty is the #1 open source CRM on GitHub. You can self-host to fully own your infrastructure, or run it on our managed cloud for a zero-ops setup.`,
|
||||
text: msg`Yes. Twenty is the #1 Open Source CRM on GitHub. You can self-host to fully own your infrastructure, or run it on our managed cloud for a zero-ops setup.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ArrowRightUpIcon, SOCIAL_ICONS } from '@/icons';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import type { FooterSocialLinkType } from '@/sections/Footer/types';
|
||||
import { LocaleSwitcher } from '@/sections/Footer/components/LocaleSwitcher';
|
||||
import { theme } from '@/theme';
|
||||
import { Separator } from '@base-ui/react/separator';
|
||||
import { styled } from '@linaria/react';
|
||||
@@ -16,13 +17,15 @@ const BottomGrid = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Copyright = styled.div`
|
||||
color: ${theme.colors.primary.text[100]};
|
||||
font-family: ${theme.font.family.mono};
|
||||
const CopyrightRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${theme.spacing(4)};
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
justify-self: start;
|
||||
text-transform: uppercase;
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: ${theme.breakpoints.md}px) {
|
||||
grid-column: 1;
|
||||
@@ -30,6 +33,12 @@ const Copyright = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Copyright = styled.div`
|
||||
color: ${theme.colors.primary.text[100]};
|
||||
font-family: ${theme.font.family.mono};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const SocialNav = styled.nav`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -83,7 +92,10 @@ type BottomProps = {
|
||||
export function Bottom({ copyright, links, renderText }: BottomProps) {
|
||||
return (
|
||||
<BottomGrid>
|
||||
<Copyright>{renderText(copyright)}</Copyright>
|
||||
<CopyrightRow>
|
||||
<Copyright>{renderText(copyright)}</Copyright>
|
||||
<LocaleSwitcher />
|
||||
</CopyrightRow>
|
||||
<SocialNav aria-label="Social media">
|
||||
{links.map((link, index) => {
|
||||
const IconComponent = SOCIAL_ICONS[link.icon];
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
'use client';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { IconCheck, IconChevronDown, IconWorld } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { type AppLocale } from 'twenty-shared/translations';
|
||||
|
||||
import { WEBSITE_LOCALE_LIST } from '@/lib/i18n/app-locale-set';
|
||||
import { localizeHref } from '@/lib/i18n/localize-href';
|
||||
import {
|
||||
getEnglishLocaleName,
|
||||
getNativeLocaleName,
|
||||
} from '@/lib/i18n/locale-display-names';
|
||||
import { useLocale } from '@/lib/i18n/use-locale';
|
||||
import { useUnlocalizedPathname } from '@/lib/i18n/use-unlocalized-pathname';
|
||||
import { theme } from '@/theme';
|
||||
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
type LocaleEntry = {
|
||||
locale: AppLocale;
|
||||
nativeName: string;
|
||||
englishName: string;
|
||||
};
|
||||
|
||||
const ALL_LOCALES: readonly LocaleEntry[] = WEBSITE_LOCALE_LIST.map(
|
||||
(locale) => ({
|
||||
locale,
|
||||
nativeName: getNativeLocaleName(locale),
|
||||
englishName: getEnglishLocaleName(locale),
|
||||
}),
|
||||
).sort((a, b) => a.nativeName.localeCompare(b.nativeName));
|
||||
|
||||
const matchesQuery = (entry: LocaleEntry, normalisedQuery: string): boolean =>
|
||||
entry.nativeName.toLocaleLowerCase().includes(normalisedQuery) ||
|
||||
entry.englishName.toLocaleLowerCase().includes(normalisedQuery);
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TriggerButton = styled.button`
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid ${theme.colors.primary.border[40]};
|
||||
border-radius: ${theme.radius(1)};
|
||||
color: ${theme.colors.primary.text[100]};
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(3)};
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
gap: ${theme.spacing(2)};
|
||||
line-height: 1;
|
||||
padding: ${theme.spacing(2)} ${theme.spacing(3)};
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded='true'] {
|
||||
border-color: ${theme.colors.primary.border[100]};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid ${theme.colors.highlight[100]};
|
||||
outline-offset: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
const TriggerLabel = styled.span`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const Popup = styled.div`
|
||||
background: #0c0c0c;
|
||||
border: 1px solid ${theme.colors.secondary.border[20]};
|
||||
border-radius: ${theme.radius(2)};
|
||||
bottom: calc(100% + ${theme.spacing(2)});
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
left: 0;
|
||||
max-width: 320px;
|
||||
min-width: 240px;
|
||||
overflow: hidden;
|
||||
padding: ${theme.spacing(2)};
|
||||
position: absolute;
|
||||
z-index: ${theme.zIndex.modal};
|
||||
`;
|
||||
|
||||
const SearchInput = styled.input`
|
||||
background: ${theme.colors.secondary.text[10]};
|
||||
border: 1px solid ${theme.colors.secondary.border[20]};
|
||||
border-radius: ${theme.radius(1)};
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(3)};
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
padding: ${theme.spacing(2)} ${theme.spacing(3)};
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: ${theme.colors.secondary.text[60]};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid ${theme.colors.highlight[100]};
|
||||
outline-offset: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemList = styled.nav`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const ItemLink = styled(Link)`
|
||||
align-items: center;
|
||||
border-radius: ${theme.radius(1)};
|
||||
color: ${theme.colors.secondary.text[100]};
|
||||
display: flex;
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(3)};
|
||||
gap: ${theme.spacing(2)};
|
||||
justify-content: space-between;
|
||||
padding: ${theme.spacing(2)} ${theme.spacing(3)};
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colors.secondary.text[10]};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid ${theme.colors.highlight[100]};
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&[aria-current='true'] {
|
||||
background: ${theme.colors.secondary.text[10]};
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemPrimary = styled.span`
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
const ItemSecondary = styled.span`
|
||||
color: ${theme.colors.secondary.text[60]};
|
||||
flex: 1;
|
||||
font-size: ${theme.font.size(2)};
|
||||
margin-left: ${theme.spacing(2)};
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
const Empty = styled.div`
|
||||
color: ${theme.colors.secondary.text[60]};
|
||||
font-family: ${theme.font.family.sans};
|
||||
font-size: ${theme.font.size(3)};
|
||||
padding: ${theme.spacing(3)};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export function LocaleSwitcher() {
|
||||
const currentLocale = useLocale();
|
||||
const unlocalizedPathname = useUnlocalizedPathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const trimmed = query.trim().toLocaleLowerCase();
|
||||
if (trimmed.length === 0) return ALL_LOCALES;
|
||||
return ALL_LOCALES.filter((entry) => matchesQuery(entry, trimmed));
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (wrapperRef.current?.contains(event.target as Node)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && ALL_LOCALES.length > SEARCH_THRESHOLD) {
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (ALL_LOCALES.length < 2) return null;
|
||||
|
||||
const triggerLabel = getNativeLocaleName(currentLocale);
|
||||
const showSearch = ALL_LOCALES.length > SEARCH_THRESHOLD;
|
||||
|
||||
const handleToggle = () => {
|
||||
setOpen((prev) => {
|
||||
if (prev) setQuery('');
|
||||
return !prev;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelect = () => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
const handleQueryChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper ref={wrapperRef}>
|
||||
<TriggerButton
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Change language"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<IconWorld size={14} aria-hidden />
|
||||
<TriggerLabel>{triggerLabel}</TriggerLabel>
|
||||
<IconChevronDown size={12} aria-hidden />
|
||||
</TriggerButton>
|
||||
{open && (
|
||||
<Popup role="dialog" aria-label="Choose a language">
|
||||
{showSearch && (
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
placeholder="Search language…"
|
||||
aria-label="Search languages"
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
/>
|
||||
)}
|
||||
<ItemList aria-label="Available languages">
|
||||
{filteredItems.map(({ locale, nativeName, englishName }) => {
|
||||
const isActive = locale === currentLocale;
|
||||
return (
|
||||
<ItemLink
|
||||
key={locale}
|
||||
href={localizeHref(locale, unlocalizedPathname)}
|
||||
hrefLang={locale}
|
||||
lang={locale}
|
||||
prefetch={false}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
<ItemPrimary>{nativeName}</ItemPrimary>
|
||||
{englishName !== nativeName && (
|
||||
<ItemSecondary>{englishName}</ItemSecondary>
|
||||
)}
|
||||
{isActive && <IconCheck size={14} aria-hidden />}
|
||||
</ItemLink>
|
||||
);
|
||||
})}
|
||||
{filteredItems.length === 0 && <Empty>No matches</Empty>}
|
||||
</ItemList>
|
||||
</Popup>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/i18n/use-locale', () => ({
|
||||
useLocale: jest.fn(),
|
||||
}));
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { useLocale } from '@/lib/i18n/use-locale';
|
||||
import { LocaleSwitcher } from '@/sections/Footer/components/LocaleSwitcher';
|
||||
|
||||
const mockUsePathname = usePathname as jest.MockedFunction<typeof usePathname>;
|
||||
const mockUseLocale = useLocale as jest.MockedFunction<typeof useLocale>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('LocaleSwitcher', () => {
|
||||
it('renders a labelled trigger button showing the active locale in its native name', () => {
|
||||
mockUsePathname.mockReturnValue('/pricing');
|
||||
mockUseLocale.mockReturnValue('en');
|
||||
|
||||
const html = renderToStaticMarkup(<LocaleSwitcher />);
|
||||
|
||||
expect(html).toMatch(/aria-label="Change language"/i);
|
||||
expect(html).toContain('>English<');
|
||||
});
|
||||
|
||||
it('shows the native French label when the active locale is French', () => {
|
||||
mockUsePathname.mockReturnValue('/fr/pricing');
|
||||
mockUseLocale.mockReturnValue('fr-FR');
|
||||
|
||||
const html = renderToStaticMarkup(<LocaleSwitcher />);
|
||||
|
||||
expect(html).toContain('>Français<');
|
||||
});
|
||||
|
||||
it('renders the trigger as a real button so it is keyboard-focusable and screen-reader-discoverable', () => {
|
||||
mockUsePathname.mockReturnValue('/pricing');
|
||||
mockUseLocale.mockReturnValue('en');
|
||||
|
||||
const html = renderToStaticMarkup(<LocaleSwitcher />);
|
||||
const triggerTag = html.match(
|
||||
/<button[^>]*aria-label="Change language"[^>]*>/i,
|
||||
);
|
||||
|
||||
expect(triggerTag).not.toBeNull();
|
||||
expect(triggerTag?.[0]).toMatch(/type="button"/);
|
||||
expect(triggerTag?.[0]).toMatch(/aria-haspopup="dialog"/);
|
||||
expect(triggerTag?.[0]).toMatch(/aria-expanded="false"/);
|
||||
});
|
||||
|
||||
it('does not render the popup contents server-side when closed (avoids hydration drift)', () => {
|
||||
mockUsePathname.mockReturnValue('/pricing');
|
||||
mockUseLocale.mockReturnValue('en');
|
||||
|
||||
const html = renderToStaticMarkup(<LocaleSwitcher />);
|
||||
|
||||
expect(html).not.toContain('href="/pricing"');
|
||||
expect(html).not.toContain('href="/fr/pricing"');
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export type HeroHeadingProps = HeadingProps & {
|
||||
};
|
||||
|
||||
export function Heading({
|
||||
as,
|
||||
as = 'h1',
|
||||
children,
|
||||
className,
|
||||
page,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { Heading as HeroHeading } from '@/sections/Hero/components/Heading';
|
||||
import { Pages } from '@/lib/pages';
|
||||
|
||||
describe('Hero.Heading defaults', () => {
|
||||
it('renders <h1> by default', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HeroHeading page={Pages.Home}>Page title</HeroHeading>,
|
||||
);
|
||||
expect(html).toContain('<h1');
|
||||
expect(html).toContain('Page title');
|
||||
expect(html).toContain('</h1>');
|
||||
});
|
||||
|
||||
it('still allows callers to override as for legacy or special cases', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<HeroHeading as="h2" page={Pages.Home}>
|
||||
Forced h2
|
||||
</HeroHeading>,
|
||||
);
|
||||
expect(html).toContain('<h2');
|
||||
expect(html).not.toContain('<h1');
|
||||
});
|
||||
});
|
||||
@@ -56,7 +56,7 @@ export function ReleaseEntry({ content, date, release }: ReleaseEntryProps) {
|
||||
const displayDate = formatReleaseDisplayDate(date);
|
||||
|
||||
return (
|
||||
<ArticleRow>
|
||||
<ArticleRow id={release}>
|
||||
<MetaColumn>
|
||||
<Version>{release}</Version>
|
||||
<DateText>{displayDate || 'TBD'}</DateText>
|
||||
|
||||
Reference in New Issue
Block a user