Localisation
Which locales the site serves, how the locale reaches every address, how to add one, and where translation is resolved.
Locales and RTL
Localisation on the site has two moving parts that meet in @v4/model: the set of
locales an environment serves, and the dictionary of UI copy those
locales translate (covered in the Dictionary keys
guide). This page is about the locales themselves and the current position on
right-to-left layout.
Two lists of locales
There are two lists, and they are not the same thing:
- the dictionary locales — the locales the Contentful space holds labels for, generated from the space; and
- the served locales — the locales the site answers on, declared in
club.config.ts.
The dictionary locales
Locales are environment-scoped Contentful configuration. The committed list lives in the generated dictionary map and is the source of truth the code compiles against:
// packages/model/src/__generated__/dictionary/index.ts — generated, do not edit
export const DICTIONARY_LOCALES = ["en-GB", "en-US", "fr-FR", "it-IT"] as const;
export const DEFAULT_DICTIONARY_LOCALE = "en-US" as const;
en-US is the default and the fallback: a key resolves from the requested
locale, then en-US, then the default copy in keys.ts. Every consumer of
getDictionary(locale) (and the synchronous createDictionary(locale) behind
it) is typed against DICTIONARY_LOCALES, so a locale that is not in that array
is a compile error, never a runtime surprise.
One consequence catches people out: a seeded en-US label beats the default
copy in keys.ts. So the site's copy can differ from the design — and from the
vocabulary file — with nothing wrong in the code; the label in Contentful is
what wins.
Only en-US carries labels in this space (12gd2cvfgb6b). The generated
en-GB.ts, fr-FR.ts and it-IT.ts maps are empty (labels = {}); en-US.ts
holds every label. Translation is an editorial task carried out in Contentful,
not in code — re-pull with sync-dictionary before relying on any coverage.
The served locales
The site serves one locale:
// apps/web/club.config.ts
locales: { supported: ["en-US"], default: "en-US", routing: "segment" },
routing: "segment" puts the locale's segment in every page address —
/en/news, /en/teams/euroleague. pnpm compose generates the segment map as
apps/web/src/lib/locale-routing.generated.ts
(LOCALE_SEGMENTS = { "en-US": "en" }, DEFAULT_LOCALE_SEGMENT = "en"), and:
- every route lives under
apps/web/src/app/[locale]/; apps/web/src/middleware.tssends an address with no supported segment to the same page in the default's (/news→/en/news, a 308);apps/web/src/lib/locale-routing.tsholds the app's helpers —withLocale,withoutLocale,localisedAddress,HOME_PATH;localePathin@v4/model/coreturns a site path into the address the browser uses (/news→/en/news) wherever a path leaves for the browser: a link's href, a redirect, a canonical, a sitemap entry. Code that READS a path works without the segment.
Adding a locale
Locales are Contentful configuration that contentful-migration does not
manage, so a dedicated CMA script adds them. This is the mechanical half of the
/model-locale skill:
node packages/model/scripts/add-locale.mjs \
--code=es-ES --name="Spanish (Spain)" [--fallback=en-US] [--optional] [--env=develop]
--code and --name are required. --fallback defaults to en-GB in the
script, which is not this space's default — pass --fallback=en-US. --env
defaults to develop (or
CONTENTFUL_MODEL_ENV). The script shares the same protected-environment guard
as the migration runner — it refuses master, production, staging and
staging-release unless it is running in CI with the environment explicitly
unlocked. It authenticates with CONTENTFUL_CMA_TOKEN (read from the
environment or the root .env).
Adding a locale changes both the vendored GraphQL schema and the dictionary
maps, so it is not a one-command job. After running add-locale.mjs, complete
the sequence:
pnpm --filter @v4/model sync-schema # the schema gains the new locale
pnpm --filter @v4/model sync-dictionary # regenerate the per-locale maps
pnpm --filter @v4/model generate # regenerate types
Then update baseline/locales.json from a fresh export, and expect every
localised field across the space to need content — a new locale starts empty and
falls back to en-US everywhere until editors translate it.
That adds a dictionary locale. For the site to serve it, add it to
locales.supported in apps/web/club.config.ts and run pnpm compose to
regenerate the segment map. (One served locale is what the code assumes today:
localePath holds a single prefix, and is the seam a second locale would
change.)
The RTL position
The token layer anticipates right-to-left languages: tokens.json ships a
complete P.RTL.* typography ramp and an fontFamilies.rtl entry. The
components do not yet consume them. Every Ghost 2.0 component set is authored
LTR-only — 93 component manifests bind P.LTR.*, and the single P.RTL.*
binding (on one form-input-field node) is an outlier rather than a pattern.
Because Ghost 2.0 has no RTL artboards, mirroring behaviour is an engineering decision made against the WAI/W3C i18n guidance, not a design hand-off. Two mechanisms already carry that decision:
- Logical properties everywhere. Components use
marginInlineStart,paddingInlineEnd,insetInlineStart,borderStartStartRadiusand so on from day one, so the layout mirrors underdir="rtl"for free. directionalIconfor the glyphs that mean direction. Logical properties mirror layout but not artwork — an arrow drawn pointing right keeps pointing right on an RTL page. The one house mechanism for flipping inline-axis navigation glyphs (next/back paddles, drill-in chevrons) is the opt-indirectionalIconutility exported from@v4/ghost-core, applied per icon slot withdata-directional="true". It is never blanket — logos, play triangles, ticks and block-axis chevrons must not flip.
The full rules live in
.claude/rules/frontend/vanilla-extract-conventions.md under "RTL: Logical
Properties from Day One" and "Directional glyphs". Revisit the whole position
when design ships RTL artboards.
Server-first resolution
The house pattern for translated copy is resolve on the server, pass strings to the client. It keeps the per-locale label maps out of client bundles.
Where translation happens
Resolve copy in React Server Components and in transform code — the same layer
that turns @v4/model fragments into component props — and hand the resulting
plain strings down as props to client components:
import { getDictionary } from "@v4/model/dictionary";
// in an RSC or a route factory (server code)
const t = await getDictionary(locale);
const heading = t("article.label.relatedContent");
const readTime = t("article.Label.estimatedReadingTime");
// pass the resolved STRINGS as props — never the dictionary itself
return <RelatedContent heading={heading} readTimeLabel={readTime} />;
getDictionary(locale), from the server-only @v4/model/dictionary
subpath, reads the live labels from Contentful through the client the app
configured once (configureDictionary({ client }), in
apps/web/src/lib/content.ts). The read is cached and tagged
content:dictionaryKey, so an editor's published label reaches the site without
a deploy. The 404 page is a worked example
(apps/web/src/app/[locale]/not-found.tsx).
createDictionary(locale) from @v4/model is the synchronous snapshot
over the committed generated maps. getDictionary falls back to it when no
client is configured or the fetch fails — copy is never worth a 500 — and tests
use it directly.
Both return the same typed function. Resolution order per key is
requested locale → default locale (en-US) → the vocabulary's default copy
in keys.ts, so a key always resolves to something — a missing translation
degrades to English rather than rendering an empty string or a raw key.
Why not resolve in the client
The generated snapshot maps are sizeable — en-US.ts, the only populated one
in this space, is about 46 KB (the en-GB, fr-FR and it-IT maps are empty).
They only enter a client bundle if a client module imports
createDictionary directly; getDictionary cannot be imported there at all
(it is server-only). Resolving server-side means:
- the client ships the handful of strings a component actually renders, not the whole dictionary for its locale;
- the locale decision is made once, at the top of the render, close to where the request's locale is known;
- Server Components and transforms are already the layer that shapes model data into props, so translation slots in without a new seam.
Treat a client-component import of createDictionary as a smell: it usually
means a string that should have been resolved a layer up.
Interpolation is typed
Keys whose default copy carries a placeholder require a matching params object;
keys with no placeholder refuse one — both checked at compile time, because
keys.ts is as const and the compiler reads each default label as a literal
type:
const t = createDictionary("en-US");
// default copy: "Would you like to update to version {{version}} ?"
t("appVersion.update.description", { version: "4.2.0" }); // ok
t("appVersion.update.description"); // type error — params required
// a key with no placeholder refuses a params object
t("common.action.signIn", { foo: "bar" }); // type error
Both {name} and the legacy {{name}} placeholder forms are recognised (both
appear in the seeded copy). For namespaced access, t.scope("appMoreMenu.action")
returns a scoped accessor whose keys are the suffixes under that prefix — the
params typing carries through it unchanged. See the
Consuming section of the dictionary guide for more.