Skip to content

Commit 67e8f40

Browse files
committed
docs(i18n): add dev docs for V1 internationalization
- New 18.features/Internationalization.md covering config shape, file structure, architecture (pre-prefixing trick + __xydI18n derivation), URL→file resolution, affected files, and the follow-up slices that V1 doesn't cover yet. - Update 18.features/1.FEATURES.md to list i18n. - Add Locale, LanguageNavigation, and i18n entries to GLOSSARY.md. - Reference the new doc from CLAUDE.md so future sessions pick it up.
1 parent f3570fd commit 67e8f40

4 files changed

Lines changed: 249 additions & 0 deletions

File tree

.dev/docs/18.features/1.FEATURES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ A list of xyd's user features:
1515
8. Custom Vite
1616
9. Fonts
1717
10. Custom Icons
18+
11. Internationalization (i18n)
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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`.

.dev/docs/GLOSSARY.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ HMR (Hot Module Replacement)
9494

9595
## I
9696

97+
i18n
98+
: Internationalization. xyd's multi-language support, configured via `navigation.languages[]` in `docs.json`. Default locale is unprefixed; non-default locales are served under `/<language>/`. Missing translations 404. See `18.features/Internationalization.md`.
99+
97100
Infer
98101
: The `infer(reference, instance)` function from `@xyd-js/uniform/inspection`. Wraps a static Reference with a decoupled ValueMap, enabling reactive proxies via `play()`, value snapshots, and reset.
99102

@@ -113,9 +116,15 @@ KaTeX
113116

114117
## L
115118

119+
LanguageNavigation
120+
: A `navigation.languages[]` entry. Same shape as `Navigation` (sidebar/tabs/anchors) plus locale fields: `language` (ISO 639-1 code), `name` (native display), `default`, `dir`, and `overrides` (per-locale `Partial<Settings>`).
121+
116122
Lerna
117123
: Build orchestrator used for monorepo management alongside pnpm workspaces. Handles dependency-ordered builds and watch mode.
118124

125+
Locale
126+
: An ISO 639-1 language code (e.g. `en`, `pl`, `de`) declared in `navigation.languages[].language`. The default locale's URLs are unprefixed; other locales are prefixed with `/<language>/`.
127+
119128
Linaria
120129
: Zero-runtime CSS-in-JS library used for styling across `@xyd-js/components`, `@xyd-js/ui`, and `@xyd-js/atlas` packages.
121130

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
### 18. Features
100100
- @.dev/docs/18.features/1.FEATURES.md
101101
- @.dev/docs/18.features/AccessControl.md
102+
- @.dev/docs/18.features/Internationalization.md
102103

103104
### 19. Refactor
104105

0 commit comments

Comments
 (0)