|
| 1 | +--- |
| 2 | +keywords: i18n, internationalization, locale, language, translation |
| 3 | +--- |
| 4 | + |
| 5 | +# Internationalization (i18n) |
| 6 | + |
| 7 | +This page documents the i18n layer in xyd: how 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, and per-locale content all work. SEO (`hreflang`, `<html lang>`), the locale switcher, search-locale filtering, prehydration script, and the `"i18n: <key>"` translation-key resolver are tracked as follow-up slices — see "Future work" at the bottom. |
| 10 | +
|
| 11 | +For the original design rationale and the full V1+V2 spec, see `.research/Internationalization.md`. |
| 12 | + |
| 13 | +## Overview |
| 14 | + |
| 15 | +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`). |
| 16 | + |
| 17 | +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`). |
| 18 | + |
| 19 | +Missing translations 404 — there is no fallback to the default locale. |
| 20 | + |
| 21 | +## Configuration |
| 22 | + |
| 23 | +### Minimal form |
| 24 | + |
| 25 | +```json |
| 26 | +{ |
| 27 | + "theme": { "name": "cosmo" }, |
| 28 | + "navigation": { |
| 29 | + "languages": [ |
| 30 | + { |
| 31 | + "language": "en", |
| 32 | + "name": "English", |
| 33 | + "default": true, |
| 34 | + "sidebar": ["docs/intro"] |
| 35 | + }, |
| 36 | + { "language": "pl", "name": "Polski", "sidebar": ["docs/intro"] }, |
| 37 | + { "language": "de", "name": "Deutsch", "sidebar": ["docs/intro"] } |
| 38 | + ] |
| 39 | + } |
| 40 | +} |
| 41 | +``` |
| 42 | + |
| 43 | +### Optional `i18n` block |
| 44 | + |
| 45 | +A top-level `i18n` block is optional and carries site-wide flags that don't belong on a single locale entry: |
| 46 | + |
| 47 | +```json |
| 48 | +{ |
| 49 | + "i18n": { |
| 50 | + "defaultLocale": "en", |
| 51 | + "detectLanguage": true |
| 52 | + }, |
| 53 | + "navigation": { "languages": [/* … */] } |
| 54 | +} |
| 55 | +``` |
| 56 | + |
| 57 | +When the `i18n` block is present, it wins: |
| 58 | + |
| 59 | +- `i18n.defaultLocale` overrides any `default: true` shorthand on language entries. |
| 60 | +- `i18n.detectLanguage` — reserved for the prehydration redirect (future slice; currently unused at runtime). |
| 61 | +- `i18n.translations` — reserved for translation catalogs used by the `"i18n: <key>"` resolver (future slice). |
| 62 | + |
| 63 | +### Field reference |
| 64 | + |
| 65 | +| Field | Type | Description | |
| 66 | +|---|---|---| |
| 67 | +| `navigation.languages[]` | `LanguageNavigation[]` | Required for i18n. Per-locale navigation blocks. | |
| 68 | +| `navigation.languages[].language` | `string` | Required. ISO 639-1 code. Used in URLs. | |
| 69 | +| `navigation.languages[].name` | `string` | Native display name (`Polski`, not `Polish`). Reserved for the locale switcher. | |
| 70 | +| `navigation.languages[].default` | `boolean` | Marks this entry as the default locale. Exactly one entry should set this; if none does, the first wins. | |
| 71 | +| `navigation.languages[].dir` | `"ltr" \| "rtl"` | Optional, sets `<html dir>` (full theme RTL audit is V2). | |
| 72 | +| `navigation.languages[].overrides` | `Partial<Settings>` | Per-locale overrides for top-level `Settings` keys (e.g. `components.footer`, `theme.head`). Shallow-merged on top of root settings when serving this locale. | |
| 73 | +| `navigation.languages[].sidebar` | `SidebarNavigation` | Per-locale sidebar tree. | |
| 74 | +| `navigation.languages[].tabs` / `.anchors` / `.sidebarDropdown` / `.segments` | various | Same as the flat `Navigation` shape. | |
| 75 | +| `i18n` | `I18nConfig` | Optional. Site-wide i18n flags. Omit if the per-language entries above are sufficient. | |
| 76 | +| `i18n.defaultLocale` | `string` | Explicit default. Overrides per-entry `default: true`. | |
| 77 | +| `i18n.detectLanguage` | `boolean` | Reserved (future). Default `false`. | |
| 78 | +| `i18n.translations` | `Record<string, string \| TranslationCatalog>` | Reserved (future). | |
| 79 | + |
| 80 | +Types are defined in `packages/xyd-core/src/types/settings.ts`. |
| 81 | + |
| 82 | +## File structure |
| 83 | + |
| 84 | +``` |
| 85 | +content/ |
| 86 | +├── docs/intro.md # default locale (en) |
| 87 | +├── docs/api.md |
| 88 | +├── pl/ |
| 89 | +│ └── docs/intro.md # Polish translation |
| 90 | +└── de/ |
| 91 | + └── docs/intro.md # German translation |
| 92 | +``` |
| 93 | + |
| 94 | +- The default locale's content lives at the content root, matching its unprefixed URL. |
| 95 | +- Each non-default locale is a mirror subtree under `<language>/`. |
| 96 | +- Frontmatter is translated **per file**: each locale's `.md` owns its own `title`, `description`, etc. |
| 97 | +- Untranslated pages are simply absent on disk → 404 at request time. |
| 98 | + |
| 99 | +## Architecture |
| 100 | + |
| 101 | +```mermaid |
| 102 | +graph TB |
| 103 | + CFG["docs.json\n(navigation.languages)"] --> DERIVE["pluginDocs:\nderiveI18n() → globalThis.__xydI18n"] |
| 104 | + DERIVE --> PREFIX["pluginDocs:\nprefixSidebarPages()\n(non-default locales)"] |
| 105 | + PREFIX --> MAPPING["__xydPagePathMapping\n(locale-prefixed keys)"] |
| 106 | + MAPPING --> ROUTES["pathRoutes()\n(emits routes per language)"] |
| 107 | + ROUTES --> RR["React Router routes\n/docs/intro, /pl/docs/intro, …"] |
| 108 | +
|
| 109 | + URL["request URL"] --> GETPATH["getPathname()\n→ {slug, locale}"] |
| 110 | + GETPATH --> LOADER["page/layout loader"] |
| 111 | + LOADER --> MAP["mapSettingsToProps(\n settings, mapping, slug, _, locale)"] |
| 112 | + MAP --> RESOLVE["resolveLocaleSettings()\nswaps in lang.sidebar"] |
| 113 | +
|
| 114 | + style CFG fill:#81ecec,color:#333,stroke:#00cec9 |
| 115 | + style DERIVE fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 116 | + style PREFIX fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 117 | + style MAPPING fill:#a29bfe,color:#fff,stroke:#8b83e8 |
| 118 | + style ROUTES fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 119 | + style RR fill:#00b894,color:#fff,stroke:#009a7a |
| 120 | + style URL fill:#dfe6e9,color:#333,stroke:#b2bec3 |
| 121 | + style GETPATH fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 122 | + style LOADER fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 123 | + style MAP fill:#00b894,color:#fff,stroke:#009a7a |
| 124 | + style RESOLVE fill:#00b894,color:#fff,stroke:#009a7a |
| 125 | +``` |
| 126 | + |
| 127 | +### Key insight: pre-prefixing |
| 128 | + |
| 129 | +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`: |
| 130 | + |
| 131 | +```ts |
| 132 | +// Before (user wrote): |
| 133 | +{ language: "pl", sidebar: ["docs/intro", { route: "/api", pages: [/*…*/] }] } |
| 134 | + |
| 135 | +// After prefixSidebarPages(): |
| 136 | +{ language: "pl", sidebar: ["pl/docs/intro", { route: "/pl/api", pages: [/*…*/] }] } |
| 137 | +``` |
| 138 | + |
| 139 | +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. |
| 140 | + |
| 141 | +### Derived globals: `__xydI18n` |
| 142 | + |
| 143 | +At `appInit()`, `pluginDocs` derives and caches: |
| 144 | + |
| 145 | +```ts |
| 146 | +globalThis.__xydI18n = { |
| 147 | + defaultLocale: string, // i18n.defaultLocale ?? languages.find(l => l.default)?.language ?? languages[0].language |
| 148 | + locales: string[], // languages.map(l => l.language) |
| 149 | + byLocale: Record<string, LanguageNavigation>,// keyed lookup |
| 150 | + detectLanguage: boolean // i18n.detectLanguage ?? false |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +Read by routing, the page/layout loaders, `docPaths`, and (in future slices) sitemap/prehydration/search. |
| 155 | + |
| 156 | +### URL → slug → file |
| 157 | + |
| 158 | +```mermaid |
| 159 | +graph LR |
| 160 | + URL["/pl/docs/intro"] --> STRIP["getPathname()\nstrip basename"] |
| 161 | + STRIP --> SLUG["slug = 'pl/docs/intro'\nlocale = 'pl'"] |
| 162 | + SLUG --> LOOKUP["__xydPagePathMapping['pl/docs/intro']"] |
| 163 | + LOOKUP --> FILE["content/pl/docs/intro.md"] |
| 164 | +
|
| 165 | + style URL fill:#dfe6e9,color:#333,stroke:#b2bec3 |
| 166 | + style STRIP fill:#6c5ce7,color:#fff,stroke:#5a4bd4 |
| 167 | + style SLUG fill:#a29bfe,color:#fff,stroke:#8b83e8 |
| 168 | + style LOOKUP fill:#a29bfe,color:#fff,stroke:#8b83e8 |
| 169 | + style FILE fill:#00b894,color:#fff,stroke:#009a7a |
| 170 | +``` |
| 171 | + |
| 172 | +`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. |
| 173 | + |
| 174 | +### `resolveLocaleSettings` in mapSettingsToProps |
| 175 | + |
| 176 | +```ts |
| 177 | +function resolveLocaleSettings(settings: Settings, locale?: string): Settings { |
| 178 | + const langs = settings?.navigation?.languages |
| 179 | + if (!locale || !langs?.length) return settings |
| 180 | + const entry = langs.find(l => l.language === locale) |
| 181 | + if (!entry) return settings |
| 182 | + return { |
| 183 | + ...settings, |
| 184 | + navigation: { |
| 185 | + ...settings.navigation, |
| 186 | + sidebar: entry.sidebar, |
| 187 | + tabs: entry.tabs, |
| 188 | + // … etc |
| 189 | + } |
| 190 | + } |
| 191 | +} |
| 192 | +``` |
| 193 | + |
| 194 | +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. |
| 195 | + |
| 196 | +### Prerender list (`docPaths`) |
| 197 | + |
| 198 | +`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. |
| 199 | + |
| 200 | +## Affected files |
| 201 | + |
| 202 | +| Concern | File | |
| 203 | +|---|---| |
| 204 | +| Settings types | `packages/xyd-core/src/types/settings.ts` | |
| 205 | +| i18n derivation, sidebar pre-prefixing, path mapping | `packages/xyd-plugin-docs/src/index.ts` | |
| 206 | +| Page loader (slug + locale extraction) | `packages/xyd-plugin-docs/src/pages/page.tsx` | |
| 207 | +| Layout loader | `packages/xyd-plugin-docs/src/pages/layout.tsx` | |
| 208 | +| Route generation | `packages/xyd-host/app/pathRoutes.ts` | |
| 209 | +| Prerender list | `packages/xyd-host/app/docPaths.ts` | |
| 210 | +| Sidebar / breadcrumbs / navlinks per locale | `packages/xyd-framework/packages/hydration/mapSettingsToProps.ts` | |
| 211 | + |
| 212 | +## Testing |
| 213 | + |
| 214 | +E2E fixture: `__tests__/e2e/8.i18n/1.basic/`. Three locales (en/pl/de), one page each, asserts: |
| 215 | + |
| 216 | +- Default locale (`en`) serves at unprefixed URL `/docs/intro`. |
| 217 | +- Polish at `/pl/docs/intro`. |
| 218 | +- German at `/de/docs/intro`. |
| 219 | +- Different content per locale (English vs Polish vs German body). |
| 220 | + |
| 221 | +Run via Docker (the only supported way to run e2e locally with a fresh xyd published to Verdaccio): |
| 222 | + |
| 223 | +```bash |
| 224 | +./__tests__/docker/run-e2e.sh -- __tests__/e2e/8.i18n/1.basic |
| 225 | +``` |
| 226 | + |
| 227 | +## Future work |
| 228 | + |
| 229 | +Tracked under follow-up slices, not in V1: |
| 230 | + |
| 231 | +- **SEO**: `<html lang>`, `<link rel="alternate" hreflang="…">`, sitemap `xhtml:link` alternates, per-locale `llms.txt`. |
| 232 | +- **Locale switcher**: `FwLocaleSwitcher` component + Surface registration on `nav.right`. |
| 233 | +- **Prehydration script**: sets `<html lang>` synchronously before React hydration; honors `i18n.detectLanguage`. |
| 234 | +- **Translation key resolver**: `"i18n: footer.resources.header"` strings in component config, resolved at render time from per-locale catalogs declared in `i18n.translations`. |
| 235 | +- **Search localization**: tag indexed docs with locale, filter by current locale. |
| 236 | +- **Per-locale OpenAPI / GraphQL specs**: V2. |
| 237 | + |
| 238 | +Full spec for each lives in `.research/Internationalization.md`. |
0 commit comments