Files
twenty/packages/twenty-website/next.config.ts
T
Abdullah. 012af11d77 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).
2026-06-28 14:39:59 +05:00

226 lines
6.6 KiB
TypeScript

import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
import path from 'path';
import { WEBSITE_LOCALE_LIST } from './src/platform/i18n/website-locale-list';
import { buildLocaleRewrites } from './src/platform/routing/locale-rewrite-patterns';
const SECURITY_HEADERS: { key: string; value: string }[] = [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
];
// Skew protection: CI sets DEPLOYMENT_ID at build time so it's baked into
// prerendered HTML + the RSC payloads. The Worker reads the same value at
// runtime (via the worker env var) and routes mismatched requests to the
// matching older Worker version via its preview URL. Required whenever
// open-next.config.ts has cloudflare.skewProtection.enabled.
const deploymentId = process.env.DEPLOYMENT_ID;
const nextConfig: LinariaConfig = {
deploymentId,
reactCompiler: true,
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
hostname: 'avatars.githubusercontent.com',
pathname: '/**',
protocol: 'https',
},
{
hostname: 'twenty-icons.com',
pathname: '/**',
protocol: 'https',
},
],
},
linaria: {
configFile: path.resolve(__dirname, 'wyw-in-js.config.cjs'),
},
experimental: {
swcPlugins: [
[
'@lingui/swc-plugin',
{
runtimeModules: {
i18n: ['@lingui/core', 'i18n'],
trans: ['@lingui/react', 'Trans'],
},
},
],
],
},
async headers() {
return [
{
source: '/:path*',
headers: SECURITY_HEADERS,
},
{
source: '/(images|illustrations|lottie)/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
];
},
// Clean public URLs: the source locale is unprefixed, other locales get a
// short segment. Rewrites map unprefixed paths onto the internal /[locale]
// tree; redirects canonicalize away explicit source-locale prefixes.
async rewrites() {
return {
beforeFiles: buildLocaleRewrites(WEBSITE_LOCALE_LIST),
};
},
async redirects() {
return [
// Canonicalise www → apex. Host-based; fires before any locale logic.
// The root-path rule must come before the :path* one — Next.js's
// path-to-regexp leaves a literal `:path*` in the Location header
// when the parameter matches empty against an absolute destination.
{
source: '/',
has: [{ type: 'host', value: 'www.twenty.com' }],
destination: 'https://twenty.com/',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'www.twenty.com' }],
destination: 'https://twenty.com/:path*',
permanent: true,
},
{
source: '/',
has: [{ type: 'host', value: 'www.twenty-main.com' }],
destination: 'https://twenty-main.com/',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'www.twenty-main.com' }],
destination: 'https://twenty-main.com/:path*',
permanent: true,
},
// Strip the source-locale prefix: /en/foo → /foo (301).
{ source: '/en', destination: '/', statusCode: 301 },
{ source: '/en/:path*', destination: '/:path*', statusCode: 301 },
{
source: '/user-guide',
destination: 'https://docs.twenty.com/user-guide/introduction',
permanent: true,
},
{
source: '/user-guide/section/:folder/:slug*',
destination: 'https://docs.twenty.com/user-guide/:folder/:slug*',
permanent: true,
},
{
source: '/user-guide/:folder/:slug*',
destination: 'https://docs.twenty.com/user-guide/:folder/:slug*',
permanent: true,
},
{
source: '/developers',
destination: 'https://docs.twenty.com/developers/introduction',
permanent: true,
},
{
source: '/developers/section/:folder/:slug*',
destination: 'https://docs.twenty.com/developers/:folder/:slug*',
permanent: true,
},
{
source: '/developers/:folder/:slug*',
destination: 'https://docs.twenty.com/developers/:folder/:slug*',
permanent: true,
},
{
source: '/twenty-ui',
destination: 'https://docs.twenty.com/twenty-ui/introduction',
permanent: true,
},
{
source: '/twenty-ui/section/:folder/:slug*',
destination: 'https://docs.twenty.com/twenty-ui/:folder/:slug*',
permanent: true,
},
{
source: '/twenty-ui/:folder/:slug*',
destination: 'https://docs.twenty.com/twenty-ui/:folder/:slug*',
permanent: true,
},
{
source: '/resources/why-twenty',
destination: '/why-twenty',
permanent: true,
},
{
source: '/story',
destination: '/why-twenty',
permanent: true,
},
{
source: '/legal/privacy',
destination: '/privacy-policy',
permanent: true,
},
{
source: '/legal/terms',
destination: '/terms',
permanent: true,
},
{
source: '/legal/dpa',
destination: '/terms',
permanent: true,
},
{
source: '/case-studies/9-dots-story',
destination: '/customers/9dots',
permanent: true,
},
{
source: '/case-studies/act-immi-story',
destination: '/customers/act-education',
permanent: true,
},
{
source: '/case-studies/:slug*',
destination: '/customers',
permanent: true,
},
{
source: '/implementation-services',
destination: '/partners',
permanent: true,
},
{
source: '/onboarding-packages',
destination: '/partners',
permanent: true,
},
];
},
};
export default withLinaria(nextConfig);
// Binds the Cloudflare dev context (R2 incremental cache, env vars) into
// `next dev` so local runs mirror the deployed OpenNext worker.
initOpenNextCloudflareForDev();