Skip to content

Commit 29bef4a

Browse files
authored
Merge pull request #145 from livesession/feat/i18n-v2
feat(i18n): re-apply internationalization support
2 parents ed3375e + 27f3830 commit 29bef4a

84 files changed

Lines changed: 3368 additions & 162 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.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: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
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.

.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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
title: Einführung
3+
description: Deutsche Einführung
4+
---
5+
6+
# Willkommen bei xyd
7+
8+
Diese Seite ist auf Deutsch.

0 commit comments

Comments
 (0)