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:
Abdullah.
2026-05-05 22:28:08 +05:00
committed by GitHub
parent d5c1f4e10a
commit 8253fb6e6d
33 changed files with 1040 additions and 132 deletions
@@ -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.`,
};
@@ -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,
+14 -52
View File
@@ -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>
);
}
@@ -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>