feat(website): ship all documentation locales (multi-locale site) (#22257)

## What

The marketing site now serves every language the **documentation** ships
— 14 locales (`en, fr, ar, cs, de, es, it, ja, ko, pt, ro, ru, tr, zh`),
up from 3 (`en, es, fr`).

## How

- **Single source of truth.** `WEBSITE_LOCALE_LIST` derives directly
from `DOCUMENTATION_SUPPORTED_LANGUAGES` (`twenty-shared/constants`).
Add a documentation language → it flows to the website automatically.
- **Off `APP_LOCALES` entirely.** The website locale type is now
`DocumentationSupportedLanguage` (short codes), so a locale **is** its
URL segment — no short↔full mapping, and no `pt-BR`/`zh-CN` ambiguity to
resolve.
- **Removed the indirection this exposed** (it only existed because
`AppLocale` was a superset of the deployed set):
- `locale-to-url-segment` / `locale-by-url-segment` (locale == segment)
- `get-locale-messages` pass-through → callers read `MESSAGES_BY_LOCALE`
directly
- the `messages-by-locale` runtime guard → a total
`Record<DocumentationSupportedLanguage, Messages>` (a missing catalog is
now a **compile** error, not a runtime throw)
- `isWebsiteLocale` → a `string → DocumentationSupportedLanguage` type
guard
  - the vestigial language-code `split('-')` in `locale-display-name`

## Catalogs

- Renamed `es-ES → es`, `fr-FR → fr`; added 11 new locales (untranslated
for now → **English fallback**).
- `crowdin-website.yml` switched to `%two_letters_code%`.
- Regenerating catalogs also synced `en.po` with current source
(`Boolean` / `Date & Time` / removed `Fields widget` from the
already-merged #22249).
- `ci-website` is unchanged — no `lingui:compile` step added; catalogs
stay committed.

## Testing

- `typecheck` · `lint` (check-conventions + oxlint + oxfmt) · 347/347
tests — all green. PR CI runs exactly lint + typecheck + test.

## Follow-up (out of repo)

Enable the 11 languages on **Crowdin project 4** so `website-i18n-pull`
backfills real translations. Until then, the new locales render with
English fallback (correct behavior).
This commit is contained in:
Abdullah.
2026-06-28 14:39:59 +05:00
committed by GitHub
parent d81b3c3fa3
commit 012af11d77
56 changed files with 75426 additions and 234 deletions
+1 -10
View File
@@ -8,16 +8,7 @@ project_id: 4
preserve_hierarchy: true
base_url: 'https://twenty.api.crowdin.com'
base_path: ..
languages_mapping:
locale:
fr: fr-FR
files:
#
# Source file - PO file for Lingui
#
- source: packages/twenty-website/src/locales/en.po
#
# Translation files path
#
translation: '%original_path%/%locale%.po'
translation: '%original_path%/%two_letters_code%.po'
+3 -3
View File
@@ -1,14 +1,14 @@
import { defineConfig } from '@lingui/conf';
import { formatter } from '@lingui/format-po';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { DOCUMENTATION_DEFAULT_LANGUAGE } from 'twenty-shared/constants';
import { WEBSITE_LOCALE_LIST } from './src/platform/i18n/website-locale-list';
export default defineConfig({
sourceLocale: SOURCE_LOCALE,
sourceLocale: DOCUMENTATION_DEFAULT_LANGUAGE,
locales: [...WEBSITE_LOCALE_LIST],
fallbackLocales: {
default: SOURCE_LOCALE,
default: DOCUMENTATION_DEFAULT_LANGUAGE,
},
catalogs: [
{
+1 -5
View File
@@ -2,13 +2,9 @@ import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
import path from 'path';
import { localeToUrlSegment } from './src/platform/i18n/locale-to-url-segment';
import { WEBSITE_LOCALE_LIST } from './src/platform/i18n/website-locale-list';
import { buildLocaleRewrites } from './src/platform/routing/locale-rewrite-patterns';
const DEPLOYED_LOCALE_URL_SEGMENTS =
WEBSITE_LOCALE_LIST.map(localeToUrlSegment);
const SECURITY_HEADERS: { key: string; value: string }[] = [
{
key: 'Strict-Transport-Security',
@@ -87,7 +83,7 @@ const nextConfig: LinariaConfig = {
// tree; redirects canonicalize away explicit source-locale prefixes.
async rewrites() {
return {
beforeFiles: buildLocaleRewrites(DEPLOYED_LOCALE_URL_SEGMENTS),
beforeFiles: buildLocaleRewrites(WEBSITE_LOCALE_LIST),
};
},
async redirects() {
@@ -3,14 +3,13 @@ import { Aleo, Azeret_Mono, Host_Grotesk, VT323 } from 'next/font/google';
import localFont from 'next/font/local';
import { type ReactNode } from 'react';
import { getLocaleMessages } from '@/platform/i18n/get-locale-messages';
import { MESSAGES_BY_LOCALE } from '@/platform/i18n/messages-by-locale';
import {
getRouteI18n,
type LocaleRouteParams,
} from '@/platform/i18n/get-route-i18n';
import { ContactCalModalRoot } from '@/contact-cal';
import { I18nProvider } from '@/platform/i18n/I18nProvider';
import { localeToUrlSegment } from '@/platform/i18n/locale-to-url-segment';
import { resolveLocaleParam } from '@/platform/i18n/resolve-locale-param';
import { WEBSITE_LOCALE_LIST } from '@/platform/i18n/website-locale-list';
import { color, fontFamily, tokenCssVariables } from '@/tokens';
@@ -103,9 +102,7 @@ const globalStyles = css`
export const dynamicParams = false;
export const generateStaticParams = (): LocaleRouteParams[] =>
WEBSITE_LOCALE_LIST.map((locale) => ({
locale: localeToUrlSegment(locale),
}));
WEBSITE_LOCALE_LIST.map((locale) => ({ locale }));
const LocaleLayout = async ({
children,
@@ -122,7 +119,7 @@ const LocaleLayout = async ({
<body
className={`${tokenCssVariables} ${globalStyles} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable} ${inter.variable}`}
>
<I18nProvider locale={locale} messages={getLocaleMessages(locale)}>
<I18nProvider locale={locale} messages={MESSAGES_BY_LOCALE[locale]}>
<ContactCalModalRoot>{children}</ContactCalModalRoot>
</I18nProvider>
</body>
+19 -10
View File
@@ -1,7 +1,9 @@
import { type MetadataRoute } from 'next';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import {
DOCUMENTATION_DEFAULT_LANGUAGE,
type DocumentationSupportedLanguage,
} from 'twenty-shared/constants';
import { localeToUrlSegment } from '@/platform/i18n/locale-to-url-segment';
import { WEBSITE_LOCALE_LIST } from '@/platform/i18n/website-locale-list';
import { getIndexedWebsiteRoutes } from '@/platform/routing';
import { WEBSITE_ROUTE_FAMILY_LIST } from '@/platform/routing/website-route-family-list';
@@ -9,33 +11,40 @@ import { getSiteUrl } from '@/platform/seo';
const SITE_URL = getSiteUrl();
const buildLocalizedUrl = (locale: AppLocale, path: string): string => {
const prefix =
locale === SOURCE_LOCALE ? '' : `/${localeToUrlSegment(locale)}`;
const buildLocalizedUrl = (
locale: DocumentationSupportedLanguage,
path: string,
): string => {
const prefix = locale === DOCUMENTATION_DEFAULT_LANGUAGE ? '' : `/${locale}`;
const tail = path === '/' ? '' : path;
return `${SITE_URL}${prefix}${tail}`;
};
const buildLanguageAlternates = (
path: string,
locales: readonly AppLocale[],
locales: readonly DocumentationSupportedLanguage[],
): Record<string, string> => {
const alternates: Record<string, string> = {};
for (const locale of locales) {
alternates[locale] = buildLocalizedUrl(locale, path);
}
alternates['x-default'] = buildLocalizedUrl(SOURCE_LOCALE, path);
alternates['x-default'] = buildLocalizedUrl(
DOCUMENTATION_DEFAULT_LANGUAGE,
path,
);
return alternates;
};
const localesFor = (
localeMode: 'all' | 'source' | undefined,
): readonly AppLocale[] =>
localeMode === 'source' ? [SOURCE_LOCALE] : WEBSITE_LOCALE_LIST;
): readonly DocumentationSupportedLanguage[] =>
localeMode === 'source'
? [DOCUMENTATION_DEFAULT_LANGUAGE]
: WEBSITE_LOCALE_LIST;
const entriesFor = (
path: string,
locales: readonly AppLocale[],
locales: readonly DocumentationSupportedLanguage[],
shared: {
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'];
priority: number;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -14
View File
@@ -1051,6 +1051,11 @@ msgstr "Bolivia 🇧🇴"
msgid "Book a call"
msgstr "Book a call"
#. js-lingui-id: bOwPjT
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "Boolean"
msgstr "Boolean"
#. js-lingui-id: Wzdd8u
#: src/partner-application/data/partner-country-options.ts
msgid "Bosnia & Herzegovina 🇧🇦"
@@ -1898,10 +1903,10 @@ msgstr "Data Model Diagram"
msgid "Data modeling · Migrations · No-code workflows · Dashboards · SSO/SCIM · Integrations"
msgstr "Data modeling · Migrations · No-code workflows · Dashboards · SSO/SCIM · Integrations"
#. js-lingui-id: Wv92XG
#. js-lingui-id: JvUngl
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "Date and Time"
msgstr "Date and Time"
msgid "Date & Time"
msgstr "Date & Time"
#. js-lingui-id: dSuMNg
#: src/platform/releases/ReleaseNotes.tsx
@@ -2469,11 +2474,6 @@ msgstr "fields"
msgid "Fields"
msgstr "Fields"
#. js-lingui-id: 9k2rvq
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
msgid "Fields widget"
msgstr "Fields widget"
#. js-lingui-id: z+q1Th
#: src/partner-application/data/partner-country-options.ts
msgid "Fiji 🇫🇯"
@@ -2738,6 +2738,7 @@ msgstr "Gambia 🇬🇲"
#. js-lingui-id: Weq9zb
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "General"
msgstr "General"
@@ -3141,7 +3142,6 @@ msgid "Indonesian"
msgstr "Indonesian"
#. js-lingui-id: S8gy7K
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/case-study-detail/CaseStudyHighlights.tsx
msgid "Industry"
msgstr "Industry"
@@ -4744,6 +4744,7 @@ msgid "Revamped Side Panel"
msgstr "Revamped Side Panel"
#. js-lingui-id: 8YBH95
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "Revenue"
msgstr "Revenue"
@@ -5846,11 +5847,6 @@ msgstr "Trigger"
msgid "Trinidad & Tobago 🇹🇹"
msgstr "Trinidad & Tobago 🇹🇹"
#. js-lingui-id: rfp3gh
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "True/False"
msgstr "True/False"
#. js-lingui-id: tdZpCd
#: src/sections/footer/footer.data.ts
msgid "Trust Center"
@@ -1056,6 +1056,11 @@ msgstr "Bolivia 🇧🇴"
msgid "Book a call"
msgstr "Reservar una llamada"
#. js-lingui-id: bOwPjT
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "Boolean"
msgstr ""
#. js-lingui-id: Wzdd8u
#: src/partner-application/data/partner-country-options.ts
msgid "Bosnia & Herzegovina 🇧🇦"
@@ -1903,9 +1908,9 @@ msgstr "Diagrama de modelo de datos"
msgid "Data modeling · Migrations · No-code workflows · Dashboards · SSO/SCIM · Integrations"
msgstr "Modelado de datos · Migraciones · Flujos de trabajo sin código · Paneles · SSO/SCIM · Integraciones"
#. js-lingui-id: Wv92XG
#. js-lingui-id: JvUngl
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "Date and Time"
msgid "Date & Time"
msgstr ""
#. js-lingui-id: dSuMNg
@@ -2474,11 +2479,6 @@ msgstr ""
msgid "Fields"
msgstr ""
#. js-lingui-id: 9k2rvq
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
msgid "Fields widget"
msgstr ""
#. js-lingui-id: z+q1Th
#: src/partner-application/data/partner-country-options.ts
msgid "Fiji 🇫🇯"
@@ -2743,6 +2743,7 @@ msgstr "Gambia 🇬🇲"
#. js-lingui-id: Weq9zb
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "General"
msgstr ""
@@ -3146,7 +3147,6 @@ msgid "Indonesian"
msgstr "Indonesio"
#. js-lingui-id: S8gy7K
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/case-study-detail/CaseStudyHighlights.tsx
msgid "Industry"
msgstr "Industria"
@@ -4749,6 +4749,7 @@ msgid "Revamped Side Panel"
msgstr "Panel lateral renovado"
#. js-lingui-id: 8YBH95
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "Revenue"
msgstr ""
@@ -5851,11 +5852,6 @@ msgstr ""
msgid "Trinidad & Tobago 🇹🇹"
msgstr "Trinidad y Tobago 🇹🇹"
#. js-lingui-id: rfp3gh
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "True/False"
msgstr ""
#. js-lingui-id: tdZpCd
#: src/sections/footer/footer.data.ts
msgid "Trust Center"
@@ -1054,6 +1054,11 @@ msgstr "Bolivie 🇧🇴"
msgid "Book a call"
msgstr "Réserver un appel"
#. js-lingui-id: bOwPjT
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "Boolean"
msgstr ""
#. js-lingui-id: Wzdd8u
#: src/partner-application/data/partner-country-options.ts
msgid "Bosnia & Herzegovina 🇧🇦"
@@ -1901,9 +1906,9 @@ msgstr "Schéma du modèle de données"
msgid "Data modeling · Migrations · No-code workflows · Dashboards · SSO/SCIM · Integrations"
msgstr "Modélisation des données · Migrations · Workflows no-code · Tableaux de bord · SSO/SCIM · Intégrations"
#. js-lingui-id: Wv92XG
#. js-lingui-id: JvUngl
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "Date and Time"
msgid "Date & Time"
msgstr ""
#. js-lingui-id: dSuMNg
@@ -2472,11 +2477,6 @@ msgstr ""
msgid "Fields"
msgstr ""
#. js-lingui-id: 9k2rvq
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
msgid "Fields widget"
msgstr ""
#. js-lingui-id: z+q1Th
#: src/partner-application/data/partner-country-options.ts
msgid "Fiji 🇫🇯"
@@ -2741,6 +2741,7 @@ msgstr "Gambie 🇬🇲"
#. js-lingui-id: Weq9zb
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "General"
msgstr ""
@@ -3144,7 +3145,6 @@ msgid "Indonesian"
msgstr "Indonésien"
#. js-lingui-id: S8gy7K
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/case-study-detail/CaseStudyHighlights.tsx
msgid "Industry"
msgstr "Secteur"
@@ -4747,6 +4747,7 @@ msgid "Revamped Side Panel"
msgstr "Panneau latéral repensé"
#. js-lingui-id: 8YBH95
#: src/sections/product-stepper/LayoutVisual/LayoutVisual.tsx
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "Revenue"
msgstr ""
@@ -5849,11 +5850,6 @@ msgstr ""
msgid "Trinidad & Tobago 🇹🇹"
msgstr "Trinité-et-Tobago 🇹🇹"
#. js-lingui-id: rfp3gh
#: src/sections/product-stepper/LayoutVisual/data/layout-data.ts
msgid "True/False"
msgstr ""
#. js-lingui-id: tdZpCd
#: src/sections/footer/footer.data.ts
msgid "Trust Center"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,18 +3,16 @@
import { setupI18n, type Messages } from '@lingui/core';
import { I18nProvider as LinguiI18nProvider } from '@lingui/react';
import { useMemo, type ReactNode } from 'react';
import { type AppLocale } from 'twenty-shared/translations';
import { type DocumentationSupportedLanguage } from 'twenty-shared/constants';
import { LocaleContext } from './locale-context';
export type I18nProviderProps = {
children: ReactNode;
locale: AppLocale;
locale: DocumentationSupportedLanguage;
messages: Messages;
};
// Catalogs arrive as props from the server layout: the client bundle never
// imports compiled catalogs directly.
export function I18nProvider({
children,
locale,
@@ -1,10 +1,12 @@
import { setupI18n, type I18n } from '@lingui/core';
import { type AppLocale } from 'twenty-shared/translations';
import { type DocumentationSupportedLanguage } from 'twenty-shared/constants';
import { getLocaleMessages } from './get-locale-messages';
import { MESSAGES_BY_LOCALE } from './messages-by-locale';
export const createI18nInstance = (locale: AppLocale): I18n =>
export const createI18nInstance = (
locale: DocumentationSupportedLanguage,
): I18n =>
setupI18n({
locale,
messages: { [locale]: getLocaleMessages(locale) },
messages: { [locale]: MESSAGES_BY_LOCALE[locale] },
});
@@ -1,7 +0,0 @@
import { type Messages } from '@lingui/core';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { MESSAGES_BY_LOCALE } from './messages-by-locale';
export const getLocaleMessages = (locale: AppLocale): Messages =>
MESSAGES_BY_LOCALE[locale] ?? MESSAGES_BY_LOCALE[SOURCE_LOCALE] ?? {};
@@ -1,9 +1,6 @@
import { LOCALE_BY_URL_SEGMENT } from './locale-by-url-segment';
export const hrefSegments: {
findFirstSegmentEnd: (path: string) => number;
buildTailFromSegmentEnd: (path: string, segmentEnd: number) => string;
isLocalePrefixSegment: (segment: string) => boolean;
} = {
findFirstSegmentEnd: (path) => {
for (let i = 1; i < path.length; i += 1) {
@@ -18,5 +15,4 @@ export const hrefSegments: {
if (tail.startsWith('?') || tail.startsWith('#')) return `/${tail}`;
return tail;
},
isLocalePrefixSegment: (segment) => LOCALE_BY_URL_SEGMENT.has(segment),
};
@@ -1,14 +1,10 @@
// Client-safe barrel: server-only modules (get-server-i18n, get-route-i18n)
// are imported by path so client components can use this barrel freely.
export { createI18nInstance } from './create-i18n-instance';
export { getLocaleMessages } from './get-locale-messages';
export { I18nProvider, type I18nProviderProps } from './I18nProvider';
export { isWebsiteLocale } from './is-website-locale';
export { localeDisplayName } from './locale-display-name';
export { LOCALE_BY_URL_SEGMENT } from './locale-by-url-segment';
export { localeToUrlSegment } from './locale-to-url-segment';
export { LocalizedLink, type LocalizedLinkProps } from './LocalizedLink';
export { localizeHref } from './localize-href';
export { MESSAGES_BY_LOCALE } from './messages-by-locale';
export { resolveLocaleParam } from './resolve-locale-param';
export { stripLocale } from './strip-locale';
export { useLocale } from './use-locale';
@@ -1,8 +1,9 @@
import { type AppLocale } from 'twenty-shared/translations';
import { type DocumentationSupportedLanguage } from 'twenty-shared/constants';
import { WEBSITE_LOCALE_LIST } from './website-locale-list';
const WEBSITE_LOCALE_SET: ReadonlySet<AppLocale> = new Set(WEBSITE_LOCALE_LIST);
const WEBSITE_LOCALE_SET: ReadonlySet<string> = new Set(WEBSITE_LOCALE_LIST);
export const isWebsiteLocale = (locale: AppLocale): boolean =>
WEBSITE_LOCALE_SET.has(locale);
export const isWebsiteLocale = (
value: string,
): value is DocumentationSupportedLanguage => WEBSITE_LOCALE_SET.has(value);
@@ -1,8 +0,0 @@
import { type AppLocale } from 'twenty-shared/translations';
import { localeToUrlSegment } from './locale-to-url-segment';
import { WEBSITE_LOCALE_LIST } from './website-locale-list';
export const LOCALE_BY_URL_SEGMENT: ReadonlyMap<string, AppLocale> = new Map(
WEBSITE_LOCALE_LIST.map((locale) => [localeToUrlSegment(locale), locale]),
);
@@ -1,6 +1,11 @@
'use client';
import { createContext } from 'react';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import {
DOCUMENTATION_DEFAULT_LANGUAGE,
type DocumentationSupportedLanguage,
} from 'twenty-shared/constants';
export const LocaleContext = createContext<AppLocale>(SOURCE_LOCALE);
export const LocaleContext = createContext<DocumentationSupportedLanguage>(
DOCUMENTATION_DEFAULT_LANGUAGE,
);
@@ -1,6 +1,4 @@
import { type AppLocale } from 'twenty-shared/translations';
const languageCode = (locale: AppLocale): string => locale.split('-')[0];
import { type DocumentationSupportedLanguage } from 'twenty-shared/constants';
const capitalizeFirst = (value: string): string =>
value.length === 0
@@ -8,15 +6,15 @@ const capitalizeFirst = (value: string): string =>
: value.charAt(0).toLocaleUpperCase() + value.slice(1);
export const localeDisplayName: {
native: (locale: AppLocale) => string;
english: (locale: AppLocale) => string;
native: (locale: DocumentationSupportedLanguage) => string;
english: (locale: DocumentationSupportedLanguage) => string;
} = {
native: (locale) => {
const display = new Intl.DisplayNames([locale], { type: 'language' });
return capitalizeFirst(display.of(languageCode(locale)) ?? locale);
return capitalizeFirst(display.of(locale) ?? locale);
},
english: (locale) => {
const display = new Intl.DisplayNames(['en'], { type: 'language' });
return display.of(languageCode(locale)) ?? locale;
return display.of(locale) ?? locale;
},
};
@@ -1,19 +0,0 @@
import { type AppLocale } from 'twenty-shared/translations';
// The source locale is served unprefixed; regional codes get a short segment.
const URL_SEGMENT_OVERRIDES: Partial<Record<AppLocale, string>> = {
en: 'en',
'fr-FR': 'fr',
'es-ES': 'es',
};
export const localeToUrlSegment = (locale: AppLocale): string => {
const segment = URL_SEGMENT_OVERRIDES[locale];
if (segment === undefined) {
// Fail at build time rather than shipping verbose URLs for a new locale.
throw new Error(
`No URL segment defined for locale "${locale}" — add it to URL_SEGMENT_OVERRIDES.`,
);
}
return segment;
};
@@ -7,26 +7,26 @@ describe('localizeHref', () => {
});
it('prefixes non-source locales with their url segment', () => {
expect(localizeHref('fr-FR', '/pricing')).toBe('/fr/pricing');
expect(localizeHref('fr-FR', '/')).toBe('/fr');
expect(localizeHref('fr', '/pricing')).toBe('/fr/pricing');
expect(localizeHref('fr', '/')).toBe('/fr');
});
it('re-localizes an already-prefixed href', () => {
expect(localizeHref('es-ES', '/fr/pricing')).toBe('/es/pricing');
expect(localizeHref('es', '/fr/pricing')).toBe('/es/pricing');
expect(localizeHref('en', '/fr/pricing')).toBe('/pricing');
});
it('preserves query strings and hashes', () => {
expect(localizeHref('fr-FR', '/pricing?seat=5#faq')).toBe(
expect(localizeHref('fr', '/pricing?seat=5#faq')).toBe(
'/fr/pricing?seat=5#faq',
);
});
it('passes through external and protocol-relative urls', () => {
expect(localizeHref('fr-FR', 'https://example.com/a')).toBe(
expect(localizeHref('fr', 'https://example.com/a')).toBe(
'https://example.com/a',
);
expect(localizeHref('fr-FR', '//cdn.example.com/x')).toBe(
expect(localizeHref('fr', '//cdn.example.com/x')).toBe(
'//cdn.example.com/x',
);
});
@@ -1,23 +1,27 @@
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import {
DOCUMENTATION_DEFAULT_LANGUAGE,
type DocumentationSupportedLanguage,
} from 'twenty-shared/constants';
import { hrefSegments } from './href-segments';
import { isWebsiteLocale } from './is-website-locale';
import { localeToUrlSegment } from './locale-to-url-segment';
export const localizeHref = (locale: AppLocale, href: string): string => {
export const localizeHref = (
locale: DocumentationSupportedLanguage,
href: string,
): string => {
if (!href.startsWith('/') || href.startsWith('//')) return href;
const segmentEnd = hrefSegments.findFirstSegmentEnd(href);
const firstSegment = href.slice(1, segmentEnd);
const unprefixed = hrefSegments.isLocalePrefixSegment(firstSegment)
const unprefixed = isWebsiteLocale(firstSegment)
? hrefSegments.buildTailFromSegmentEnd(href, segmentEnd)
: href;
if (locale === SOURCE_LOCALE || !isWebsiteLocale(locale)) {
if (locale === DOCUMENTATION_DEFAULT_LANGUAGE) {
return unprefixed;
}
const segment = localeToUrlSegment(locale);
return unprefixed === '/' ? `/${segment}` : `/${segment}${unprefixed}`;
return unprefixed === '/' ? `/${locale}` : `/${locale}${unprefixed}`;
};
@@ -1,29 +1,37 @@
import { type Messages } from '@lingui/core';
import { type AppLocale } from 'twenty-shared/translations';
import { type DocumentationSupportedLanguage } from 'twenty-shared/constants';
import { messages as arMessages } from '@/locales/generated/ar';
import { messages as csMessages } from '@/locales/generated/cs';
import { messages as deMessages } from '@/locales/generated/de';
import { messages as enMessages } from '@/locales/generated/en';
import { messages as esMessages } from '@/locales/generated/es-ES';
import { messages as frMessages } from '@/locales/generated/fr-FR';
import { messages as esMessages } from '@/locales/generated/es';
import { messages as frMessages } from '@/locales/generated/fr';
import { messages as itMessages } from '@/locales/generated/it';
import { messages as jaMessages } from '@/locales/generated/ja';
import { messages as koMessages } from '@/locales/generated/ko';
import { messages as ptMessages } from '@/locales/generated/pt';
import { messages as roMessages } from '@/locales/generated/ro';
import { messages as ruMessages } from '@/locales/generated/ru';
import { messages as trMessages } from '@/locales/generated/tr';
import { messages as zhMessages } from '@/locales/generated/zh';
import { WEBSITE_LOCALE_LIST } from './website-locale-list';
const CATALOGS: Partial<Record<AppLocale, Messages>> = {
export const MESSAGES_BY_LOCALE: Record<
DocumentationSupportedLanguage,
Messages
> = {
en: enMessages,
'es-ES': esMessages,
'fr-FR': frMessages,
fr: frMessages,
ar: arMessages,
cs: csMessages,
de: deMessages,
es: esMessages,
it: itMessages,
ja: jaMessages,
ko: koMessages,
pt: ptMessages,
ro: roMessages,
ru: ruMessages,
tr: trMessages,
zh: zhMessages,
};
// Fail at module load if a locale was added to WEBSITE_LOCALE_LIST without
// importing its compiled catalog above — silent English fallbacks are bugs.
for (const locale of WEBSITE_LOCALE_LIST) {
if (CATALOGS[locale] === undefined) {
throw new Error(
`Missing compiled catalog for website locale "${locale}". Run ` +
`\`nx run twenty-website:lingui:compile\` and import ` +
`@/locales/generated/${locale} in messages-by-locale.ts.`,
);
}
}
export const MESSAGES_BY_LOCALE: Partial<Record<AppLocale, Messages>> =
CATALOGS;
@@ -1,12 +1,11 @@
import { notFound } from 'next/navigation';
import { type AppLocale } from 'twenty-shared/translations';
import { type DocumentationSupportedLanguage } from 'twenty-shared/constants';
import { LOCALE_BY_URL_SEGMENT } from './locale-by-url-segment';
import { isWebsiteLocale } from './is-website-locale';
// The only sanctioned way to turn a raw [locale] route param into an
// AppLocale. Unknown segments 404.
export const resolveLocaleParam = (raw: string): AppLocale => {
const locale = LOCALE_BY_URL_SEGMENT.get(raw);
if (locale === undefined) notFound();
return locale;
export const resolveLocaleParam = (
raw: string,
): DocumentationSupportedLanguage => {
if (!isWebsiteLocale(raw)) notFound();
return raw;
};
@@ -1,11 +1,12 @@
import { hrefSegments } from './href-segments';
import { isWebsiteLocale } from './is-website-locale';
export const stripLocale = (pathname: string): string => {
if (!pathname.startsWith('/')) return pathname;
const segmentEnd = hrefSegments.findFirstSegmentEnd(pathname);
const firstSegment = pathname.slice(1, segmentEnd);
if (!hrefSegments.isLocalePrefixSegment(firstSegment)) return pathname;
if (!isWebsiteLocale(firstSegment)) return pathname;
return hrefSegments.buildTailFromSegmentEnd(pathname, segmentEnd);
};
@@ -1,8 +1,9 @@
'use client';
import { useContext } from 'react';
import { type AppLocale } from 'twenty-shared/translations';
import { type DocumentationSupportedLanguage } from 'twenty-shared/constants';
import { LocaleContext } from './locale-context';
export const useLocale = (): AppLocale => useContext(LocaleContext);
export const useLocale = (): DocumentationSupportedLanguage =>
useContext(LocaleContext);
@@ -1,15 +1,7 @@
import {
APP_LOCALES,
SOURCE_LOCALE,
type AppLocale,
} from 'twenty-shared/translations';
DOCUMENTATION_SUPPORTED_LANGUAGES,
type DocumentationSupportedLanguage,
} from 'twenty-shared/constants';
// THE source of deployed website locales: URL segments, generateStaticParams,
// next.config rewrites, hreflang, and the sitemap all derive from this list.
// Adding a locale here fails the build until its compiled catalog is imported
// in messages-by-locale.ts.
export const WEBSITE_LOCALE_LIST: readonly AppLocale[] = [
SOURCE_LOCALE,
APP_LOCALES['fr-FR'],
APP_LOCALES['es-ES'],
];
export const WEBSITE_LOCALE_LIST: readonly DocumentationSupportedLanguage[] =
DOCUMENTATION_SUPPORTED_LANGUAGES;
@@ -1,6 +1,7 @@
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { localeToUrlSegment } from '@/platform/i18n/locale-to-url-segment';
import {
DOCUMENTATION_DEFAULT_LANGUAGE,
type DocumentationSupportedLanguage,
} from 'twenty-shared/constants';
import { getSiteUrl } from './get-site-url';
@@ -8,11 +9,10 @@ type BreadcrumbItem = { name: string; path: string };
export const buildBreadcrumbListJsonLd = (
items: readonly BreadcrumbItem[],
locale: AppLocale,
locale: DocumentationSupportedLanguage,
): Record<string, unknown> => {
const siteUrl = getSiteUrl();
const prefix =
locale === SOURCE_LOCALE ? '' : `/${localeToUrlSegment(locale)}`;
const prefix = locale === DOCUMENTATION_DEFAULT_LANGUAGE ? '' : `/${locale}`;
return {
'@context': 'https://schema.org',
@@ -1,5 +1,8 @@
import { type Metadata } from 'next';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import {
DOCUMENTATION_DEFAULT_LANGUAGE,
type DocumentationSupportedLanguage,
} from 'twenty-shared/constants';
import {
type WebsiteRouteFamily,
@@ -8,19 +11,19 @@ import {
import { buildPageMetadata } from './build-page-metadata';
// Dynamic family pages (articles, releases) derive their SEO surface from
// the family registry plus the enumerated entry — the same single-source
// rule as static routes.
export const buildFamilyEntryMetadata = (
family: WebsiteRouteFamily,
entry: WebsiteRouteFamilyEntry,
locale: AppLocale,
locale: DocumentationSupportedLanguage,
): Metadata =>
buildPageMetadata({
description: entry.description,
indexed: family.indexed,
locale,
locales: family.localeMode === 'source' ? [SOURCE_LOCALE] : undefined,
locales:
family.localeMode === 'source'
? [DOCUMENTATION_DEFAULT_LANGUAGE]
: undefined,
ogImagePath: entry.ogImagePath,
path: `${family.basePath}/${entry.slug}`,
title: entry.title,
@@ -1,10 +1,11 @@
import { type MessageDescriptor } from '@lingui/core';
import { type Metadata } from 'next';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import {
DOCUMENTATION_DEFAULT_LANGUAGE,
type DocumentationSupportedLanguage,
} from 'twenty-shared/constants';
import { createI18nInstance } from '@/platform/i18n/create-i18n-instance';
import { isWebsiteLocale } from '@/platform/i18n/is-website-locale';
import { localeToUrlSegment } from '@/platform/i18n/locale-to-url-segment';
import { WEBSITE_LOCALE_LIST } from '@/platform/i18n/website-locale-list';
import { getSiteUrl } from './get-site-url';
@@ -13,35 +14,35 @@ const SITE_NAME = 'Twenty';
const TWITTER_HANDLE = '@twentycrm';
const DEFAULT_OG_IMAGE_PATH = '/images/og/default.png';
// Static pages pass catalog messages; dynamic family entries pass plain
// strings from their content source.
type MetadataText = MessageDescriptor | string;
export type BuildPageMetadataInput = {
description: MetadataText;
indexed?: boolean;
locale: AppLocale;
locales?: readonly AppLocale[];
locale: DocumentationSupportedLanguage;
locales?: readonly DocumentationSupportedLanguage[];
ogImagePath?: string;
path: string;
title: MetadataText;
};
const localizePath = (locale: AppLocale, path: string): string => {
if (locale === SOURCE_LOCALE || !isWebsiteLocale(locale)) return path;
const segment = localeToUrlSegment(locale);
return path === '/' ? `/${segment}` : `/${segment}${path}`;
const localizePath = (
locale: DocumentationSupportedLanguage,
path: string,
): string => {
if (locale === DOCUMENTATION_DEFAULT_LANGUAGE) return path;
return path === '/' ? `/${locale}` : `/${locale}${path}`;
};
const buildLanguageAlternates = (
path: string,
locales: readonly AppLocale[],
locales: readonly DocumentationSupportedLanguage[],
): Record<string, string> => {
const languages: Record<string, string> = {};
for (const locale of locales) {
languages[locale] = localizePath(locale, path);
}
languages['x-default'] = localizePath(SOURCE_LOCALE, path);
languages['x-default'] = localizePath(DOCUMENTATION_DEFAULT_LANGUAGE, path);
return languages;
};
@@ -54,9 +55,8 @@ export function buildPageMetadata({
path,
title,
}: BuildPageMetadataInput): Metadata {
const metadataLocale = isWebsiteLocale(locale) ? locale : SOURCE_LOCALE;
const canonical = localizePath(metadataLocale, path);
const i18n = createI18nInstance(metadataLocale);
const canonical = localizePath(locale, path);
const i18n = createI18nInstance(locale);
const resolvedTitle = typeof title === 'string' ? title : i18n._(title);
const resolvedDescription =
typeof description === 'string' ? description : i18n._(description);
@@ -76,7 +76,7 @@ export function buildPageMetadata({
description: resolvedDescription,
url: canonical,
siteName: SITE_NAME,
locale: metadataLocale,
locale,
type: 'website',
images: ogImages,
},
@@ -1,5 +1,5 @@
import { type Metadata } from 'next';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { DOCUMENTATION_DEFAULT_LANGUAGE } from 'twenty-shared/constants';
import { type LocaleRouteParams } from '@/platform/i18n/get-route-i18n';
import { resolveLocaleParam } from '@/platform/i18n/resolve-locale-param';
@@ -8,8 +8,6 @@ import { type WebsiteRouteId } from '@/platform/routing/website-route';
import { buildPageMetadata } from './build-page-metadata';
// Pages declare `export const generateMetadata = buildRouteMetadata('home')`
// and their entire SEO surface derives from the route registry.
export const buildRouteMetadata = (routeId: WebsiteRouteId) => {
return async ({
params,
@@ -23,7 +21,10 @@ export const buildRouteMetadata = (routeId: WebsiteRouteId) => {
description: route.description,
indexed: route.indexed,
locale,
locales: route.localeMode === 'source' ? [SOURCE_LOCALE] : undefined,
locales:
route.localeMode === 'source'
? [DOCUMENTATION_DEFAULT_LANGUAGE]
: undefined,
ogImagePath: route.ogImagePath,
path: route.path,
title: route.title,