Skip to content

Commit 21391f8

Browse files
committed
feat(i18n): catalog-only navigation, dot-key + catalog-driven overrides
- inheritTopLevelNavigation(): when navigation.languages[].sidebar/tabs/etc. are omitted, copy the top-level navigation onto the entry. Lets users define the structure once and only declare locales — translations come from catalogs. - extractCatalogOverrides(): catalog keys prefixed with `$` are settings override paths, not translation keys. Pulled out at boot and merged into the matching navigation.languages[].overrides. - applyOverrides() in mapSettingsToProps.resolveLocaleSettings: deep-merge per-locale overrides into the resolved settings at request time. Accepts both nested Partial<Settings> and flat dot-keys (expanded before merge). - Rename `i18n.translations` -> `i18n.catalogs` across types, loader, e2e fixture, and docs. - Unit tests for inheritTopLevelNavigation (5) and extractCatalogOverrides (5); existing loadTranslations tests updated for the rename. - New user-facing guide apps/docs/guides/internalization.md and updated dev doc .dev/docs/18.features/Internationalization.md.
1 parent 45704c5 commit 21391f8

10 files changed

Lines changed: 821 additions & 25 deletions

File tree

.dev/docs/18.features/Internationalization.md

Lines changed: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ keywords: i18n, internationalization, locale, language, translation
66

77
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.
88

9-
> **Status:** V1. Routing, per-locale navigation, per-locale content, the `FwLocaleSwitcher` component (auto-registered on the `nav.right` surface), and the `"i18n: <key>"` translation-key resolver 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.
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.
1010
1111
## Overview
1212

@@ -21,18 +21,24 @@ Missing translations 404 — there is no fallback to the default locale.
2121
```mermaid
2222
graph TB
2323
CFG["docs.json\n(navigation.languages)"] --> DERIVE["pluginDocs:\nderiveI18n() → globalThis.__xydI18n"]
24-
DERIVE --> PREFIX["pluginDocs:\nprefixSidebarPages()\n(non-default locales)"]
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)"]
2528
PREFIX --> MAPPING["__xydPagePathMapping\n(locale-prefixed keys)"]
2629
MAPPING --> ROUTES["pathRoutes()\n(emits routes per language)"]
2730
ROUTES --> RR["React Router routes\n/docs/intro, /pl/docs/intro, …"]
2831
2932
URL["request URL"] --> GETPATH["getPathname()\n→ {slug, locale}"]
3033
GETPATH --> LOADER["page/layout loader"]
3134
LOADER --> MAP["mapSettingsToProps(\n settings, mapping, slug, _, locale)"]
32-
MAP --> RESOLVE["resolveLocaleSettings()\nswaps in lang.sidebar"]
35+
MAP --> RESOLVE["resolveLocaleSettings()\nswap nav + applyOverrides()"]
3336
3437
style CFG fill:#81ecec,color:#333,stroke:#00cec9
3538
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
3642
style PREFIX fill:#6c5ce7,color:#fff,stroke:#5a4bd4
3743
style MAPPING fill:#a29bfe,color:#fff,stroke:#8b83e8
3844
style ROUTES fill:#6c5ce7,color:#fff,stroke:#5a4bd4
@@ -91,26 +97,78 @@ graph LR
9197

9298
`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.
9399

94-
### `resolveLocaleSettings` in mapSettingsToProps
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`
95138

96139
```ts
97140
function resolveLocaleSettings(settings: Settings, locale?: string): Settings {
98141
const langs = settings?.navigation?.languages
99142
if (!locale || !langs?.length) return settings
100143
const entry = langs.find(l => l.language === locale)
101144
if (!entry) return settings
102-
return {
145+
146+
const next: Settings = {
103147
...settings,
104148
navigation: {
105149
...settings.navigation,
106150
sidebar: entry.sidebar,
107151
tabs: entry.tabs,
108-
// … etc
152+
sidebarDropdown: entry.sidebarDropdown,
153+
segments: entry.segments,
154+
anchors: entry.anchors,
109155
}
110156
}
157+
158+
if (entry.overrides) {
159+
return applyOverrides(next, entry.overrides)
160+
}
161+
return next
111162
}
112163
```
113164

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+
114172
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.
115173

116174
### Prerender list (`docPaths`)
@@ -157,7 +215,7 @@ When the `i18n` block is present, it wins:
157215

158216
- `i18n.defaultLocale` overrides any `default: true` shorthand on language entries.
159217
- `i18n.detectLanguage` — reserved for the prehydration redirect (future slice; currently unused at runtime).
160-
- `i18n.translations` — translation catalogs used by the `"i18n: <key>"` resolver. See [Translation catalogs](#translation-catalogs) below.
218+
- `i18n.catalogs` — translation catalogs used by the `"i18n: <key>"` resolver. See [Translation catalogs](#translation-catalogs) below.
161219

162220
### Translation catalogs
163221

@@ -170,7 +228,7 @@ There are three ways to register catalogs, in priority order:
170228
```json
171229
{
172230
"i18n": {
173-
"translations": {
231+
"catalogs": {
174232
"en": "./locales/english.json",
175233
"pl": "./tlumaczenia/polski.json",
176234
"de": "./locales/de-DE.json"
@@ -184,15 +242,15 @@ There are three ways to register catalogs, in priority order:
184242
```json
185243
{
186244
"i18n": {
187-
"translations": {
245+
"catalogs": {
188246
"en": { "footer.copyright": "All rights reserved" },
189247
"pl": { "footer": { "copyright": "Wszelkie prawa zastrzeżone" } }
190248
}
191249
}
192250
}
193251
```
194252

195-
**3. Convention fallback** — when `i18n.translations` is omitted (or a particular locale's entry is missing), the framework auto-discovers `i18n/<language>.json` at the project root.
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.
196254

197255
#### Catalog file format
198256

@@ -210,6 +268,27 @@ Lookup tries the exact flat key first, then walks dot-segments through nested ob
210268

211269
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`).
212270

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+
213292
## File structure
214293

215294
```
@@ -229,15 +308,18 @@ content/
229308

230309
## Affected files
231310

232-
| Concern | File |
311+
| Concern | File / symbol |
233312
|---|---|
234-
| Settings types | `packages/xyd-core/src/types/settings.ts` |
235-
| i18n derivation, sidebar pre-prefixing, path mapping | `packages/xyd-plugin-docs/src/index.ts` |
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` |
236317
| Page loader (slug + locale extraction) | `packages/xyd-plugin-docs/src/pages/page.tsx` |
237318
| Layout loader | `packages/xyd-plugin-docs/src/pages/layout.tsx` |
238319
| Route generation | `packages/xyd-host/app/pathRoutes.ts` |
239320
| Prerender list | `packages/xyd-host/app/docPaths.ts` |
240-
| Sidebar / breadcrumbs / navlinks per locale | `packages/xyd-framework/packages/hydration/mapSettingsToProps.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` |
241323

242324
## Future work
243325

__tests__/e2e/8.i18n/4.translation-keys-custom-paths/docs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"theme": { "name": "cosmo" },
33
"i18n": {
4-
"translations": {
4+
"catalogs": {
55
"en": "./locales/english.json",
66
"pl": { "sidebar.getstarted": "Zaczynamy" }
77
}

apps/docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@
228228
"pages": [
229229
"guides/writing-quickstart",
230230
"guides/developer-content",
231+
"guides/internalization",
231232
"guides/seo",
232233
"guides/react-components",
233234
"guides/compose-content"

0 commit comments

Comments
 (0)