|
| 1 | +--- |
| 2 | +keywords: i18n, internationalization, locale, language, translation |
| 3 | +--- |
| 4 | + |
| 5 | +# Internationalization (i18n) |
| 6 | + |
| 7 | +This page documents the i18n feature - a multi-language docs are configured in `docs.json`, how routes/files are organized, and how the framework wires it all up at runtime. |
| 8 | + |
| 9 | +> **Status:** V1. Routing, per-locale navigation, per-locale content, the `FwLocaleSwitcher` component (auto-registered on the `nav.right` surface), the `"i18n: <key>"` translation-key resolver, catalog-only navigation (top-level nav inherited when language entry omits `sidebar`/`tabs`/etc.), and per-locale settings overrides — both via `navigation.languages[].overrides` and via catalog `$`-prefixed keys — all work. SEO (`hreflang`, `<html lang>`), search-locale filtering, and the prehydration script are tracked as follow-up slices — see "Future work" at the bottom. |
| 10 | +
|
| 11 | +## Overview |
| 12 | + |
| 13 | +xyd's i18n is configured via a single source of truth: `navigation.languages[]`. Each entry is a per-locale navigation block (sidebar/tabs/anchors) plus locale fields (`language`, `name`, `default`, `dir`, `overrides`). |
| 14 | + |
| 15 | +The default locale's content lives at the content root and is served unprefixed (`/docs/intro`). Other locales mirror that tree under `<language>/` and are served at `/<language>/<slug>` (e.g. `/pl/docs/intro`). |
| 16 | + |
| 17 | +Missing translations 404 — there is no fallback to the default locale. |
| 18 | + |
| 19 | +## Architecture |
| 20 | + |
| 21 | +```mermaid |
| 22 | +graph TB |
| 23 | + CFG["docs.json\n(navigation.languages)"] --> DERIVE["pluginDocs:\nderiveI18n() → globalThis.__xydI18n"] |
| 24 | + DERIVE --> CATALOGS["pluginDocs:\nloadI18nTranslations()"] |
| 25 | + CATALOGS --> EXTRACT["pluginDocs:\nextractCatalogOverrides()\n($-keys → lang.overrides)"] |
| 26 | + EXTRACT --> INHERIT["pluginDocs:\ninheritTopLevelNavigation()\n(catalog-only mode)"] |
| 27 | + INHERIT --> PREFIX["pluginDocs:\nprefixSidebarPages()\n(non-default locales)"] |
| 28 | + PREFIX --> MAPPING["__xydPagePathMapping\n(locale-prefixed keys)"] |
| 29 | + MAPPING --> ROUTES["pathRoutes()\n(emits routes per language)"] |
| 30 | + ROUTES --> RR["React Router routes\n/docs/intro, /pl/docs/intro, …"] |
| 31 | +
|
| 32 | + URL["request URL"] --> GETPATH["getPathname()\n→ {slug, locale}"] |
| 33 | + GETPATH --> LOADER["page/layout loader"] |
| 34 | + LOADER --> MAP["mapSettingsToProps(\n settings, mapping, slug, _, locale)"] |
| 35 | + MAP --> RESOLVE["resolveLocaleSettings()\nswap nav + applyOverrides()"] |
| 36 | +
|
| 37 | + style CFG fill:#81ecec,color:#333,stroke:#00cec9 |
| 38 | + style DERIVE fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 39 | + style CATALOGS fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 40 | + style EXTRACT fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 41 | + style INHERIT fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 42 | + style PREFIX fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 43 | + style MAPPING fill:#a29bfe,color:#fff,stroke:#8b83e8 |
| 44 | + style ROUTES fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 45 | + style RR fill:#00b894,color:#fff,stroke:#009a7a |
| 46 | + style URL fill:#dfe6e9,color:#333,stroke:#b2bec3 |
| 47 | + style GETPATH fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 48 | + style LOADER fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 49 | + style MAP fill:#00b894,color:#fff,stroke:#009a7a |
| 50 | + style RESOLVE fill:#00b894,color:#fff,stroke:#009a7a |
| 51 | +``` |
| 52 | + |
| 53 | +### Key insight: pre-prefixing |
| 54 | + |
| 55 | +The framework's "trick" for keeping the rest of the stack locale-unaware is **pre-prefixing**. At boot, `pluginDocs` walks each non-default-locale entry's `sidebar` and prepends the locale code to every page string and `SidebarRoute.route`: |
| 56 | + |
| 57 | +```ts |
| 58 | +// Before (user wrote): |
| 59 | +{ language: "pl", sidebar: ["docs/intro", { route: "/api", pages: [/*…*/] }] } |
| 60 | + |
| 61 | +// After prefixSidebarPages(): |
| 62 | +{ language: "pl", sidebar: ["pl/docs/intro", { route: "/pl/api", pages: [/*…*/] }] } |
| 63 | +``` |
| 64 | + |
| 65 | +This makes URL slugs, sidebar pages, and `__xydPagePathMapping` keys all share **one key space**. Downstream code (`mapSettingsToProps`, `pathRoutes`, `docPaths`) just walks the prefixed sidebar and gets correct results without any locale-aware lookups. |
| 66 | + |
| 67 | +### Derived globals: `__xydI18n` |
| 68 | + |
| 69 | +At `appInit()`, `pluginDocs` derives and caches: |
| 70 | + |
| 71 | +```ts |
| 72 | +globalThis.__xydI18n = { |
| 73 | + defaultLocale: string, // i18n.defaultLocale ?? languages.find(l => l.default)?.language ?? languages[0].language |
| 74 | + locales: string[], // languages.map(l => l.language) |
| 75 | + byLocale: Record<string, LanguageNavigation>,// keyed lookup |
| 76 | + detectLanguage: boolean // i18n.detectLanguage ?? false |
| 77 | +} |
| 78 | +``` |
| 79 | + |
| 80 | +Read by routing, the page/layout loaders, `docPaths`, and (in future slices) sitemap/prehydration/search. |
| 81 | + |
| 82 | +### URL → slug → file |
| 83 | + |
| 84 | +```mermaid |
| 85 | +graph LR |
| 86 | + URL["/pl/docs/intro"] --> STRIP["getPathname()\nstrip basename"] |
| 87 | + STRIP --> SLUG["slug = 'pl/docs/intro'\nlocale = 'pl'"] |
| 88 | + SLUG --> LOOKUP["__xydPagePathMapping['pl/docs/intro']"] |
| 89 | + LOOKUP --> FILE["content/pl/docs/intro.md"] |
| 90 | +
|
| 91 | + style URL fill:#dfe6e9,color:#333,stroke:#b2bec3 |
| 92 | + style STRIP fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 93 | + style SLUG fill:#a29bfe,color:#fff,stroke:#8b83e8 |
| 94 | + style LOOKUP fill:#a29bfe,color:#fff,stroke:#8b83e8 |
| 95 | + style FILE fill:#00b894,color:#fff,stroke:#009a7a |
| 96 | +``` |
| 97 | + |
| 98 | +`getPathname()` (in `packages/xyd-plugin-docs/src/pages/page.tsx` and `layout.tsx`) returns `{ slug, locale }`. The slug already contains the locale prefix when serving a non-default locale (because the sidebar was pre-prefixed and routes were emitted accordingly), so the mapping lookup is a direct dictionary hit. The separate `locale` is passed to `mapSettingsToProps` so it can resolve the right per-language navigation tree. |
| 99 | + |
| 100 | +### `inheritTopLevelNavigation` (catalog-only mode) |
| 101 | + |
| 102 | +If a language entry omits `sidebar`/`tabs`/`sidebarDropdown`/`segments`/`anchors`, it inherits the matching field from the top-level `navigation`. This lets users write the structure once at `navigation.sidebar` and only declare locales — translations come from catalogs (see below). |
| 103 | + |
| 104 | +```ts |
| 105 | +// User wrote: |
| 106 | +{ navigation: { languages: [{language:"en",default:true},{language:"pl"}], |
| 107 | + sidebar: [{group:"i18n: x", pages:["intro"]}] } } |
| 108 | + |
| 109 | +// After inheritTopLevelNavigation(): |
| 110 | +// each language entry gets a deep-cloned copy of the top-level sidebar, |
| 111 | +// so the prefixSidebarPages() pass below mutates per-locale copies safely. |
| 112 | +``` |
| 113 | + |
| 114 | +Implemented in `pluginDocs` as `inheritTopLevelNavigation(settings)`, called once before `prefixSidebarPages` runs. |
| 115 | + |
| 116 | +### `extractCatalogOverrides` (catalog `$`-keys) |
| 117 | + |
| 118 | +Catalog keys prefixed with `$` are not translation keys — they encode per-locale settings overrides applied to the matching language entry's `overrides`: |
| 119 | + |
| 120 | +```json |
| 121 | +// pl.json |
| 122 | +{ |
| 123 | + "sidebar.greet": "Cześć", |
| 124 | + "$components.footer.footnote.props.children": "Wspierane przez LiveSession", |
| 125 | + "$components.footer.footnote.props.href": "https://pl.livesession.io" |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +At boot, `pluginDocs` calls `extractCatalogOverrides(catalogs)` which: |
| 130 | + |
| 131 | +1. Pulls out every `$`-prefixed key from each catalog and strips the prefix. |
| 132 | +2. Mutates the catalogs to drop the `$` keys (so `"i18n: <key>"` lookup never sees them). |
| 133 | +3. Returns a per-locale flat-key overrides map. |
| 134 | + |
| 135 | +The map is then merged into `navigation.languages[].overrides` so the runtime `resolveLocaleSettings` deep-merges it like any declared override. |
| 136 | + |
| 137 | +### `resolveLocaleSettings` + `applyOverrides` |
| 138 | + |
| 139 | +```ts |
| 140 | +function resolveLocaleSettings(settings: Settings, locale?: string): Settings { |
| 141 | + const langs = settings?.navigation?.languages |
| 142 | + if (!locale || !langs?.length) return settings |
| 143 | + const entry = langs.find(l => l.language === locale) |
| 144 | + if (!entry) return settings |
| 145 | + |
| 146 | + const next: Settings = { |
| 147 | + ...settings, |
| 148 | + navigation: { |
| 149 | + ...settings.navigation, |
| 150 | + sidebar: entry.sidebar, |
| 151 | + tabs: entry.tabs, |
| 152 | + sidebarDropdown: entry.sidebarDropdown, |
| 153 | + segments: entry.segments, |
| 154 | + anchors: entry.anchors, |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + if (entry.overrides) { |
| 159 | + return applyOverrides(next, entry.overrides) |
| 160 | + } |
| 161 | + return next |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | +`applyOverrides` deep-merges the override block into the resolved settings. It supports both shapes: |
| 166 | + |
| 167 | +- **Nested objects** — standard `Partial<Settings>` (`{components: {footer: {…}}}`). |
| 168 | +- **Flat dot-keys** — `{"components.footer.footnote.props.children": "Wspierane"}`. Expanded to nested form before merge so a single override path doesn't have to declare four levels of object literal. |
| 169 | + |
| 170 | +Two helpers do the work: `expandDotKeys()` walks the override map and turns dot-keys into nested objects; `mergeDeep()` merges source into target without mutating either side. JSON cloning protects the result from later mutation. |
| 171 | + |
| 172 | +This is the only locale-aware step inside `mapSettingsToProps`. Everything downstream (sidebar groups, breadcrumbs, prev/next nav links) operates on `settings.navigation.sidebar` as if there were no i18n. |
| 173 | + |
| 174 | +### Prerender list (`docPaths`) |
| 175 | + |
| 176 | +`xyd-host/app/docPaths.ts` collects the list of routes to prerender (used by `react-router.config.ts` with `ssr: false`). In i18n mode it walks every language's sidebar and returns the locale-prefixed paths. With React Router 7's stricter `ssr:false` validator, every route that exports a `loader` must be in the prerender list, so this step is load-bearing. |
| 177 | + |
| 178 | +## Configuration |
| 179 | + |
| 180 | +### Minimal form |
| 181 | + |
| 182 | +```json |
| 183 | +{ |
| 184 | + "theme": { "name": "cosmo" }, |
| 185 | + "navigation": { |
| 186 | + "languages": [ |
| 187 | + { |
| 188 | + "language": "en", |
| 189 | + "name": "English", |
| 190 | + "default": true, |
| 191 | + "sidebar": ["docs/intro"] |
| 192 | + }, |
| 193 | + { "language": "pl", "name": "Polski", "sidebar": ["docs/intro"] }, |
| 194 | + { "language": "de", "name": "Deutsch", "sidebar": ["docs/intro"] } |
| 195 | + ] |
| 196 | + } |
| 197 | +} |
| 198 | +``` |
| 199 | + |
| 200 | +### Optional `i18n` block |
| 201 | + |
| 202 | +A top-level `i18n` block is optional and carries site-wide flags that don't belong on a single locale entry: |
| 203 | + |
| 204 | +```json |
| 205 | +{ |
| 206 | + "i18n": { |
| 207 | + "defaultLocale": "en", |
| 208 | + "detectLanguage": true |
| 209 | + }, |
| 210 | + "navigation": { "languages": [/* … */] } |
| 211 | +} |
| 212 | +``` |
| 213 | + |
| 214 | +When the `i18n` block is present, it wins: |
| 215 | + |
| 216 | +- `i18n.defaultLocale` overrides any `default: true` shorthand on language entries. |
| 217 | +- `i18n.detectLanguage` — reserved for the prehydration redirect (future slice; currently unused at runtime). |
| 218 | +- `i18n.catalogs` — translation catalogs used by the `"i18n: <key>"` resolver. See [Translation catalogs](#translation-catalogs) below. |
| 219 | + |
| 220 | +### Translation catalogs |
| 221 | + |
| 222 | +Any string in the navigation tree (and any value passed through `useT()` from a theme) can be written as `"i18n: <key>"`. At request time the framework looks the key up in the current-locale catalog (then the default-locale catalog, then falls back to the literal key for visibility in dev). Catalogs accept both flat dot-keys and nested objects in the same file. |
| 223 | + |
| 224 | +There are three ways to register catalogs, in priority order: |
| 225 | + |
| 226 | +**1. Custom file paths** — relative to project root or absolute: |
| 227 | + |
| 228 | +```json |
| 229 | +{ |
| 230 | + "i18n": { |
| 231 | + "catalogs": { |
| 232 | + "en": "./locales/english.json", |
| 233 | + "pl": "./tlumaczenia/polski.json", |
| 234 | + "de": "./locales/de-DE.json" |
| 235 | + } |
| 236 | + } |
| 237 | +} |
| 238 | +``` |
| 239 | + |
| 240 | +**2. Inline catalogs** — useful for small projects: |
| 241 | + |
| 242 | +```json |
| 243 | +{ |
| 244 | + "i18n": { |
| 245 | + "catalogs": { |
| 246 | + "en": { "footer.copyright": "All rights reserved" }, |
| 247 | + "pl": { "footer": { "copyright": "Wszelkie prawa zastrzeżone" } } |
| 248 | + } |
| 249 | + } |
| 250 | +} |
| 251 | +``` |
| 252 | + |
| 253 | +**3. Convention fallback** — when `i18n.catalogs` is omitted (or a particular locale's entry is missing), the framework auto-discovers `i18n/<language>.json` at the project root. |
| 254 | + |
| 255 | +#### Catalog file format |
| 256 | + |
| 257 | +Two equivalent shapes; both can coexist in the same file: |
| 258 | + |
| 259 | +```json |
| 260 | +// Flat dot-keys |
| 261 | +{ "footer.resources.header": "Resources", "footer.resources.examples": "Examples" } |
| 262 | + |
| 263 | +// Or nested objects |
| 264 | +{ "footer": { "resources": { "header": "Resources", "examples": "Examples" } } } |
| 265 | +``` |
| 266 | + |
| 267 | +Lookup tries the exact flat key first, then walks dot-segments through nested objects. |
| 268 | + |
| 269 | +For the full field reference, see the `Settings`, `Navigation`, `LanguageNavigation`, and `I18nConfig` interfaces in `4.settings/1.SETTINGS.md` (and the source of truth: `packages/xyd-core/src/types/settings.ts`). |
| 270 | + |
| 271 | +### Per-locale overrides |
| 272 | + |
| 273 | +Each `navigation.languages[]` entry accepts `overrides?: Partial<Settings>` for any field that should differ per locale (e.g. translated footer link text, banner content, locale-specific component config). Two equivalent shapes are accepted: |
| 274 | + |
| 275 | +```jsonc |
| 276 | +// Flat dot-key (recommended for narrow overrides) |
| 277 | +{ "language": "pl", |
| 278 | + "overrides": { "components.footer.footnote.props.children": "Wspierane" } } |
| 279 | + |
| 280 | +// Nested Partial<Settings> |
| 281 | +{ "language": "pl", |
| 282 | + "overrides": { "components": { "footer": { "footnote": { "props": { "children": "Wspierane" } } } } } } |
| 283 | +``` |
| 284 | + |
| 285 | +Overrides come from two sources, both ending up on the same `lang.overrides` field: |
| 286 | + |
| 287 | +1. **Declared in `docs.json`** under `navigation.languages[].overrides`. |
| 288 | +2. **Extracted from catalog `$`-keys** at boot — e.g. `"$components.footer.footnote.props.children"` in `pl.json` becomes `lang.overrides["components.footer.footnote.props.children"]`. See `extractCatalogOverrides` above. |
| 289 | + |
| 290 | +At request time `resolveLocaleSettings` deep-merges `lang.overrides` into the effective settings. Flat dot-keys are expanded to nested form before the merge. |
| 291 | + |
| 292 | +## File structure |
| 293 | + |
| 294 | +``` |
| 295 | +content/ |
| 296 | +├── docs/intro.md # default locale (en) |
| 297 | +├── docs/api.md |
| 298 | +├── pl/ |
| 299 | +│ └── docs/intro.md # Polish translation |
| 300 | +└── de/ |
| 301 | + └── docs/intro.md # German translation |
| 302 | +``` |
| 303 | + |
| 304 | +- The default locale's content lives at the content root, matching its unprefixed URL. |
| 305 | +- Each non-default locale is a mirror subtree under `<language>/`. |
| 306 | +- Frontmatter is translated **per file**: each locale's `.md` owns its own `title`, `description`, etc. |
| 307 | +- Untranslated pages are simply absent on disk → 404 at request time. |
| 308 | + |
| 309 | +## Affected files |
| 310 | + |
| 311 | +| Concern | File / symbol | |
| 312 | +|---|---| |
| 313 | +| Settings types (`I18nConfig`, `LanguageNavigation.overrides`, `TranslationCatalog`) | `packages/xyd-core/src/types/settings.ts` | |
| 314 | +| i18n derivation, catalog loading, sidebar pre-prefixing, path mapping | `packages/xyd-plugin-docs/src/index.ts` | |
| 315 | +| Catalog-only mode helper (`inheritTopLevelNavigation`) | `packages/xyd-plugin-docs/src/index.ts` | |
| 316 | +| Catalog `$`-key extractor (`extractCatalogOverrides`) | `packages/xyd-plugin-docs/src/index.ts` | |
| 317 | +| Page loader (slug + locale extraction) | `packages/xyd-plugin-docs/src/pages/page.tsx` | |
| 318 | +| Layout loader | `packages/xyd-plugin-docs/src/pages/layout.tsx` | |
| 319 | +| Route generation | `packages/xyd-host/app/pathRoutes.ts` | |
| 320 | +| Prerender list | `packages/xyd-host/app/docPaths.ts` | |
| 321 | +| Sidebar / breadcrumbs / navlinks per locale, override merge (`applyOverrides`, `expandDotKeys`, `mergeDeep`) | `packages/xyd-framework/packages/hydration/mapSettingsToProps.ts` | |
| 322 | +| Unit tests | `packages/xyd-plugin-docs/__tests__/i18n.{loadTranslations,inheritTopLevelNavigation,extractCatalogOverrides}.test.ts` | |
| 323 | + |
| 324 | +## Future work |
| 325 | + |
| 326 | +Tracked under follow-up slices, not in V1: |
| 327 | + |
| 328 | +- **SEO**: `<html lang>`, `<link rel="alternate" hreflang="…">`, sitemap `xhtml:link` alternates, per-locale `llms.txt`. |
| 329 | +- **Prehydration script**: sets `<html lang>` synchronously before React hydration; honors `i18n.detectLanguage`. |
| 330 | +- **Search localization**: tag indexed docs with locale, filter by current locale. |
| 331 | +- **Per-locale OpenAPI / GraphQL specs**: V2. |
0 commit comments