[Website] locale-segment routing and shared Lingui factory (#20079)

**1. Shared Lingui factory in `twenty-shared`**

- Extracted `createI18nInstanceFactory` into
`packages/twenty-shared/src/i18n/create-i18n-instance-factory.ts` so
every package gets the same per-render Lingui bootstrap with a
per-locale singleton cache and a `SOURCE_LOCALE` fallback.
- `twenty-emails/src/utils/i18n.utils.ts` now consumes the shared
factory.

**2. `twenty-website-new` Lingui bootstrap + Crowdin wiring**

- `lingui.config.ts`, `src/lib/i18n/*`, `nx run
twenty-website-new:lingui:{extract,compile}`.
- 31 locale PO files generated; minified compiled output kept out of
Prettier and Oxlint.
- `i18n-{push,pull}.yaml` workflows updated to include
`twenty-website-new` in Crowdin sync.

**3. `app/[locale]/...` segment routing with English at the root**

- All marketing routes moved under `src/app/[locale]/`; static
generation preserved (15 routes × 31 locales = 465 prerendered URLs).
- Middleware behavior:
  - `/{en}/...`         → 301 redirect to unprefixed canonical.
  - `/{non-en}/...`     → pass through, set `NEXT_LOCALE` cookie.

### What this PR explicitly does not do (deferred)

- Lingui-wrapping the actual marketing copy. Keys, build pipeline, and
  runtime are wired; copy migration is a separate, reviewer-friendlier
  PR.
This commit is contained in:
Abdullah.
2026-04-27 17:11:30 +05:00
committed by GitHub
parent 9b7f7d059a
commit a90895e167
177 changed files with 1295 additions and 238 deletions
+2
View File
@@ -58,6 +58,7 @@ jobs:
npx nx run twenty-server:lingui:compile --strict
npx nx run twenty-emails:lingui:compile --strict
npx nx run twenty-front:lingui:compile --strict
npx nx run twenty-website-new:lingui:compile --strict
continue-on-error: true
- name: Stash any changes before pulling translations
@@ -115,6 +116,7 @@ jobs:
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
npx nx run twenty-website-new:lingui:compile
git status
git add .
if ! git diff --staged --quiet --exit-code; then
+2
View File
@@ -41,6 +41,7 @@ jobs:
npx nx run twenty-server:lingui:extract
npx nx run twenty-emails:lingui:extract
npx nx run twenty-front:lingui:extract
npx nx run twenty-website-new:lingui:extract
- name: Check and commit extracted files
id: check_extract_changes
@@ -60,6 +61,7 @@ jobs:
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
npx nx run twenty-website-new:lingui:compile
- name: Check and commit compiled files
id: check_compile_changes
+3 -19
View File
@@ -1,6 +1,6 @@
import { setupI18n, type I18n, type Messages } from '@lingui/core';
import { type Messages } from '@lingui/core';
import { createI18nInstanceFactory } from 'twenty-shared/i18n';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { messages as afMessages } from '@/locales/generated/af-ZA';
import { messages as arMessages } from '@/locales/generated/ar-SA';
import { messages as caMessages } from '@/locales/generated/ca-ES';
@@ -67,20 +67,4 @@ const messages: Record<keyof typeof APP_LOCALES, Messages> = {
'zh-TW': zhHantMessages,
};
const i18nInstancesMap: Partial<Record<keyof typeof APP_LOCALES, I18n>> = {};
export const createI18nInstance = (locale: keyof typeof APP_LOCALES): I18n => {
if (isDefined(i18nInstancesMap[locale])) {
return i18nInstancesMap[locale];
}
const i18nInstance = setupI18n();
const localeMessages = messages[locale] ?? messages.en;
i18nInstance.load(locale, localeMessages);
i18nInstance.activate(locale);
i18nInstancesMap[locale] = i18nInstance;
return i18nInstance;
};
export const createI18nInstance = createI18nInstanceFactory(messages);
+9
View File
@@ -66,6 +66,11 @@
"import": "./dist/database-events.mjs",
"require": "./dist/database-events.cjs"
},
"./i18n": {
"types": "./dist/i18n/index.d.ts",
"import": "./dist/i18n.mjs",
"require": "./dist/i18n.cjs"
},
"./logic-function": {
"types": "./dist/logic-function/index.d.ts",
"import": "./dist/logic-function.mjs",
@@ -118,6 +123,7 @@
"application",
"constants",
"database-events",
"i18n",
"logic-function",
"metadata",
"testing",
@@ -142,6 +148,9 @@
"database-events": [
"dist/database-events/index.d.ts"
],
"i18n": [
"dist/i18n/index.d.ts"
],
"logic-function": [
"dist/logic-function/index.d.ts"
],
+2
View File
@@ -20,6 +20,8 @@
"{projectRoot}/constants/dist",
"{projectRoot}/database-events/package.json",
"{projectRoot}/database-events/dist",
"{projectRoot}/i18n/package.json",
"{projectRoot}/i18n/dist",
"{projectRoot}/logic-function/package.json",
"{projectRoot}/logic-function/dist",
"{projectRoot}/metadata/package.json",
@@ -0,0 +1,29 @@
import { setupI18n, type I18n, type Messages } from '@lingui/core';
import { type AppLocale } from '@/translations/constants/AppLocales';
import { SOURCE_LOCALE } from '@/translations/constants/SourceLocale';
export type LocaleMessagesMap = Partial<Record<AppLocale, Messages>>;
export const createI18nInstanceFactory = (
messagesByLocale: LocaleMessagesMap,
) => {
const cache: Partial<Record<AppLocale, I18n>> = {};
return (locale: AppLocale): I18n => {
const cached = cache[locale];
if (cached !== undefined) {
return cached;
}
const fallbackMessages = messagesByLocale[SOURCE_LOCALE] ?? {};
const localeMessages = messagesByLocale[locale] ?? fallbackMessages;
const i18n = setupI18n();
i18n.load(locale, localeMessages);
i18n.activate(locale);
cache[locale] = i18n;
return i18n;
};
};
+11
View File
@@ -0,0 +1,11 @@
/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
* |_| \_/\_/ \___|_| |_|\__|\__, |
* |___/
*/
export type { LocaleMessagesMap } from './create-i18n-instance-factory';
export { createI18nInstanceFactory } from './create-i18n-instance-factory';
+1 -1
View File
@@ -4,7 +4,7 @@
"categories": {
"correctness": "error"
},
"ignorePatterns": ["node_modules"],
"ignorePatterns": ["node_modules", "src/locales/generated"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
@@ -0,0 +1 @@
src/locales/generated
@@ -0,0 +1,22 @@
import { defineConfig } from '@lingui/conf';
import { formatter } from '@lingui/format-po';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
export default defineConfig({
sourceLocale: SOURCE_LOCALE,
locales: Object.values(APP_LOCALES),
pseudoLocale: 'pseudo-en',
fallbackLocales: {
'pseudo-en': 'en',
default: SOURCE_LOCALE,
},
catalogs: [
{
path: '<rootDir>/src/locales/{locale}',
include: ['src'],
},
],
catalogsMergePath: '<rootDir>/src/locales/generated/{locale}',
compileNamespace: 'ts',
format: formatter({ lineNumbers: false, printLinguiId: true }),
});
@@ -36,6 +36,19 @@ const nextConfig: LinariaConfig = {
configFile: path.resolve(__dirname, 'wyw-in-js.config.cjs'),
},
reactCompiler: true,
experimental: {
swcPlugins: [
[
'@lingui/swc-plugin',
{
runtimeModules: {
i18n: ['@lingui/core', 'i18n'],
trans: ['@lingui/react', 'Trans'],
},
},
],
],
},
async headers() {
return [
{
+7
View File
@@ -15,6 +15,8 @@
"@gsap/react": "2.1.2",
"@linaria/core": "^7.0.0",
"@linaria/react": "^7.0.1",
"@lingui/core": "^5.1.2",
"@lingui/react": "^5.1.2",
"@lottiefiles/dotlottie-react": "^0.18.10",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
@@ -31,9 +33,14 @@
"sharp": "^0.33.5",
"stripe": "^20.3.1",
"three": "^0.183.2",
"twenty-shared": "workspace:*",
"zod": "^4.1.11"
},
"devDependencies": {
"@lingui/cli": "^5.1.2",
"@lingui/conf": "5.1.2",
"@lingui/format-po": "5.1.2",
"@lingui/swc-plugin": "^5.11.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+16
View File
@@ -117,6 +117,22 @@
"configurations": {
"fix": {}
}
},
"lingui:extract": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "{projectRoot}",
"command": "lingui extract --overwrite --clean"
}
},
"lingui:compile": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "{projectRoot}",
"command": "lingui compile --typescript"
}
}
}
}
@@ -1,10 +1,10 @@
import { HELPED_DATA } from '@/app/(home)/helped.data';
import { HERO_DATA } from '@/app/(home)/hero.data';
import { HOME_STEPPER_DATA } from '@/app/(home)/home-stepper.data';
import { PROBLEM_DATA } from '@/app/(home)/problem.data';
import { TESTIMONIALS_DATA } from '@/app/(home)/testimonials.data';
import { THREE_CARDS_FEATURE_DATA } from '@/app/(home)/three-cards-feature.data';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/(home)/three-cards-illustration.data';
import { HELPED_DATA } from '@/app/[locale]/(home)/helped.data';
import { HERO_DATA } from '@/app/[locale]/(home)/hero.data';
import { HOME_STEPPER_DATA } from '@/app/[locale]/(home)/home-stepper.data';
import { PROBLEM_DATA } from '@/app/[locale]/(home)/problem.data';
import { TESTIMONIALS_DATA } from '@/app/[locale]/(home)/testimonials.data';
import { THREE_CARDS_FEATURE_DATA } from '@/app/[locale]/(home)/three-cards-feature.data';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/(home)/three-cards-illustration.data';
import { TalkToUsButton } from '@/lib/contact-cal';
import { FAQ_DATA } from '@/sections/Faq/data';
import { MENU_DATA } from '@/sections/Menu/data';
@@ -22,15 +22,18 @@ 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 { buildLocalizedMetadata } from '@/lib/seo';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
export const metadata: Metadata = {
alternates: { canonical: '/' },
};
export const generateMetadata = buildLocalizedMetadata({
path: '/',
title: 'Twenty | #1 open source CRM',
description:
'The #1 open source CRM for modern teams. Modular, scalable, and built to fit your business.',
});
const HOME_TOP_BACKGROUND_COLOR = '#F4F4F4';
const PRODUCT_HUNT_LAUNCH_URL =
@@ -1,13 +1,12 @@
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { buildLocalizedMetadata } from '@/lib/seo';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1733244766159-f58f4184fd38?w=1600&q=80';
@@ -99,7 +98,7 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/9dots',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
@@ -1,13 +1,12 @@
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { buildLocalizedMetadata } from '@/lib/seo';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1687600154329-150952c73169?w=1600&q=80';
@@ -99,7 +98,7 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/act-education',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
@@ -1,13 +1,12 @@
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { buildLocalizedMetadata } from '@/lib/seo';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1702047149248-a6049168d2a8?w=1600&q=80';
@@ -73,7 +72,7 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/alternative-partners',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
@@ -1,13 +1,12 @@
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { buildLocalizedMetadata } from '@/lib/seo';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1758873269035-aae0e1fd3422?w=1600&q=80';
@@ -113,7 +112,7 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/elevate-consulting',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
@@ -1,13 +1,12 @@
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { buildLocalizedMetadata } from '@/lib/seo';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1744830343976-ce690ba2a67c?w=1600&q=80';
@@ -103,7 +102,7 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/netzero',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
@@ -14,11 +14,10 @@ import { Menu } from '@/sections/Menu/components';
import { Signoff } from '@/sections/Signoff/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import { buildLocalizedMetadata } from '@/lib/seo';
import { css } from '@linaria/core';
import type { Metadata } from 'next';
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/customers',
title: 'Customers | Twenty',
description:
@@ -1,13 +1,12 @@
import { MENU_DATA } from '@/sections/Menu/data';
import { CustomersCaseStudySignoff } from '@/app/customers/_components/CustomersCaseStudySignoff';
import { CustomersCaseStudySignoff } from '@/app/[locale]/customers/_components/CustomersCaseStudySignoff';
import { getCaseStudyPalette, type CaseStudyData } from '@/lib/customers';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { CaseStudy } from '@/sections/CaseStudy/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { buildLocalizedMetadata } from '@/lib/seo';
const PLACEHOLDER_HERO =
'https://images.unsplash.com/photo-1756830231350-3b501f63c5c1?w=1600&q=80';
@@ -100,7 +99,7 @@ const CASE_STUDY: CaseStudyData = {
},
};
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/customers/w3villa',
title: CASE_STUDY.meta.title,
description: CASE_STUDY.meta.description,
@@ -1,5 +1,5 @@
import { MENU_DATA } from '@/sections/Menu/data';
import { EnterpriseActivateClient } from '@/app/enterprise/activate/EnterpriseActivateClient';
import { EnterpriseActivateClient } from '@/app/[locale]/enterprise/activate/EnterpriseActivateClient';
import { Body, Container, Eyebrow } from '@/design-system/components';
import type { HeadingType } from '@/design-system/components/Heading';
import { Pages } from '@/lib/pages';
@@ -8,12 +8,11 @@ import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels'
import { Hero } from '@/sections/Hero/components';
import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { buildLocalizedMetadata } from '@/lib/seo';
import { Suspense } from 'react';
import { styled } from '@linaria/react';
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/enterprise/activate',
title: 'Enterprise activation | Twenty',
description:
@@ -1,6 +1,6 @@
'use client';
import type { ReactExportSettings } from '@/app/halftone/_lib/exporters';
import type { ReactExportSettings } from '@/app/[locale]/halftone/_lib/exporters';
import { IconLayoutSidebarRightCollapse, IconShare } from '@tabler/icons-react';
import { styled } from '@linaria/react';
import type {
@@ -1,6 +1,6 @@
'use client';
import { ControlsPanel } from '@/app/halftone/_components/ControlsPanel';
import { ControlsPanel } from '@/app/[locale]/halftone/_components/ControlsPanel';
import {
DEFAULT_REACT_EXPORT_SETTINGS,
deriveExportComponentName,
@@ -9,14 +9,14 @@ import {
getExportedModelFile,
parseExportedPreset,
type ReactExportSettings,
} from '@/app/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import { generateImageHalftoneSvg } from '@/app/halftone/_lib/imageSvgExport';
} from '@/app/[locale]/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/[locale]/halftone/_lib/exportNames';
import { generateImageHalftoneSvg } from '@/app/[locale]/halftone/_lib/imageSvgExport';
import {
buildShareUrl,
decodeShareState,
encodeShareState,
} from '@/app/halftone/_lib/share';
} from '@/app/[locale]/halftone/_lib/share';
import {
HalftoneCanvas,
type HalftoneSnapshotFn,
@@ -39,9 +39,9 @@ import {
normalizeHalftoneStudioSettings,
} from '@/lib/halftone/state';
import { Logo as LogoIcon } from '@/icons';
import { LocalizedLink } from '@/lib/i18n';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import Link from 'next/link';
import {
useCallback,
useEffect,
@@ -89,7 +89,7 @@ const CanvasLayer = styled.div`
width: 100%;
`;
const LogoLink = styled(Link)`
const LogoLink = styled(LocalizedLink)`
display: grid;
left: 24px;
position: absolute;
@@ -4,7 +4,7 @@ import {
formatAngle,
formatDecimal,
formatPercent,
} from '@/app/halftone/_lib/formatters';
} from '@/app/[locale]/halftone/_lib/formatters';
import type { HalftoneStudioSettings } from '@/lib/halftone/state';
import {
ColorControlLabel,
@@ -1,6 +1,9 @@
'use client';
import { formatAngle, formatDecimal } from '@/app/halftone/_lib/formatters';
import {
formatAngle,
formatDecimal,
} from '@/app/[locale]/halftone/_lib/formatters';
import {
DEFAULT_GLASS_ANIMATION_SETTINGS,
DEFAULT_GLASS_LIGHTING_SETTINGS,
@@ -1,8 +1,8 @@
'use client';
import type { ReactExportSettings } from '@/app/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
import { formatAnimationName } from '@/app/halftone/_lib/formatters';
import type { ReactExportSettings } from '@/app/[locale]/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/[locale]/halftone/_lib/exportNames';
import { formatAnimationName } from '@/app/[locale]/halftone/_lib/formatters';
import type {
HalftoneGeometrySpec,
HalftoneStudioSettings,
@@ -3,8 +3,8 @@ import {
generateStandaloneHtml,
parseExportedPreset,
type ReactExportSettings,
} from '@/app/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/halftone/_lib/exportNames';
} from '@/app/[locale]/halftone/_lib/exporters';
import { resolveExportArtifactNames } from '@/app/[locale]/halftone/_lib/exportNames';
import { REFERENCE_PREVIEW_DISTANCE } from '@/lib/halftone/footprint';
import {
DEFAULT_HALFTONE_SETTINGS,
@@ -1,5 +1,5 @@
import { normalizeExportComponentName } from '@/app/halftone/_lib/exportNames';
import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/halftone/_lib/glassEnvironmentData';
import { normalizeExportComponentName } from '@/app/[locale]/halftone/_lib/exportNames';
import { GLASS_ENVIRONMENT_DATA_URL } from '@/app/[locale]/halftone/_lib/glassEnvironmentData';
import {
HALFTONE_FOOTPRINT_RUNTIME_SOURCE,
REFERENCE_PREVIEW_DISTANCE,
@@ -0,0 +1,12 @@
import { HalftoneStudio } from '@/app/[locale]/halftone/_components/HalftoneStudio';
import { buildLocalizedMetadata } from '@/lib/seo';
export const generateMetadata = buildLocalizedMetadata({
path: '/halftone',
title: 'Halftone Generator | Twenty',
description: 'Interactive halftone generator exported from Twenty.',
});
export default function HalftonePage() {
return <HalftoneStudio />;
}
@@ -1,17 +1,25 @@
import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate';
import { ScrollToTopOnRouteChange } from '@/app/_components/ScrollToTopOnRouteChange';
import { FOOTER_DATA } from '@/sections/Footer/data';
import { ContactCalModalRoot } from '@/lib/contact-cal';
import { PartnerApplicationModalRoot } from '@/lib/partner-application';
import { getSiteUrl } from '@/lib/seo';
import { DRACO_DECODER_ORIGIN } from '@/lib/visual-runtime/draco-decoder-path';
import { Footer } from '@/sections/Footer/components';
import { theme } from '@/theme';
import { cssVariables } from '@/theme/css-variables';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
import { Aleo, Azeret_Mono, Host_Grotesk, VT323 } from 'next/font/google';
import { type ReactNode } from 'react';
import { FooterVisibilityGate } from '@/app/_components/FooterVisibilityGate';
import { ScrollToTopOnRouteChange } from '@/app/_components/ScrollToTopOnRouteChange';
import { ContactCalModalRoot } from '@/lib/contact-cal';
import {
I18nProvider,
PUBLIC_APP_LOCALE_LIST,
getLocaleMessages,
resolveLocaleParam,
} from '@/lib/i18n';
import { PartnerApplicationModalRoot } from '@/lib/partner-application';
import { Footer } from '@/sections/Footer/components';
import { FOOTER_DATA } from '@/sections/Footer/data';
const hostGrotesk = Host_Grotesk({
subsets: ['latin'],
@@ -59,9 +67,6 @@ const _globalStyles = css`
display: flex;
font-family: ${theme.font.family.sans};
flex-direction: column;
/* dvh keeps the footer pinned to the visible viewport bottom on mobile
* Safari (where 100vh = large viewport with chrome hidden, leaving a
* gap when the URL bar is showing). vh fallback for older browsers. */
min-height: 100vh;
min-height: 100dvh;
-webkit-font-smoothing: antialiased;
@@ -70,6 +75,8 @@ const _globalStyles = css`
`;
const StyledMain = styled.main`
display: flex;
flex-direction: column;
flex-grow: 1;
`;
@@ -101,17 +108,27 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({
type LocaleLayoutParams = { locale: string };
export const dynamicParams = false;
export const generateStaticParams = (): LocaleLayoutParams[] =>
PUBLIC_APP_LOCALE_LIST.map((locale) => ({ locale }));
const LocaleLayout = async ({
children,
}: Readonly<{ children: React.ReactNode }>) {
params,
}: {
children: ReactNode;
params: Promise<LocaleLayoutParams>;
}) => {
const { locale: rawLocale } = await params;
const locale = resolveLocaleParam(rawLocale);
const messages = getLocaleMessages(locale);
return (
<html lang="en">
<html lang={locale}>
<head>
{/*
* Warm up the connection to the DRACO decoder host so the first 3D
* model on the page does not pay the full TLS handshake cost the
* moment it starts decoding.
*/}
<link
crossOrigin="anonymous"
href={DRACO_DECODER_ORIGIN}
@@ -122,23 +139,27 @@ export default function RootLayout({
className={`${cssVariables} ${hostGrotesk.variable} ${aleo.variable} ${azeretMono.variable} ${vt323.variable}`}
suppressHydrationWarning
>
<ContactCalModalRoot>
<PartnerApplicationModalRoot>
<ScrollToTopOnRouteChange />
<StyledMain>{children}</StyledMain>
<FooterVisibilityGate>
<Footer.Root>
<Footer.Logo />
<Footer.Nav groups={FOOTER_DATA.navGroups} />
<Footer.Bottom
copyright={FOOTER_DATA.bottom.copyright}
links={FOOTER_DATA.socialLinks}
/>
</Footer.Root>
</FooterVisibilityGate>
</PartnerApplicationModalRoot>
</ContactCalModalRoot>
<I18nProvider locale={locale} messages={messages}>
<ContactCalModalRoot>
<PartnerApplicationModalRoot>
<ScrollToTopOnRouteChange />
<StyledMain>{children}</StyledMain>
<FooterVisibilityGate>
<Footer.Root>
<Footer.Logo />
<Footer.Nav groups={FOOTER_DATA.navGroups} />
<Footer.Bottom
copyright={FOOTER_DATA.bottom.copyright}
links={FOOTER_DATA.socialLinks}
/>
</Footer.Root>
</FooterVisibilityGate>
</PartnerApplicationModalRoot>
</ContactCalModalRoot>
</I18nProvider>
</body>
</html>
);
}
};
export default LocaleLayout;
@@ -3,14 +3,14 @@ import { MENU_DATA } from '@/sections/Menu/data';
import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
import { TalkToUsButton } from '@/lib/contact-cal';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/partners/three-cards-illustration.data';
import { HERO_DATA } from '@/app/partners/hero.data';
import { SIGNOFF_DATA } from '@/app/partners/signoff.data';
import { TESTIMONIALS_DATA } from '@/app/partners/testimonials.data';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/partners/three-cards-illustration.data';
import { HERO_DATA } from '@/app/[locale]/partners/hero.data';
import { SIGNOFF_DATA } from '@/app/[locale]/partners/signoff.data';
import { TESTIMONIALS_DATA } from '@/app/[locale]/partners/testimonials.data';
import {
PartnerHeroCtas,
PartnerSignoffCtas,
} from '@/app/partners/components/PartnerApplication';
} from '@/app/[locale]/partners/components/PartnerApplication';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/lib/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
@@ -25,9 +25,8 @@ import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import type { ThreeCardsScrollLayoutOptions } from '@/sections/ThreeCards/utils/three-cards-scroll-layout';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import { buildLocalizedMetadata } from '@/lib/seo';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
const PARTNER_ILLUSTRATION_CARDS_SCROLL_LAYOUT_OPTIONS: ThreeCardsScrollLayoutOptions =
{
@@ -46,7 +45,7 @@ const PromoSpacing = styled.div`
}
`;
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/partners',
title: 'Partners | Twenty',
description:
@@ -1,11 +1,11 @@
import { FAQ_DATA } from '@/sections/Faq/data';
import { MENU_DATA } from '@/sections/Menu/data';
import { TalkToUsButton } from '@/lib/contact-cal';
import { BecomePartnerButton } from '@/app/partners/components/PartnerApplication';
import { ENGAGEMENT_BAND_DATA } from '@/app/pricing/engagement-band.data';
import { HERO_DATA } from '@/app/pricing/hero.data';
import { PLAN_TABLE_DATA } from '@/app/pricing/plan-table.data';
import { SALESFORCE_DATA } from '@/app/pricing/salesforce.data';
import { BecomePartnerButton } from '@/app/[locale]/partners/components/PartnerApplication';
import { ENGAGEMENT_BAND_DATA } from '@/app/[locale]/pricing/engagement-band.data';
import { HERO_DATA } from '@/app/[locale]/pricing/hero.data';
import { PLAN_TABLE_DATA } from '@/app/[locale]/pricing/plan-table.data';
import { SALESFORCE_DATA } from '@/app/[locale]/pricing/salesforce.data';
import { Eyebrow, LinkButton } from '@/design-system/components';
import { Pages } from '@/lib/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
@@ -19,9 +19,8 @@ import { PricingStateProvider } from '@/sections/Plans/context/PricingStateConte
import { PlanTable } from '@/sections/PlanTable/components';
import { Salesforce } from '@/sections/Salesforce/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import { buildLocalizedMetadata } from '@/lib/seo';
import { styled } from '@linaria/react';
import type { Metadata } from 'next';
const PricingPlansContainer = styled.div`
display: grid;
@@ -35,7 +34,7 @@ const PricingBannerContainer = styled.div`
width: 100%;
`;
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/pricing',
title: 'Pricing | Twenty',
description:
@@ -1,4 +1,4 @@
import Link from 'next/link';
import { LocalizedLink } from '@/lib/i18n';
export function PrivacyPolicyDocument() {
return (
@@ -56,7 +56,7 @@ export function PrivacyPolicyDocument() {
{
'By using the Service, you agree to the collection and use of information in accordance with this Privacy Policy and our '
}
<Link href={'/terms'}>{'Terms of Service'}</Link>
<LocalizedLink href={'/terms'}>{'Terms of Service'}</LocalizedLink>
{'.'}
</p>
@@ -1,14 +1,12 @@
import type { Metadata } from 'next';
import { MENU_DATA } from '@/sections/Menu/data';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { LegalDocument } from '@/sections/LegalDocument/components';
import { buildPageMetadata } from '@/lib/seo';
import { buildLocalizedMetadata } from '@/lib/seo';
import { PrivacyPolicyDocument } from './_components';
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/privacy-policy',
title: 'Privacy Policy | Twenty',
description:
@@ -2,11 +2,11 @@ import { FAQ_DATA } from '@/sections/Faq/data';
import { MENU_DATA } from '@/sections/Menu/data';
import { TRUSTED_BY_DATA } from '@/sections/TrustedBy/data';
import { TalkToUsButton } from '@/lib/contact-cal';
import { FEATURE_DATA } from '@/app/product/feature.data';
import { HERO_DATA } from '@/app/product/hero.data';
import { SIGNOFF_DATA } from '@/app/product/signoff.data';
import { STEPPER_DATA } from '@/app/product/stepper.data';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/product/three-cards.data';
import { FEATURE_DATA } from '@/app/[locale]/product/feature.data';
import { HERO_DATA } from '@/app/[locale]/product/hero.data';
import { SIGNOFF_DATA } from '@/app/[locale]/product/signoff.data';
import { STEPPER_DATA } from '@/app/[locale]/product/stepper.data';
import { THREE_CARDS_ILLUSTRATION_DATA } from '@/app/[locale]/product/three-cards.data';
import { Body, Eyebrow, Heading, LinkButton } from '@/design-system/components';
import { Pages } from '@/lib/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
@@ -20,10 +20,9 @@ import { Signoff } from '@/sections/Signoff/components';
import { ThreeCards } from '@/sections/ThreeCards/components';
import { TrustedBy } from '@/sections/TrustedBy/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { buildLocalizedMetadata } from '@/lib/seo';
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/product',
title: 'Product | Twenty',
description:
@@ -2,7 +2,7 @@ import { MENU_DATA } from '@/sections/Menu/data';
import {
RELEASE_NOTES_HERO_BODY,
RELEASE_NOTES_HERO_HEADING,
} from '@/app/releases/hero.data';
} from '@/app/[locale]/releases/hero.data';
import { LinkButton } from '@/design-system/components';
import { Pages } from '@/lib/pages';
import { GitHubIcon } from '@/icons';
@@ -15,11 +15,10 @@ import { Hero } from '@/sections/Hero/components';
import { Menu } from '@/sections/Menu/components';
import { ReleaseNotes } from '@/sections/ReleaseNotes/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
import { buildLocalizedMetadata } from '@/lib/seo';
import { Fragment } from 'react';
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/releases',
title: 'Releases | Twenty',
description:
@@ -1,4 +1,4 @@
import Link from 'next/link';
import { LocalizedLink } from '@/lib/i18n';
export function TermsDocument() {
return (
@@ -20,7 +20,7 @@ export function TermsDocument() {
Our Privacy Policy also governs your use of our Service and explains how
we collect, safeguard and disclose information that results from your
use of our web pages. Please read it here:{' '}
<Link href="/privacy-policy">Privacy Policy</Link>.
<LocalizedLink href="/privacy-policy">Privacy Policy</LocalizedLink>.
</p>
<p>
Your agreement with us includes these Terms and our Privacy Policy
@@ -1,14 +1,12 @@
import type { Metadata } from 'next';
import { MENU_DATA } from '@/sections/Menu/data';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
import { mergeSocialLinkLabels } from '@/lib/community/merge-social-link-labels';
import { LegalDocument } from '@/sections/LegalDocument/components';
import { buildPageMetadata } from '@/lib/seo';
import { buildLocalizedMetadata } from '@/lib/seo';
import { TermsDocument } from './_components';
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/terms',
title: 'Terms of Service | Twenty',
description:
@@ -1,10 +1,10 @@
import { MENU_DATA } from '@/sections/Menu/data';
import { EDITORIAL_FOUR } from '@/app/why-twenty/editorial-four.data';
import { EDITORIAL_ONE } from '@/app/why-twenty/editorial-one.data';
import { EDITORIAL_THREE } from '@/app/why-twenty/editorial-three.data';
import { HERO_DATA } from '@/app/why-twenty/hero.data';
import { MARQUEE_DATA } from '@/app/why-twenty/marquee.data';
import { SIGNOFF_DATA } from '@/app/why-twenty/signoff.data';
import { EDITORIAL_FOUR } from '@/app/[locale]/why-twenty/editorial-four.data';
import { EDITORIAL_ONE } from '@/app/[locale]/why-twenty/editorial-one.data';
import { EDITORIAL_THREE } from '@/app/[locale]/why-twenty/editorial-three.data';
import { HERO_DATA } from '@/app/[locale]/why-twenty/hero.data';
import { MARQUEE_DATA } from '@/app/[locale]/why-twenty/marquee.data';
import { SIGNOFF_DATA } from '@/app/[locale]/why-twenty/signoff.data';
import { LinkButton } from '@/design-system/components';
import { Pages } from '@/lib/pages';
import { fetchCommunityStats } from '@/lib/community/fetch-community-stats';
@@ -15,9 +15,8 @@ import { Marquee } from '@/sections/Marquee/components';
import { Menu } from '@/sections/Menu/components';
import { Signoff } from '@/sections/Signoff/components';
import { theme } from '@/theme';
import { buildPageMetadata } from '@/lib/seo';
import { buildLocalizedMetadata } from '@/lib/seo';
import { css } from '@linaria/core';
import type { Metadata } from 'next';
const editorialOneIntroClass = css`
margin-bottom: ${theme.spacing(4)};
@@ -58,7 +57,7 @@ const sectionCrosshairRight = {
lineColor: crosshairLineColor,
};
export const metadata: Metadata = buildPageMetadata({
export const generateMetadata = buildLocalizedMetadata({
path: '/why-twenty',
title: 'Why Twenty | Twenty',
description:
@@ -1,16 +1,17 @@
'use client';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
import { useUnlocalizedPathname } from '@/lib/i18n';
type FooterVisibilityGateProps = {
children: ReactNode;
};
export function FooterVisibilityGate({ children }: FooterVisibilityGateProps) {
const pathname = usePathname();
const route = useUnlocalizedPathname();
if (pathname === '/halftone') {
if (route === '/halftone') {
return null;
}
@@ -1,13 +0,0 @@
import { HalftoneStudio } from '@/app/halftone/_components/HalftoneStudio';
import { buildPageMetadata } from '@/lib/seo';
import type { Metadata } from 'next';
export const metadata: Metadata = buildPageMetadata({
path: '/halftone',
title: 'Halftone Generator | Twenty',
description: 'Interactive halftone generator exported from Twenty.',
});
export default function HalftonePage() {
return <HalftoneStudio />;
}
@@ -10,7 +10,13 @@ export default function robots(): MetadataRoute.Robots {
{
userAgent: '*',
allow: '/',
disallow: ['/halftone', '/enterprise/activate', '/api/'],
disallow: [
'/api/',
'/halftone',
'/*/halftone',
'/enterprise/activate',
'/*/enterprise/activate',
],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
+39 -13
View File
@@ -1,15 +1,19 @@
import type { MetadataRoute } from 'next';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { CASE_STUDY_CATALOG_ENTRIES } from '@/lib/customers';
import { PUBLIC_APP_LOCALE_LIST } from '@/lib/i18n';
import { getSiteUrl } from '@/lib/seo';
const SITE_URL = getSiteUrl();
const STATIC_ROUTES: ReadonlyArray<{
type StaticRoute = {
path: string;
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'];
priority: number;
}> = [
};
const STATIC_ROUTES: readonly StaticRoute[] = [
{ path: '/', changeFrequency: 'weekly', priority: 1.0 },
{ path: '/why-twenty', changeFrequency: 'monthly', priority: 0.8 },
{ path: '/product', changeFrequency: 'monthly', priority: 0.8 },
@@ -21,20 +25,42 @@ const STATIC_ROUTES: ReadonlyArray<{
{ path: '/terms', changeFrequency: 'yearly', priority: 0.3 },
];
const buildLocalizedUrl = (locale: AppLocale, path: string): string => {
const prefix = locale === SOURCE_LOCALE ? '' : `/${locale}`;
const tail = path === '/' ? '' : path;
return `${SITE_URL}${prefix}${tail}`;
};
const buildLanguageAlternates = (path: string): Record<string, string> => {
const alternates: Record<string, string> = {};
for (const locale of PUBLIC_APP_LOCALE_LIST) {
alternates[locale] = buildLocalizedUrl(locale, path);
}
alternates['x-default'] = buildLocalizedUrl(SOURCE_LOCALE, path);
return alternates;
};
const localize = (
path: string,
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'],
priority: number,
): MetadataRoute.Sitemap =>
PUBLIC_APP_LOCALE_LIST.map((locale) => ({
url: buildLocalizedUrl(locale, path),
changeFrequency,
priority,
alternates: { languages: buildLanguageAlternates(path) },
}));
export default function sitemap(): MetadataRoute.Sitemap {
const staticEntries = STATIC_ROUTES.map(
({ path, changeFrequency, priority }) => ({
url: `${SITE_URL}${path}`,
changeFrequency,
priority,
}),
const staticEntries = STATIC_ROUTES.flatMap(
({ path, changeFrequency, priority }) =>
localize(path, changeFrequency, priority),
);
const caseStudyEntries = CASE_STUDY_CATALOG_ENTRIES.map((entry) => ({
url: `${SITE_URL}${entry.href}`,
changeFrequency: 'yearly' as const,
priority: 0.5,
}));
const caseStudyEntries = CASE_STUDY_CATALOG_ENTRIES.flatMap((entry) =>
localize(entry.href, 'yearly', 0.5),
);
return [...staticEntries, ...caseStudyEntries];
}
@@ -1,6 +1,6 @@
import type { LinkButtonType } from '@/design-system/components/Button/types/LinkButtonType';
import { LocalizedLink } from '@/lib/i18n';
import { styled } from '@linaria/react';
import Link from 'next/link';
import {
BaseButton,
type BaseButtonProps,
@@ -11,7 +11,7 @@ const StyledButtonAnchor = styled.a`
${buttonBaseStyles}
`;
const StyledButtonLink = styled(Link)`
const StyledButtonLink = styled(LocalizedLink)`
${buttonBaseStyles}
`;
@@ -1,6 +1,6 @@
import { LocalizedLink } from '@/lib/i18n';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import Link from 'next/link';
import type { ComponentType } from 'react';
type IconComponent =
@@ -59,7 +59,7 @@ const StyledButton = styled.button<SurfaceProps>`
width: ${({ $size }) => `${$size}px`};
`;
const StyledIconLink = styled(Link)<SurfaceProps>`
const StyledIconLink = styled(LocalizedLink)<SurfaceProps>`
${iconButtonSurfaceStyles}
border: 1px solid ${({ $borderColor }) => $borderColor};
color: inherit;
@@ -0,0 +1,33 @@
'use client';
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 { LocaleContext } from './LocaleContext';
type I18nProviderProps = {
locale: AppLocale;
messages: Messages;
children: ReactNode;
};
export const I18nProvider = ({
locale,
messages,
children,
}: I18nProviderProps) => {
const i18n = useMemo(() => {
const instance = setupI18n();
instance.load(locale, messages);
instance.activate(locale);
return instance;
}, [locale, messages]);
return (
<LocaleContext.Provider value={locale}>
<LinguiI18nProvider i18n={i18n}>{children}</LinguiI18nProvider>
</LocaleContext.Provider>
);
};
@@ -0,0 +1,6 @@
'use client';
import { createContext } from 'react';
import { type AppLocale } from 'twenty-shared/translations';
export const LocaleContext = createContext<AppLocale | null>(null);
@@ -0,0 +1,17 @@
'use client';
import Link from 'next/link';
import { type ComponentProps } from 'react';
import { localizeHref } from './localize-href';
import { useLocale } from './use-locale';
type LocalizedLinkProps = Omit<ComponentProps<typeof Link>, 'href'> & {
href: string;
};
export const LocalizedLink = ({ href, ...rest }: LocalizedLinkProps) => {
const locale = useLocale();
// oxlint-disable-next-line eslint-plugin-react(jsx-props-no-spreading)
return <Link href={localizeHref(locale, href)} {...rest} />;
};
@@ -0,0 +1,73 @@
import { detectLocale } from '../detect-locale';
describe('detectLocale', () => {
it('returns the cookie value when it is a supported locale', () => {
expect(
detectLocale({
cookieValue: 'fr-FR',
acceptLanguageHeader: 'de-DE,de;q=0.9',
}),
).toBe('fr-FR');
});
it('ignores an unsupported cookie value and falls back to Accept-Language', () => {
expect(
detectLocale({
cookieValue: 'xx-YY',
acceptLanguageHeader: 'de-DE,de;q=0.9',
}),
).toBe('de-DE');
});
it('matches the highest-quality Accept-Language entry first', () => {
expect(
detectLocale({
acceptLanguageHeader: 'en;q=0.5,fr-FR;q=0.9,de;q=0.7',
}),
).toBe('fr-FR');
});
it('falls back from a regional tag to the language family', () => {
expect(
detectLocale({
acceptLanguageHeader: 'fr-CA',
}),
).toBe('fr-FR');
});
it('falls back from a bare language tag to the first matching regional locale', () => {
expect(
detectLocale({
acceptLanguageHeader: 'pt;q=1.0,en;q=0.5',
}),
).toBe('pt-BR');
});
it('returns the source locale when no input is provided', () => {
expect(detectLocale({})).toBe('en');
});
it('returns the source locale when nothing in Accept-Language is supported', () => {
expect(
detectLocale({
acceptLanguageHeader: 'xx-YY,zz-ZZ;q=0.5',
}),
).toBe('en');
});
it('treats a malformed quality value as zero (deprioritized)', () => {
expect(
detectLocale({
acceptLanguageHeader: 'fr-FR;q=not-a-number,de-DE;q=0.5',
}),
).toBe('de-DE');
});
it('parses quality values when parameters carry leading whitespace around the semicolon', () => {
expect(
detectLocale({
acceptLanguageHeader: 'en;q=0.1, fr-FR ; q=0.9, de-DE ; q=0.5',
}),
).toBe('fr-FR');
});
});
@@ -0,0 +1,109 @@
import { localizeHref, stripLocale } from '../localize-href';
describe('localizeHref', () => {
it('prefixes a non-default locale onto an internal absolute path', () => {
expect(localizeHref('fr-FR', '/pricing')).toBe('/fr-FR/pricing');
});
it('returns paths unprefixed for the default locale (English at root)', () => {
expect(localizeHref('en', '/pricing')).toBe('/pricing');
expect(localizeHref('en', '/')).toBe('/');
});
it('prefixes a non-default locale onto the root path', () => {
expect(localizeHref('fr-FR', '/')).toBe('/fr-FR/');
});
it('preserves query strings and hash fragments', () => {
expect(localizeHref('de-DE', '/customers?ref=hero#top')).toBe(
'/de-DE/customers?ref=hero#top',
);
expect(localizeHref('en', '/customers?ref=hero#top')).toBe(
'/customers?ref=hero#top',
);
});
it('does not double-prefix paths that already start with a non-default locale', () => {
expect(localizeHref('fr-FR', '/de-DE/why-twenty')).toBe(
'/de-DE/why-twenty',
);
expect(localizeHref('fr-FR', '/fr-FR/pricing')).toBe('/fr-FR/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('/');
});
it('rewrites an /en-prefixed path onto the active non-default locale', () => {
expect(localizeHref('fr-FR', '/en/why-twenty')).toBe('/fr-FR/why-twenty');
expect(localizeHref('fr-FR', '/en')).toBe('/fr-FR/');
});
it('passes external https URLs through unchanged', () => {
expect(localizeHref('en', 'https://docs.twenty.com')).toBe(
'https://docs.twenty.com',
);
});
it('passes protocol-relative URLs through unchanged', () => {
expect(localizeHref('en', '//cdn.twenty.com/asset.png')).toBe(
'//cdn.twenty.com/asset.png',
);
});
it('passes mailto and tel links through unchanged', () => {
expect(localizeHref('en', 'mailto:contact@twenty.com')).toBe(
'mailto:contact@twenty.com',
);
expect(localizeHref('en', 'tel:+1234567890')).toBe('tel:+1234567890');
});
it('passes hash-only and relative hrefs through unchanged', () => {
expect(localizeHref('en', '#top')).toBe('#top');
expect(localizeHref('en', './sibling')).toBe('./sibling');
expect(localizeHref('en', '../parent')).toBe('../parent');
});
it('handles a locale segment immediately followed by a query string', () => {
expect(localizeHref('en', '/en?ref=hero')).toBe('/?ref=hero');
expect(localizeHref('fr-FR', '/en?ref=hero')).toBe('/fr-FR/?ref=hero');
expect(localizeHref('fr-FR', '/de-DE?ref=hero')).toBe('/de-DE?ref=hero');
});
it('handles a locale segment immediately followed by a hash fragment', () => {
expect(localizeHref('en', '/en#anchor')).toBe('/#anchor');
expect(localizeHref('fr-FR', '/en#anchor')).toBe('/fr-FR/#anchor');
expect(localizeHref('fr-FR', '/de-DE#anchor')).toBe('/de-DE#anchor');
});
});
describe('stripLocale', () => {
it('removes a known locale prefix from the pathname', () => {
expect(stripLocale('/en/why-twenty')).toBe('/why-twenty');
expect(stripLocale('/fr-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('/');
});
it('returns the pathname unchanged when no known locale prefix is present', () => {
expect(stripLocale('/why-twenty')).toBe('/why-twenty');
expect(stripLocale('/')).toBe('/');
});
it('returns the pathname unchanged when the input does not start with a slash', () => {
expect(stripLocale('not-a-path')).toBe('not-a-path');
expect(stripLocale('')).toBe('');
});
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(
'/customers?ref=hero#top',
);
});
});
@@ -0,0 +1,28 @@
import { APP_LOCALES, type AppLocale } from 'twenty-shared/translations';
const APP_LOCALE_VALUES: readonly AppLocale[] = Object.values(APP_LOCALES);
export const isPublicAppLocale = (locale: AppLocale): boolean =>
!locale.startsWith('pseudo-');
export const APP_LOCALE_LIST: readonly AppLocale[] = APP_LOCALE_VALUES;
export const PUBLIC_APP_LOCALE_LIST: readonly AppLocale[] =
APP_LOCALE_VALUES.filter(isPublicAppLocale);
export const APP_LOCALE_BY_RAW: ReadonlyMap<string, AppLocale> = new Map(
PUBLIC_APP_LOCALE_LIST.map((locale) => [locale, locale]),
);
export const APP_LOCALE_BY_LANGUAGE: ReadonlyMap<string, AppLocale> = (() => {
const byLanguage = new Map<string, AppLocale>();
for (const locale of PUBLIC_APP_LOCALE_LIST) {
const [languageSubtag] = locale.split('-');
if (languageSubtag === undefined) continue;
const key = languageSubtag.toLowerCase();
if (!byLanguage.has(key)) {
byLanguage.set(key, locale);
}
}
return byLanguage;
})();
@@ -0,0 +1,5 @@
import { createI18nInstanceFactory } from 'twenty-shared/i18n';
import { MESSAGES_BY_LOCALE } from './messages-by-locale';
export const createI18nInstance = createI18nInstanceFactory(MESSAGES_BY_LOCALE);
@@ -0,0 +1,58 @@
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { APP_LOCALE_BY_LANGUAGE, APP_LOCALE_BY_RAW } from './app-locale-set';
export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE';
type AcceptLanguageEntry = { tag: string; quality: number };
const parseAcceptLanguage = (header: string): AcceptLanguageEntry[] =>
header
.split(',')
.flatMap((part): AcceptLanguageEntry[] => {
const [rawTag, ...params] = part.trim().split(';');
if (rawTag === undefined || rawTag.length === 0) return [];
const qParam = params.find((p) => p.trim().startsWith('q='))?.trim();
const parsedQuality =
qParam !== undefined ? Number.parseFloat(qParam.slice(2)) : 1;
return [
{
tag: rawTag.trim(),
quality: Number.isFinite(parsedQuality) ? parsedQuality : 0,
},
];
})
.sort((a, b) => b.quality - a.quality);
const matchTag = (tag: string): AppLocale | undefined => {
const exact = APP_LOCALE_BY_RAW.get(tag);
if (exact !== undefined) return exact;
const [languageSubtag] = tag.split('-');
if (languageSubtag === undefined) return undefined;
return APP_LOCALE_BY_LANGUAGE.get(languageSubtag.toLowerCase());
};
type DetectLocaleInput = {
cookieValue?: string;
acceptLanguageHeader?: string;
};
export const detectLocale = ({
cookieValue,
acceptLanguageHeader,
}: DetectLocaleInput): AppLocale => {
if (cookieValue !== undefined) {
const fromCookie = APP_LOCALE_BY_RAW.get(cookieValue);
if (fromCookie !== undefined) return fromCookie;
}
if (acceptLanguageHeader !== undefined && acceptLanguageHeader.length > 0) {
for (const { tag } of parseAcceptLanguage(acceptLanguageHeader)) {
const match = matchTag(tag);
if (match !== undefined) return match;
}
}
return SOURCE_LOCALE;
};
@@ -0,0 +1,17 @@
export {
APP_LOCALE_BY_LANGUAGE,
APP_LOCALE_BY_RAW,
APP_LOCALE_LIST,
PUBLIC_APP_LOCALE_LIST,
isPublicAppLocale,
} from './app-locale-set';
export { createI18nInstance } from './create-i18n-instance';
export { detectLocale, LOCALE_COOKIE_NAME } from './detect-locale';
export { I18nProvider } from './I18nProvider';
export { LocaleContext } from './LocaleContext';
export { LocalizedLink } from './LocalizedLink';
export { localizeHref, stripLocale } from './localize-href';
export { getLocaleMessages } from './messages-by-locale';
export { resolveLocaleParam } from './resolve-locale-param';
export { useLocale } from './use-locale';
export { useUnlocalizedPathname } from './use-unlocalized-pathname';
@@ -0,0 +1,47 @@
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { APP_LOCALE_BY_RAW } from './app-locale-set';
const findFirstSegmentEnd = (path: string): number => {
for (let i = 1; i < path.length; i += 1) {
const ch = path[i];
if (ch === '/' || ch === '?' || ch === '#') return i;
}
return path.length;
};
const buildTailFromSegmentEnd = (path: string, segmentEnd: number): string => {
const tail = path.slice(segmentEnd);
if (tail.length === 0) return '/';
if (tail.startsWith('?') || tail.startsWith('#')) return `/${tail}`;
return tail;
};
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 = APP_LOCALE_BY_RAW.get(firstSegment);
if (existingLocale !== undefined && existingLocale !== SOURCE_LOCALE) {
return href;
}
const unprefixed =
existingLocale === SOURCE_LOCALE
? buildTailFromSegmentEnd(href, segmentEnd)
: href;
return locale === SOURCE_LOCALE ? unprefixed : `/${locale}${unprefixed}`;
};
export const stripLocale = (pathname: string): string => {
if (!pathname.startsWith('/')) return pathname;
const segmentEnd = findFirstSegmentEnd(pathname);
const firstSegment = pathname.slice(1, segmentEnd);
if (!APP_LOCALE_BY_RAW.has(firstSegment)) return pathname;
return buildTailFromSegmentEnd(pathname, segmentEnd);
};
@@ -0,0 +1,13 @@
import { type Messages } from '@lingui/core';
import { SOURCE_LOCALE, type AppLocale } from 'twenty-shared/translations';
import { messages as enMessages } from '@/locales/generated/en';
const MESSAGES_BY_LOCALE: Partial<Record<AppLocale, Messages>> = {
en: enMessages,
};
export const getLocaleMessages = (locale: AppLocale): Messages =>
MESSAGES_BY_LOCALE[locale] ?? MESSAGES_BY_LOCALE[SOURCE_LOCALE] ?? {};
export { MESSAGES_BY_LOCALE };
@@ -0,0 +1,10 @@
import { notFound } from 'next/navigation';
import { type AppLocale } from 'twenty-shared/translations';
import { APP_LOCALE_BY_RAW } from './app-locale-set';
export const resolveLocaleParam = (raw: string): AppLocale => {
const locale = APP_LOCALE_BY_RAW.get(raw);
if (locale === undefined) notFound();
return locale;
};

Some files were not shown because too many files have changed in this diff Show More