@xndrjs/i18n 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +67 -872
  2. package/dist/codegen/index.d.ts +117 -51
  3. package/dist/codegen/index.js +1585 -93
  4. package/dist/codegen/index.js.map +1 -1
  5. package/dist/index.d.ts +100 -234
  6. package/dist/index.js +74 -638
  7. package/dist/index.js.map +1 -1
  8. package/dist/validation/index.d.ts +5 -0
  9. package/dist/validation/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/IcuTranslationProviderMulti.test.ts +18 -40
  12. package/src/IcuTranslationProviderMulti.ts +27 -37
  13. package/src/audit/audit-dictionaries.ts +1 -1
  14. package/src/audit/run-audit.test.ts +6 -7
  15. package/src/builder-load-registry.ts +26 -5
  16. package/src/builder-loaders.ts +8 -11
  17. package/src/builder-types.test.ts +11 -42
  18. package/src/builder.test.ts +82 -243
  19. package/src/builder.ts +8 -110
  20. package/src/codegen/codegen-config-schema.ts +82 -77
  21. package/src/codegen/config.test.ts +55 -143
  22. package/src/codegen/config.ts +10 -67
  23. package/src/codegen/dictionary-spec-contract.test.ts +28 -0
  24. package/src/codegen/dictionary-spec-contract.ts +138 -0
  25. package/src/codegen/emit/dictionary-schema-file.ts +7 -43
  26. package/src/codegen/emit/instance-file.test.ts +35 -52
  27. package/src/codegen/emit/instance-file.ts +170 -268
  28. package/src/codegen/emit/namespace-loaders-file.test.ts +72 -74
  29. package/src/codegen/emit/namespace-loaders-file.ts +147 -90
  30. package/src/codegen/emit/types-file.test.ts +20 -41
  31. package/src/codegen/emit/types-file.ts +48 -74
  32. package/src/codegen/generate-i18n-types.test.ts +163 -492
  33. package/src/codegen/generate-i18n-types.ts +7 -269
  34. package/src/codegen/paths.ts +0 -14
  35. package/src/codegen/project-locales-set-namespace.test.ts +44 -86
  36. package/src/codegen/read-dictionary.test.ts +27 -9
  37. package/src/codegen/read-dictionary.ts +18 -40
  38. package/src/codegen/regenerate-namespaces.ts +180 -0
  39. package/src/codegen/run-codegen.test.ts +230 -0
  40. package/src/codegen/run-codegen.ts +252 -0
  41. package/src/codegen/types.ts +0 -6
  42. package/src/codegen-config/build-config.ts +4 -23
  43. package/src/codegen-config/codegen-config.test.ts +10 -6
  44. package/src/codegen-config/index.ts +18 -3
  45. package/src/engine.ts +3 -27
  46. package/src/fetch-artifact.ts +16 -0
  47. package/src/i18n-handle.ts +202 -0
  48. package/src/index.ts +15 -34
  49. package/src/project-locales.ts +2 -2
  50. package/src/scope-multi.ts +1 -20
  51. package/src/scope-types.ts +14 -6
  52. package/src/scope.test.ts +3 -312
  53. package/src/serialized-state.test.ts +114 -0
  54. package/src/serialized-state.ts +28 -0
  55. package/src/setup/setup-i18n.test.ts +22 -33
  56. package/src/setup/setup-i18n.ts +22 -27
  57. package/src/types.ts +3 -3
  58. package/src/validation/index.ts +4 -0
  59. package/src/IcuTranslationProviderSingle.test.ts +0 -177
  60. package/src/IcuTranslationProviderSingle.ts +0 -114
  61. package/src/builder-multi.ts +0 -481
  62. package/src/codegen/emit/dictionary-file.test.ts +0 -215
  63. package/src/codegen/emit/dictionary-file.ts +0 -244
  64. package/src/deep-freeze.test.ts +0 -40
  65. package/src/deep-freeze.ts +0 -22
  66. package/src/patch-key.test.ts +0 -186
  67. package/src/patch-key.ts +0 -140
  68. package/src/scope-single.ts +0 -85
  69. package/src/single-builder.ts +0 -153
package/README.md CHANGED
@@ -1,946 +1,141 @@
1
1
  # @xndrjs/i18n
2
2
 
3
- A **compiler-first, type-safe i18n system** based on the [ICU MessageFormat](https://formatjs.github.io/docs/core-concepts/icu-syntax/) standard, with **runtime dictionary overrides from external sources** (no rebuild required).
3
+ Compiler-first, type-safe ICU MessageFormat i18n for TypeScript apps.
4
4
 
5
- The core idea: your ICU strings live in local JSON files that act as **type-safe fallbacks**. A build-time codegen step parses the ICU AST and generates exact TypeScript types for every translation key and its parameters. At runtime, a centralized engine caches compiled messages; the builder loads lazy artifacts, and locale-bound scopes patch individual keys via `scope.set()` (for example from a CMS) without rebuilding.
5
+ - **Codegen** turns JSON/YAML dictionaries into exact TypeScript types and lazy loaders.
6
+ - **Runtime** is a single handle: `createI18n(options?).load({ namespaces, locale })`.
7
+ - **Content updates without rebuild** use authoring edits outside the library + `regenerateNamespaces([...])` + `loaderStrategy: "fetch"`.
8
+ - **React** lives in [`@xndrjs/i18n-react`](../i18n-react) (gate / HOC, no Suspense).
6
9
 
7
- ## Key features
8
-
9
- - **Type-safe `t()`** — the compiler knows exactly which parameters each key requires (`string`, `number`, or none).
10
- - **ICU MessageFormat** — full support for interpolation, plurals, and select.
11
- - **Runtime override** — patch individual translation keys from an external source via `scope.set()` on locale-bound scopes (after `load()`), without rebuilding.
12
- - **Single-file or multi-namespace** — one flat dictionary, or multiple JSON files each bound to a namespace.
13
- - **Lazy namespace loading** — optional code-splitting via `loadOnInit` and generated `namespaceLoaders` (multi mode).
14
- - **Hot compilation cache** — compiled `IntlMessageFormat` instances are cached and invalidated on override.
15
- - **Explicit runtime errors** — malformed ICU (e.g. a corrupt remote payload) or missing parameters throw descriptive errors.
16
- - **Translation audit** — `xndrjs-i18n-audit` reports missing locales per key (direct vs effective after fallback); optional CI gate via `--fail-on`.
17
- - **Publishable library** — the runtime and codegen live in a standalone package (`@xndrjs/i18n`) that carries no project-specific types.
18
-
19
- ## Getting started
20
-
21
- ### Install
10
+ ## Install
22
11
 
23
12
  ```bash
24
13
  npm install @xndrjs/i18n zod
25
14
  npm install -D tsx
26
15
  ```
27
16
 
28
- `tsx` and `zod` are **peer dependencies** of `@xndrjs/i18n`:
29
-
30
- - **`tsx`** — required by `xndrjs-i18n-codegen` and `xndrjs-i18n-audit` (CLIs run TypeScript directly). Install as a **devDependency** — only needed at build/CI time.
31
- - **`zod`** — required by codegen/audit config validation (`i18n.codegen.json`) and by `@xndrjs/i18n/validation` when using `dictionarySchemaOutput` and `validateExternalDictionary()`. Use a **devDependency** if validation runs only at build time; a regular **dependency** if you validate CMS payloads in production.
32
-
33
- ### Quick setup (single file)
34
-
35
- **1. Translation JSON** (`i18n/translations/translations.json`):
36
-
37
- ```json
38
- {
39
- "login_button": { "en": "Login", "it": "Accedi" },
40
- "welcome": { "en": "Welcome {name}!" }
41
- }
42
- ```
43
-
44
- **2. Codegen config** (`i18n/i18n.codegen.json`):
45
-
46
- ```json
47
- {
48
- "dictionary": "translations/translations.json",
49
- "typesOutput": "generated/i18n-types.generated.ts",
50
- "dictionaryOutput": "generated/dictionary.generated.ts",
51
- "instanceOutput": "generated/instance.generated.ts",
52
- "paramsTypeName": "MyProjectParams",
53
- "schemaTypeName": "MyProjectSchema"
54
- }
55
- ```
56
-
57
- **3. npm script** (`package.json`):
58
-
59
- ```json
60
- {
61
- "scripts": {
62
- "i18n:codegen": "xndrjs-i18n-codegen --config i18n/i18n.codegen.json",
63
- "i18n:audit": "xndrjs-i18n-audit --config i18n/i18n.codegen.json"
64
- }
65
- }
66
- ```
67
-
68
- **4. Generate and audit:**
69
-
70
- ```bash
71
- npm run i18n:codegen
72
- npm run i18n:audit
73
- ```
74
-
75
- ```ts
76
- // i18n/index.ts
77
- import { createI18n } from "./generated/instance.generated.js";
78
- import { defaultDictionary } from "./generated/dictionary.generated.js";
79
-
80
- export * from "./generated/instance.generated.js";
81
- export * from "./generated/dictionary.generated.js";
82
- export * from "./generated/i18n-types.generated.js";
83
-
84
- export const i18n = createI18n(defaultDictionary);
85
- ```
86
-
87
- ```ts
88
- import { i18n } from "./i18n";
89
-
90
- const { t } = i18n;
91
-
92
- t("login_button", "it"); // "Accedi"
93
- t("welcome", "en", { name: "Ada" }); // "Welcome Ada!"
94
- ```
95
-
96
- Run codegen after every change to your JSON files (or wire it into your build).
97
-
98
- Or scaffold the starter files with the setup CLI:
99
-
100
- ```bash
101
- xndrjs-i18n-setup single . --project MyApp
102
- xndrjs-i18n-setup multi apps/myapp --project MyApp
103
- ```
104
-
105
- This creates `i18n/i18n.codegen.json`, starter translation JSON, and `i18n/index.ts` under the target directory. Pass `src` as the target directory if your project uses a `src/` layout (`src/i18n/`). Edit the config for lazy loading, validation, locale fallback, and extra namespaces, then run codegen.
106
-
107
- ### Quick setup (multi namespace)
108
-
109
- Use `namespaces` instead of `dictionary` in `i18n/i18n.codegen.json`. See [Configuration](#configuration-i18ncodegenjson) and the [multi-namespace example](#multi-namespace-example) below.
110
-
111
- For lazy loading, add `loadOnInit` and `namespaceLoadersOutput` — see [Lazy namespace loading](#lazy-namespace-loading-multi-mode). Add `dictionarySchemaOutput` only when you validate external CMS/API payloads.
112
-
113
- ## Repository layout
114
-
115
- This package lives in the xndrjs-toolkit pnpm monorepo.
116
-
117
- ```
118
- xndrjs-toolkit/
119
- ├── packages/
120
- │ └── i18n/ # @xndrjs/i18n — the publishable library
121
- │ ├── bin/
122
- │ │ ├── codegen.mjs # CLI entry: xndrjs-i18n-codegen
123
- │ │ └── audit.mjs # CLI entry: xndrjs-i18n-audit
124
- │ └── src/
125
- │ ├── index.ts # public exports
126
- │ ├── types.ts # generic dictionary/cache types
127
- │ ├── IcuTranslationProviderSingle.ts
128
- │ ├── IcuTranslationProviderMulti.ts
129
- │ └── codegen/
130
- │ └── generate-i18n-types.ts
131
- └── apps/
132
- └── i18n-demo/ # @xndrjs/i18n-demo — workshop app
133
- ├── single/ # single-file example (config at sub-project root)
134
- │ ├── i18n.codegen.json
135
- │ └── src/i18n/
136
- └── multi/ # multi-namespace example
137
- ├── i18n.codegen.json
138
- └── src/i18n/
139
- ```
140
-
141
- A typical consumer app scaffolded with `xndrjs-i18n-setup single .` colocates everything under `i18n/`:
142
-
143
- ```
144
- my-app/
145
- └── i18n/
146
- ├── i18n.codegen.json
147
- ├── index.ts
148
- ├── generated/
149
- └── translations/
150
- ```
151
-
152
- For a `src/` layout, run `xndrjs-i18n-setup single src` instead:
153
-
154
- ```
155
- my-app/
156
- └── src/
157
- └── i18n/
158
- ├── i18n.codegen.json
159
- ├── index.ts
160
- ├── generated/
161
- └── translations/
162
- ```
163
-
164
- ### Library vs. consumer
165
-
166
- `@xndrjs/i18n` is **generic** and never imports project-specific types. It exposes two provider classes parametrized by `Schema` and `Params` generics, plus the codegen CLI.
167
-
168
- The consumer app owns its ICU JSON, its codegen config, and the generated files that bind the generic providers to concrete types.
169
-
170
- ### 1. Source JSON
171
-
172
- Each translation key maps locale codes to ICU strings. Structure is identical in both modes: `key -> locale -> ICU string`.
173
-
174
- ```json
175
- {
176
- "login_button": { "it": "Accedi", "en": "Login" },
177
- "welcome": { "it": "Benvenuto {name}!", "en": "Welcome {name}!" },
178
- "dashboard_status": {
179
- "it": "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
180
- "en": "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}"
181
- }
182
- }
183
- ```
184
-
185
- ### 2. Codegen (build-time)
186
-
187
- `xndrjs-i18n-codegen` reads `i18n.codegen.json` (by default `i18n/i18n.codegen.json`), parses every ICU string with `@formatjs/icu-messageformat-parser`, and infers parameter types:
188
-
189
- | ICU construct | Inferred type |
190
- | ----------------------------------------- | ------------- |
191
- | Simple argument `{name}` | `string` |
192
- | `plural` argument `{count, plural, ...}` | `number` |
193
- | `select` argument `{gender, select, ...}` | `string` |
194
- | No variables | `never` |
195
-
196
- Variables found across **all locales** of the same key are merged. If parsing fails for any key/locale, codegen prints a contextual error and exits with a non-zero code (so it blocks CI/builds).
197
-
198
- ### 3. Generated files
199
-
200
- - **`i18n-types.generated.ts`** — `I18N_MODE`, `MyProjectParams`, `MyProjectSchema`. With `delivery: "custom"`, also `MyProjectDeliveryArea`, `DELIVERY_ARTIFACTS`, `LOCALE_DELIVERY_AREA`, and `MyProjectDeliveryArtifacts`.
201
- - **`dictionary.generated.ts`** — imports the JSON files and exports `defaultDictionary` (canonical delivery) or `defaultDictionaryFor(locale)` / `defaultDictionaryFor(area)` when eager namespaces exist in split/custom delivery. Omitted when every namespace is lazy in split/custom delivery.
202
- - **`instance.generated.ts`** — exports `createI18n(dictionary)` (required argument; no default import of the fallback dictionary), typed `projectDictionaryLocales()` (full schema), and in multi mode `projectNamespaceLocales()` (single namespace); with `delivery: "custom"`, also `projectDictionaryForDeliveryArea()` and `projectNamespaceForDeliveryArea()`; `LOCALE_FALLBACK` wired in when configured.
203
- - **`i18n.ts`** (optional, hand-written) — app-owned singleton if desired.
204
-
205
- Example generated types (multi-namespace):
206
-
207
- ```ts
208
- export const I18N_MODE = "multi" as const;
209
-
210
- export type MyProjectParams = {
211
- default: {
212
- login_button: never;
213
- welcome: { name: string };
214
- dashboard_status: { msgCount: number; chatCount: number };
215
- };
216
- user: {
217
- profile_title: never;
218
- greeting: { name: string };
219
- };
220
- billing: {
221
- invoice_summary: { count: number };
222
- };
223
- };
224
-
225
- export type MyProjectSchema = {
226
- default: {
227
- login_button: Partial<Record<MyProjectLocale, string>>;
228
- welcome: Partial<Record<MyProjectLocale, string>>;
229
- dashboard_status: Partial<Record<MyProjectLocale, string>>;
230
- };
231
- user: {
232
- profile_title: Partial<Record<MyProjectLocale, string>>;
233
- greeting: Partial<Record<MyProjectLocale, string>>;
234
- };
235
- billing: {
236
- invoice_summary: Partial<Record<MyProjectLocale, string>>;
237
- };
238
- };
239
- ```
240
-
241
- ### 4. Runtime provider
242
-
243
- Create a provider with the generated factory (no side effects at import time). Pass the dictionary explicitly — from codegen’s `dictionary.generated.ts`, from lazy loaders, or from an external source — so the factory module does not pull fallback JSON into your bundle:
244
-
245
- ```ts
246
- import { createI18n } from "./i18n";
247
- import { defaultDictionary } from "./i18n/generated/dictionary.generated.js";
248
-
249
- const i18n = createI18n(defaultDictionary);
250
- // or with an external dictionary at init:
251
- const i18n = createI18n(externalDictionary);
252
- ```
253
-
254
- Optionally, wrap it in an app-owned singleton (`i18n.ts`):
255
-
256
- ```ts
257
- import { createI18n } from "./instance.generated.js";
258
- import { defaultDictionary } from "./dictionary.generated.js";
259
- export const i18n = createI18n(defaultDictionary);
260
- ```
261
-
262
- ### Locale-bound provider (`forLocale`)
263
-
264
- Bind a locale once and omit it on every `t()`:
265
-
266
- ```ts
267
- const i18n = createI18n(defaultDictionary);
268
- const i18nEn = i18n.forLocale("en");
269
-
270
- i18nEn.t("login_button"); // single-file
271
- i18nEn.t("welcome", { name: "Ada" });
272
-
273
- const i18nIt = i18n.forLocale("it");
274
- i18nIt.t("default", "login_button"); // multi-namespace
275
- i18nIt.t("billing", "invoice_summary", { count: 3 });
276
- ```
277
-
278
- The bound scope shares the parent dictionary, cache, and fallback rules. It exposes `locale` and a narrower `t()` signature.
279
-
280
- Because `Params[K]` (or `Params[NS][K]`) is used in a conditional rest parameter, TypeScript enforces the exact argument shape:
281
-
282
- ```ts
283
- ...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
284
- ```
285
-
286
- ### 5. Locale fallback
287
-
288
- When a translation is missing for the requested locale (`undefined` in the dictionary), the provider can walk a fallback chain before throwing. An empty string `""` is treated as a valid template and does **not** trigger fallback.
289
-
290
- ```json
291
- "localeFallback": {
292
- "en": null,
293
- "de-DE": "en",
294
- "de-CH": "de-DE",
295
- "it": "en"
296
- }
297
- ```
298
-
299
- - `null` marks a terminal locale (no further fallback).
300
- - Any other value is the next locale to try, recursively.
301
- - If the chain ends without finding a template, `t()` throws and includes the full chain in the error message.
302
-
303
- Codegen emits `LOCALE_FALLBACK` and extends `MyProjectLocale` with the fallback locales. The generated factory wires the map into the provider automatically.
304
-
305
- When `localeFallback` is present in config, codegen **enriches** the generated map: every locale in `MyProjectLocale` that is missing from your config is added with `null` (explicit terminal — no fallback chain). Runtime behavior is unchanged; the map is simply complete. If `localeFallback` is omitted from config, no `LOCALE_FALLBACK` is emitted (current behavior).
306
-
307
- You can also pass a fallback map manually when constructing a provider:
308
-
309
- ```ts
310
- import { IcuTranslationProviderMulti, type LocaleFallbackMap } from "@xndrjs/i18n";
311
-
312
- const localeFallback = {
313
- en: null,
314
- "de-CH": "en",
315
- } satisfies LocaleFallbackMap;
316
-
317
- const i18n = new IcuTranslationProviderMulti(schema, { localeFallback });
318
- ```
319
-
320
- #### `projectDictionaryLocales` / `projectNamespaceLocales`
321
-
322
- Namespaces split the dictionary by domain; locale projection splits it by **locale**. Use with `load()` + `scope.set()` when ingesting external payloads for a single locale (or a small regional group) in memory.
323
-
324
- ```ts
325
- import { createI18n } from "./i18n/generated/instance.generated.js";
326
- import { validateExternalKey } from "./i18n/generated/dictionary-schema.generated.js";
327
- import {
328
- projectDictionaryLocales,
329
- projectNamespaceLocales,
330
- } from "./i18n/generated/instance.generated.js";
331
- import billingDictionary from "./i18n/translations/billing.json";
332
-
333
- const { t, set } = await createI18n({}).withNamespaces(["billing"]).withLocale(activeLocale).load();
334
-
335
- // Optional: validate a projected slice, then patch one key
336
- const billingEn = projectNamespaceLocales(billingDictionary, [activeLocale]);
337
- const result = validateExternalKey("billing", "invoice_summary", billingEn.invoice_summary);
338
- if (result.ok) {
339
- set("billing", "invoice_summary", result.data.invoice_summary[activeLocale]!);
340
- }
341
- ```
342
-
343
- Codegen emits typed wrappers in `instance.generated.ts` (`projectDictionaryLocales` for the full schema; `projectNamespaceLocales` in multi mode for one namespace). With `delivery: "custom"`, it also emits `projectDictionaryForDeliveryArea` and `projectNamespaceForDeliveryArea`. The low-level `@xndrjs/i18n` exports are `*Core` helpers for tooling without codegen.
344
-
345
- ### 6. Translation audit (`xndrjs-i18n-audit`)
346
-
347
- Report missing translations as JSON — useful for translator backlogs and optional CI gates.
348
-
349
- ```bash
350
- xndrjs-i18n-audit --config i18n/i18n.codegen.json
351
- xndrjs-i18n-audit --config i18n/i18n.codegen.json --out audit.json
352
- xndrjs-i18n-audit --config i18n/i18n.codegen.json --fail-on effective
353
- ```
354
-
355
- **`requiredLocales`** in the report match generated `MyProjectLocale` (dictionary locales ∪ fallback keys and targets).
356
-
357
- | Field | Meaning |
358
- | -------------------------- | ---------------------------------------------------------------------------------------- |
359
- | `missingDirectByLocale` | No template for that locale on the key (or empty string when `--allow-empty` is not set) |
360
- | `missingEffectiveByLocale` | Runtime would fail after walking `LOCALE_FALLBACK` — real coverage gaps |
361
-
362
- By default the CLI **does not fail** (exit `0`) — report-only. Pass `--fail-on effective`, `direct`, or `any` to exit `1` when gaps exist (for CI).
363
-
364
- Locales such as `de-CH` that exist only in `localeFallback` (not in source files) typically show high `missingDirect` but low `missingEffective` when `en` covers the chain — that is expected.
365
-
366
- ## Configuration (`i18n/i18n.codegen.json`)
367
-
368
- Specify **exactly one** of `dictionary` (single-file) or `namespaces` (multi-file). Paths in the config are relative to the directory containing `i18n.codegen.json` (the `i18n/` folder created by setup).
369
-
370
- ### Multi-namespace
371
-
372
- ```json
373
- {
374
- "namespaces": {
375
- "default": "translations/default.json",
376
- "user": "translations/user.json",
377
- "billing": "translations/billing.yaml"
378
- },
379
- "typesOutput": "generated/i18n-types.generated.ts",
380
- "dictionaryOutput": "generated/dictionary.generated.ts",
381
- "instanceOutput": "generated/instance.generated.ts",
382
- "paramsTypeName": "MyProjectParams",
383
- "schemaTypeName": "MyProjectSchema",
384
- "localeTypeName": "MyProjectLocale",
385
- "factoryName": "createI18n",
386
- "localeFallback": {
387
- "en": null,
388
- "de-DE": "en",
389
- "de-CH": "de-DE",
390
- "it": "en"
391
- }
392
- }
393
- ```
394
-
395
- ### Single-file
17
+ Peer deps: `zod` (config + validation), `tsx` (CLI).
396
18
 
397
- ```json
398
- {
399
- "dictionary": "translations/translations.json",
400
- "defaultNamespace": "default",
401
- "typesOutput": "generated/i18n-types.generated.ts",
402
- "dictionaryOutput": "generated/dictionary.generated.ts",
403
- "instanceOutput": "generated/instance.generated.ts",
404
- "paramsTypeName": "MyProjectParams",
405
- "schemaTypeName": "MyProjectSchema",
406
- "localeTypeName": "MyProjectLocale",
407
- "factoryName": "createI18n",
408
- "localeFallback": {
409
- "en": null,
410
- "de-DE": "en",
411
- "de-CH": "de-DE",
412
- "it": "en"
413
- }
414
- }
415
- ```
416
-
417
- | Field | Description |
418
- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
419
- | `dictionary` | Path to a single dictionary file (`.json`, `.yaml`, or `.yml`) for the flat API. Mutually exclusive with `namespaces`. |
420
- | `namespaces` | Map of `namespace -> dictionary path` (`.json`, `.yaml`, or `.yml`) for the namespaced API. Mutually exclusive with `dictionary`. |
421
- | `defaultNamespace` | Optional. Namespace label used internally in single-file mode (default `"default"`). Not exposed in the flat API. |
422
- | `typesOutput` | Output path for the generated types. |
423
- | `dictionaryOutput` | Optional. Output path for `dictionary.generated.ts` when codegen emits it (canonical delivery, or split/custom with eager namespaces). Omit in split/custom when every namespace is lazy — codegen does not write the file; if a stale manifest exists at the default `{dirname(typesOutput)}/dictionary.generated.ts`, it is removed. Explicit `dictionaryOutput` still controls which path is cleaned up. |
424
- | `instanceOutput` | Output path for the generated factory (`createI18n`). |
425
- | `importExtension` | Optional. Relative import suffix between generated `.ts` modules: `"none"` (default, extensionless), `".ts"`, or `".js"`. |
426
- | `factoryName` | Name of the exported factory function (default `createI18n`). |
427
- | `paramsTypeName` / `schemaTypeName` | Names of the exported types (customizable per project). |
428
- | `localeTypeName` | Name of the exported locale union type (default `MyProjectLocale`). |
429
- | `localeFallback` | Optional map of `locale -> next locale | null` for runtime fallback resolution. |
430
- | `localeFallbackConstName` | Name of the generated fallback constant (default `LOCALE_FALLBACK`). |
431
- | `dictionarySchemaOutput` | Optional path for generated external dictionary validation (`dictionary-schema.generated.ts`). Requires `zod` in the consumer app. |
432
- | `loadOnInit` | Multi mode only; **canonical delivery only**. Namespaces to include in the initial bundle via static imports. When omitted, all namespaces are eager (default). Not allowed with `split-by-locale` or `custom` — those modes load every namespace through `namespaceLoaders`. |
433
- | `namespaceLoadersOutput` | Output path for generated `namespaceLoaders` (dynamic `import()` per lazy namespace). Defaults to `{dirname(instanceOutput)}/namespace-loaders.generated.ts`. Required when lazy namespaces exist. |
434
- | `delivery` | Optional. `"canonical"` (default) — one multilocale JSON per namespace. `"split-by-locale"` — emits `{basename}.{locale}.json` under `{deliveryOutput}/translations/` and per-locale loaders. `"custom"` — named delivery areas via `deliveryArtifacts`; emits `{basename}.{area}.json`, area-scoped loaders, and typed `DELIVERY_ARTIFACTS` / `LOCALE_DELIVERY_AREA` in `i18n-types.generated.ts`. |
435
- | `deliveryArtifacts` | Required when `delivery` is `"custom"`. Map of delivery area → locale list (e.g. `{ "eu": ["en", "it"], "amer": ["en-US"] }`). Codegen validates structure and that locales partition the project locale set. Emitted as `DELIVERY_ARTIFACTS` (area → locales) and `LOCALE_DELIVERY_AREA` (locale → area). |
436
- | `deliveryOutput` | Optional. Directory for compiled and split delivery JSON (files land in `{deliveryOutput}/translations/`). Defaults to `dirname(typesOutput)`. Use e.g. `public/i18n` to ship per-locale JSON from a static host while keeping generated TypeScript under `generated/`. |
437
-
438
- > Paths are resolved relative to the directory containing `i18n.codegen.json` (e.g. `i18n/` when using `xndrjs-i18n-setup .`).
439
-
440
- ### YAML authoring
441
-
442
- Dictionary paths may use `.yaml` or `.yml` instead of `.json`. **YAML is an authoring format, not a runtime format** — codegen compiles YAML to JSON under the delivery output directory (for example `translations/billing.yaml` → `{deliveryOutput}/translations/billing.json`; default `{deliveryOutput}` is `dirname(typesOutput)`); generated TypeScript imports the compiled JSON at runtime. Edit only the YAML source — the compiled JSON is overwritten on each codegen run.
443
-
444
- Use YAML when ICU is hard to read on one line: multiple parameters, or plural/select branches. It is not aimed at long legal copy or styled pages — those usually belong in a separate content template with localized fragments inside.
445
-
446
- YAML block scalars keep complex ICU readable in source while preserving (or intentionally folding) line breaks.
447
-
448
- ```yaml
449
- # translations/billing.yaml
450
- appointment_summary:
451
- en: |
452
- Due {dueDate, date, short}
453
- at {startTime, time, short}
454
- it: "Scade il {dueDate, date, short} alle {startTime, time, short}"
455
- ```
456
-
457
- Workflow:
458
-
459
- 1. Edit the `.yaml` / `.yml` source file under `translations/`.
460
- 2. Run `xndrjs-i18n-codegen`.
461
- 3. Commit the YAML source; the compiled JSON lives under `generated/translations/` and is safe to commit or gitignore if CI regenerates it before build.
462
-
463
- Mixed namespaces are supported: some namespaces can stay `.json` while others use YAML.
464
-
465
- ### Split-by-locale delivery
19
+ ## Quick start
466
20
 
467
- By default (`delivery: "canonical"`), codegen keeps one multilocale JSON per namespace — either your source file in place (`.json`) or a compiled YAML → JSON under `{deliveryOutput}/translations/` (default: `generated/translations/`).
468
-
469
- Set `"delivery": "split-by-locale"` to emit **one JSON file per locale** instead. All namespaces are lazy in this mode — register them with `namespaceLoaders` before rendering:
21
+ **1. Config** (`i18n/i18n.codegen.json`):
470
22
 
471
23
  ```json
472
24
  {
25
+ "projectName": "App",
473
26
  "delivery": "split-by-locale",
474
27
  "namespaces": {
475
28
  "default": "translations/default.json",
476
29
  "billing": "translations/billing.yaml"
477
30
  },
478
- "namespaceLoadersOutput": "generated/namespace-loaders.generated.ts"
479
- }
480
- ```
481
-
482
- Codegen writes `{deliveryOutput}/translations/{basename}.{locale}.json` (for example `user.it.json`, `billing.en.json`). Each file contains the same shape as `projectNamespaceLocalesCore(dict, [locale])` — keys map to a single locale entry. With `localeFallback`, locales such as `de-CH` are resolved at codegen time (for example from `de-DE`).
483
-
484
- **Authoring is unchanged** — edit the canonical source JSON/YAML. Types (`MyProjectSchema`) are generated explicitly from ICU analysis (keys + `Partial<Record<MyProjectLocale, string>>` per key), not from `typeof import` of JSON files. Audit still reads the config paths.
485
-
486
- **Generated API changes (opt-in):**
487
-
488
- | Canonical | Split-by-locale / custom (all lazy) |
489
- | -------------------------------- | -------------------------------------------------------------------------------------------------- |
490
- | `export const defaultDictionary` | `dictionary.generated.ts` is not generated |
491
- | `namespaceLoaders.billing()` | `namespaceLoaders.billing(locale)` or `namespaceLoaders.billing(area)` |
492
- | — | `createI18n({}).withNamespaces([...]).withLocale(locale).load()` / `withDeliveryArea(area).load()` |
493
- | — | `DELIVERY_ARTIFACTS`, `LOCALE_DELIVERY_AREA` in `i18n-types.generated.ts` (`custom` only) |
494
-
495
- When `loadOnInit` lists eager namespaces in **canonical** delivery only, `dictionary.generated.ts` still exports `defaultDictionary` with static imports. In split/custom delivery, eager slices use `defaultDictionaryFor(locale)` or `defaultDictionaryFor(area)` when configured.
496
-
497
- Example init (multi, split-by-locale, all namespaces lazy):
498
-
499
- ```ts
500
- import { createI18n } from "./generated/instance.generated.js";
501
-
502
- const { t } = await createI18n({})
503
- .withNamespaces(["default", "billing"])
504
- .withLocale(activeLocale)
505
- .load();
506
- ```
507
-
508
- Example init (multi, custom delivery, all namespaces lazy):
509
-
510
- ```ts
511
- import { createI18n } from "./generated/instance.generated.js";
512
-
513
- const { t } = await createI18n({})
514
- .withNamespaces(["default", "billing"])
515
- .withDeliveryArea(activeArea)
516
- .load();
517
-
518
- // ActiveLocales is narrowed to DELIVERY_ARTIFACTS[activeArea] — forLocale() only accepts those locales.
519
- t("default", "login_button", "it");
520
- ```
521
-
522
- Custom delivery config and typed artifacts (no need to read `deliveryArtifacts` from JSON at runtime):
523
-
524
- ```json
525
- {
526
- "delivery": "custom",
527
- "deliveryArtifacts": {
528
- "eu": ["en", "it", "fr"],
529
- "amer": ["en-US", "es-AR"]
530
- },
531
- "namespaces": {
532
- "default": "translations/default.json",
533
- "billing": "translations/billing.yaml"
534
- },
535
- "namespaceLoadersOutput": "generated/namespace-loaders.generated.ts"
536
- }
537
- ```
538
-
539
- Codegen emits the partition in `i18n-types.generated.ts`:
540
-
541
- ```ts
542
- export type MyProjectDeliveryArea = "amer" | "eu";
543
-
544
- export const DELIVERY_ARTIFACTS = {
545
- amer: ["en-US", "es-AR"] as const,
546
- eu: ["en", "fr", "it"] as const,
547
- } as const satisfies Record<MyProjectDeliveryArea, readonly MyProjectLocale[]>;
548
-
549
- export type MyProjectDeliveryArtifacts = typeof DELIVERY_ARTIFACTS;
550
-
551
- export const LOCALE_DELIVERY_AREA = {
552
- "en-US": "amer",
553
- "es-AR": "amer",
554
- en: "eu",
555
- fr: "eu",
556
- it: "eu",
557
- } as const satisfies Record<MyProjectLocale, MyProjectDeliveryArea>;
558
- ```
559
-
560
- ```ts
561
- import {
562
- DELIVERY_ARTIFACTS,
563
- LOCALE_DELIVERY_AREA,
564
- type MyProjectDeliveryArea,
565
- } from "./generated/i18n-types.generated.js";
566
-
567
- const euLocales = DELIVERY_ARTIFACTS.eu; // readonly ["en", "fr", "it"]
568
- const areaForEnUs: MyProjectDeliveryArea = LOCALE_DELIVERY_AREA["en-US"]; // "amer"
569
- ```
570
-
571
- Example `dictionary.generated.ts` (multi, canonical delivery, `loadOnInit: ["default"]`):
572
-
573
- ```ts
574
- import defaultEn from "./translations/default.en.json";
575
- import defaultIt from "./translations/default.it.json";
576
-
577
- const defaultByLocale = {
578
- en: defaultEn,
579
- it: defaultIt,
580
- } as const satisfies Record<MyProjectLocale, MyProjectSchema["default"]>;
581
-
582
- export function defaultDictionaryFor(locale: MyProjectLocale): InitialSchema {
583
- return { default: defaultByLocale[locale] };
584
- }
585
- ```
586
-
587
- Example `namespace-loaders.generated.ts`:
588
-
589
- ```ts
590
- export const namespaceLoaders = {
591
- billing: (locale: MyProjectLocale) => {
592
- switch (locale) {
593
- case "en":
594
- return import("./translations/billing.en.json").then((m) => m.default);
595
- case "it":
596
- return import("./translations/billing.it.json").then((m) => m.default);
597
- case "de-CH":
598
- return import("./translations/billing.de-CH.json").then((m) => m.default);
599
- default:
600
- throw new Error(
601
- `[i18n] No translation artifact for namespace "billing" and locale "${String(locale)}".`
602
- );
603
- }
604
- },
605
- };
606
- export const defaultLazyNamespaces = ["billing", "default"] as const;
607
- ```
608
-
609
- Load only the namespaces you need:
610
-
611
- ```ts
612
- import { createI18n } from "./generated/instance.generated.js";
613
-
614
- const { t } = await createI18n({}).withNamespaces(["billing"]).withLocale(activeLocale).load();
615
- ```
616
-
617
- Use split delivery when you want smaller lazy chunks (one locale per dynamic import) or when serving per-locale JSON from `public/` without runtime `projectNamespaceLocales`. Runtime `projectDictionaryLocales` / `projectNamespaceLocales` remain available for external CMS/API payloads.
618
-
619
- ## Usage
620
-
621
- ### Single vs. multi-namespace API
622
-
623
- | | Single-file | Multi-namespace |
624
- | ----------- | ------------------------------------------ | ---------------------------------------------- |
625
- | `I18N_MODE` | `'single'` | `'multi'` |
626
- | Provider | `IcuTranslationProviderSingle` | `IcuTranslationProviderMulti` |
627
- | `t()` | `t(key, locale, params?)` | `t(namespace, key, locale, params?)` |
628
- | Patch | `set(key, template)` on locale-bound scope | `set(ns, key, template)` on locale-bound scope |
629
-
630
- ### Multi-namespace example
631
-
632
- ```ts
633
- import { i18n } from "./i18n"; // app singleton from i18n.ts
634
- // or: import { createI18n, defaultDictionary } from './i18n'; const { t } = createI18n(defaultDictionary);
635
-
636
- const { t } = i18n;
637
-
638
- t("default", "login_button", "it"); // "Accedi"
639
- t("default", "welcome", "en", { name: "Ada" }); // "Welcome Ada!"
640
- t("default", "dashboard_status", "it", { msgCount: 3, chatCount: 2 });
641
- t("billing", "invoice_summary", "en", { count: 12 });
642
-
643
- // Compile-time errors:
644
- t("default", "welcome", "it"); // ✗ missing { name }
645
- t("billing", "login_button", "it"); // ✗ key not in namespace
646
- ```
647
-
648
- ### Single-file example
649
-
650
- ```ts
651
- import { i18n } from "./i18n"; // app singleton from i18n.ts
652
- // or: import { createI18n, defaultDictionary } from './i18n'; const { t } = createI18n(defaultDictionary);
653
-
654
- const { t } = i18n;
655
-
656
- t("login_button", "it");
657
- t("welcome", "en", { name: "Ada" });
658
- ```
659
-
660
- ### Runtime override (key-level patch)
661
-
662
- Runtime patches go through **`scope.set()`** on a locale-bound scope returned from `load()`. The key (and locale) must already be preloaded — typically via a prior `load()` call or the canonical eager dictionary.
663
-
664
- ```ts
665
- // Multi — locale-bound scope from load()
666
- const { t, set } = await createI18n({}).withNamespaces(["billing"]).withLocale("en").load();
667
- set("billing", "invoice_summary", "You have {count, plural, one {1 invoice} other {# invoices}}");
668
- t("billing", "invoice_summary", { count: 2 });
669
-
670
- // Single — same pattern
671
- const { set: setWelcome } = await createI18n(defaultDictionary).withLocale("en").load();
672
- setWelcome("welcome", "Welcome {name}!");
673
- ```
674
-
675
- Unbound scopes (without `withLocale` / `withDeliveryArea` before `load()`) are read-only; call `.forLocale(locale)` before patching.
676
-
677
- ### Lazy namespace loading (multi mode)
678
-
679
- The pattern depends on `delivery`:
680
-
681
- | Delivery | Loading pattern |
682
- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
683
- | `canonical` | `loadOnInit` for eager namespaces; manual `namespaceLoaders.ns()` for lazy ones. |
684
- | `split-by-locale` / `custom` | Every namespace is lazy. Prefer the builder: `createI18n({}).withNamespaces([...]).withLocale(locale).load()` / `withDeliveryArea(area).load()`. |
685
-
686
- #### Canonical delivery — `loadOnInit` and manual loaders
687
-
688
- Split namespaces across chunks with `loadOnInit` in **canonical** delivery only. Codegen emits typed `namespaceLoaders` with one dynamic `import()` per lazy namespace.
689
-
690
- ```json
691
- {
692
- "delivery": "canonical",
693
- "namespaces": {
694
- "default": "translations/default.json",
695
- "billing": "translations/billing.yaml"
696
- },
697
- "loadOnInit": ["default"],
698
- "namespaceLoadersOutput": "generated/namespace-loaders.generated.ts"
31
+ "codegenPath": "generated"
699
32
  }
700
33
  ```
701
34
 
702
- Codegen also emits `LoadOnInitNamespace`, `LazyNamespace`, and `InitialSchema` types. The generated factory accepts a partial `InitialSchema` at init time.
35
+ Generated under `codegenPath/`: `i18n-types.generated.ts`, `instance.generated.ts`, `namespace-loaders.generated.ts`, `dictionary-schema.generated.ts`. Delivery JSON goes to `artifactsPath` (defaults to `codegenPath`). Type names are inferred as `AppParams` / `AppSchema` / `AppLocale`.
703
36
 
704
- Canonical delivery: eager namespaces load via `loadOnInit`. For lazy ones, use the builder or call generated `namespaceLoaders` inside a custom loader wired into `createI18n`.
37
+ **2. Generate:**
705
38
 
706
- ```ts
707
- import { createI18n, namespaceLoaders, type LazyNamespace } from "./i18n";
708
-
709
- // Eager namespace available immediately on the canonical singleton scope
710
- const { t } = createI18n(defaultDictionary).toScope();
711
- t("default", "login_button", "en");
712
-
713
- // Lazy namespace — load via builder
714
- const { t: tBilling } = await createI18n(defaultDictionary)
715
- .withNamespaces(["billing"])
716
- .withLocale("en")
717
- .load();
718
- tBilling("billing", "invoice_summary", { count: 12 });
719
-
720
- // batch preload several lazy namespaces for one locale
721
- await Promise.all(
722
- (["user", "billing"] as const satisfies readonly LazyNamespace[]).map(async (namespace) => {
723
- await namespaceLoaders[namespace]();
724
- })
725
- );
39
+ ```bash
40
+ npx xndrjs-i18n-codegen --config i18n/i18n.codegen.json
41
+ # or from code:
42
+ # import { runCodegen } from "@xndrjs/i18n/codegen";
43
+ # runCodegen("i18n/i18n.codegen.json");
726
44
  ```
727
45
 
728
- #### Split-by-locale / custom — builder loading
729
-
730
- With `split-by-locale` or `custom`, codegen wires `namespaceLoaders` into `createI18n({})` and returns a builder. Use `load()` to get a ready scope:
46
+ **3. Use:**
731
47
 
732
48
  ```ts
733
49
  import { createI18n } from "./generated/instance.generated.js";
734
50
 
735
- const { t } = await createI18n({})
736
- .withNamespaces(["billing"])
737
- .withLocale(activeLocale) // split-by-locale
738
- // .withDeliveryArea(activeArea) // custom delivery
739
- .load();
51
+ const i18n = createI18n();
52
+ const { t } = await i18n.load({
53
+ namespaces: ["default", "billing"],
54
+ locale: "en",
55
+ });
740
56
 
57
+ t("default", "welcome", { name: "Ada" });
741
58
  t("billing", "invoice_summary", { count: 3 });
742
59
  ```
743
60
 
744
- Different locale or delivery-area partitions accumulate on the shared engine — load `"it"`, then `"en"`, and both remain available. Loading the **same** namespace + partition again is a no-op (preserves runtime `scope.set()` patches):
61
+ SSR CSR hydrate:
745
62
 
746
63
  ```ts
747
- const builder = createI18n({}).withNamespaces(["billing"] as const);
748
- const { t: tIt, set: setIt } = await builder.withLocale("it").load();
749
- const { t: tEn } = await builder.withLocale("en").load();
750
- // tIt and tEn share one engine; both locales are preloaded for billing keys.
751
-
752
- setIt("billing", "invoice_summary", "Custom {count} for {name}");
753
- await builder.withLocale("it").load(); // skipped — patch preserved
754
- tIt("billing", "invoice_summary", { count: 1, name: "Ada" }); // "Custom 1 for Ada"
64
+ const state = i18n.serialize();
65
+ // pass `state` into the React root; gates resolve via peek() without a flash
755
66
  ```
756
67
 
757
- Generated loaders throw if locale/area does not match a known artifact (no silent empty payloads into `load()`).
68
+ Scaffold:
758
69
 
759
- Optional locale projection before validating external slices:
760
-
761
- ```ts
762
- import { projectNamespaceLocales } from "./i18n/generated/instance.generated.js";
763
-
764
- const { set } = await createI18n({}).withNamespaces(["billing"]).withLocale(userLocale).load();
765
- const billing = await namespaceLoaders.billing();
766
- const billingSlice = projectNamespaceLocales(billing, [userLocale]);
767
- const result = validateExternalKey("billing", "invoice_summary", billingSlice.invoice_summary);
768
- if (result.ok) {
769
- set("billing", "invoice_summary", result.data.invoice_summary[userLocale]!);
770
- }
70
+ ```bash
71
+ npx xndrjs-i18n-setup multi .
771
72
  ```
772
73
 
773
- When a namespace key was never loaded, `t()` resolves through `onMissing` (default: throw).
74
+ ## Delivery modes
774
75
 
775
- Fetch/CMS hydration is app-owned: fetch → validate (`validateExternalKey` / `validateExternalNamespacePartial`) → `load()` `scope.set()` per key.
76
+ | Mode | Artifacts | `load({ locale })` behavior |
77
+ | --------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------- |
78
+ | `split-by-locale` (default) | `{ns}.{locale}.json` | Loads that locale file |
79
+ | `custom` | `{ns}.{area}.json` + `deliveryArtifacts` | Maps locale → area via generated `LOCALE_DELIVERY_AREA`, then loads the area |
776
80
 
777
- ### External dictionary validation
778
-
779
- When translations arrive from an external source (i.e. a CMS), validate the `unknown` input before calling `scope.set()`. Keys must be preloaded via `load()` first.
780
-
781
- Enable validation by adding `dictionarySchemaOutput` to `i18n/i18n.codegen.json`:
81
+ ## Loader strategy
782
82
 
783
83
  ```json
784
84
  {
785
- "dictionarySchemaOutput": "generated/dictionary-schema.generated.ts"
85
+ "loaderStrategy": "fetch"
786
86
  }
787
87
  ```
788
88
 
789
- Add `zod` as a dependency in the consumer app (required peer of `@xndrjs/i18n`).
790
-
791
- Codegen emits `DICTIONARY_SPEC`, `validateExternalDictionary()`, `validateExternalDictionaryPartial()`, `validateExternalNamespacePartial()`, and `validateExternalKey()`. Validation runs in two phases:
792
-
793
- 1. **Normalize** — parse ICU templates, extract variables, check required keys (missing keys fail; extra keys are ignored; partial locales are OK).
794
- 2. **Validate** — compare extracted arguments against the static `Params` schema via Zod.
795
-
796
- ```ts
797
- import { formatIssues } from "@xndrjs/i18n/validation";
798
- import {
799
- validateExternalDictionaryPartial,
800
- validateExternalKey,
801
- } from "./i18n/generated/dictionary-schema.generated.js";
802
- import { createI18n } from "./i18n";
803
-
804
- const raw: unknown = await loadTranslations();
805
-
806
- const { t, set } = await createI18n({})
807
- .withNamespaces(["default", "billing"])
808
- .withLocale("en")
809
- .load();
810
-
811
- const result = validateExternalDictionaryPartial(raw);
812
- if (!result.ok) {
813
- console.error(formatIssues(result.issues));
814
- return;
815
- }
816
-
817
- for (const [namespace, nsData] of Object.entries(result.data)) {
818
- if (!nsData) continue;
819
- for (const [key, locales] of Object.entries(nsData)) {
820
- const template = locales?.en;
821
- if (template !== undefined) {
822
- set(namespace as "billing", key as "invoice_summary", template);
823
- }
824
- }
825
- }
826
- ```
89
+ - **`import`** (default) dynamic `import()`; JSON is bundled. Content changes need a rebuild.
90
+ - **`fetch`** — generated loaders call `fetchImpl({ locale, namespace, area? })`. You **must** pass `fetchImpl` to `createI18n({ fetchImpl })` (or `I18nRoot`). Mapping the resource id to a URL, CDN path, or filesystem read is an application concern — codegen does not embed base URLs.
827
91
 
828
- For a single key patch (multi mode):
92
+ Hydrate from SSR:
829
93
 
830
94
  ```ts
831
- import { validateExternalKey } from "./i18n/generated/dictionary-schema.generated.js";
832
-
833
- const { set } = await createI18n({}).withNamespaces(["billing"]).withLocale("it").load();
834
-
835
- const result = validateExternalKey("billing", "invoice_summary", rawLocales);
836
- if (result.ok) {
837
- set("billing", "invoice_summary", result.data.invoice_summary.it!);
838
- }
95
+ const state = i18n.serialize();
96
+ const client = createI18n({ state }); // import strategy
97
+ // fetch strategy:
98
+ // const client = createI18n({ state, fetchImpl });
839
99
  ```
840
100
 
841
- Low-level API is also available from `@xndrjs/i18n/validation` for custom wiring.
842
-
843
- ## Provider API
101
+ ## Content updates without build (CMS)
844
102
 
845
- Both providers share this behavior:
103
+ Two APIs from `@xndrjs/i18n/codegen`:
846
104
 
847
- - **Compilation cache** — compiled `IntlMessageFormat` instances are cached per locale (and per namespace in multi mode). Invalidated on `scope.set()` for the affected key/locale.
848
- - **`getAll()`** returns a deep-frozen snapshot of the current dictionary (not a live reference).
849
- - **Load deduplication** the engine tracks lazy resources loaded via the builder (`namespace` + locale or delivery area). A repeated `load()` for the same resource is skipped so default artifacts do not overwrite runtime `scope.set()` patches.
850
- - **Destructuring-safe scopes** — `t`, `set`, and `forLocale` on scopes can be extracted (`const { t } = scope`) without manual `.bind()`.
851
- - **No public merge/replace** — `setAll`, `mergeAll`, `setNamespace`, and `mergeNamespace` were removed in 0.7.0. The first `load()` per resource deep-merges defaults; further patches go through `scope.set()`.
852
- - **Missing key/locale** — by default throws an error if the template is `undefined` (configurable via `onMissing`, see below). An empty string (`""`) is treated as a valid template.
853
- - **ICU syntax error** — throws `[i18n ICU Syntax Error] ...`.
854
- - **Formatting error** (missing/invalid params) — throws `[i18n Formatting Error] ...`.
105
+ | API | When | Writes |
106
+ | ----------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------- |
107
+ | `runCodegen(config)` | Contract changes (keys, params, namespaces, locales) | Types + instance + loaders + delivery JSON (then rebuild the app) |
108
+ | `regenerateNamespaces({ namespaces, })` | Editorial label/copy changes (same ICU contract) | **Only** delivery JSON for the listed namespaces |
855
109
 
856
- ### Missing-translation strategy (`onMissing`)
857
-
858
- Both providers accept an `onMissing` option controlling what happens when no template resolves for a key/locale (after walking the full fallback chain):
110
+ Authoring files are updated **outside** this library (CMS export, editors, scripts). Then refresh delivery artifacts:
859
111
 
860
112
  ```ts
861
- onMissing?: "throw" | "key" | ((context: MissingTranslationContext) => string);
862
- // MissingTranslationContext = { namespace?: string; key: string; locale: string; fallbackChain: string }
863
- ```
113
+ import { regenerateNamespaces } from "@xndrjs/i18n/codegen";
864
114
 
865
- - **`"throw"`** (default) throws `[i18n] Missing key or locale: ...` including the fallback chain.
866
- - **`"key"`** — returns the key itself: `key` in single mode, `namespace.key` in multi mode.
867
- - **Function** — receives the missing-translation context (`namespace` is only set in multi mode) and returns the string to display.
868
-
869
- ```ts
870
- const i18n = new IcuTranslationProviderMulti(schema, {
871
- localeFallback,
872
- onMissing: ({ namespace, key }) => `[missing: ${namespace}.${key}]`,
115
+ // after writing translations/billing.json (same keys + ICU params)
116
+ regenerateNamespaces({
117
+ configPath: "i18n/i18n.codegen.json",
118
+ namespaces: ["billing"],
873
119
  });
874
120
  ```
875
121
 
876
- `onMissing` only applies to missing-template resolution. ICU syntax errors, formatting errors, and (in multi mode) unloaded namespaces still throw.
122
+ If authoring changes a key’s ICU parameter contract (or adds/removes keys), regeneration fails run `runCodegen` and ship a release instead.
877
123
 
878
- The generated factory accepts `onMissing` as a pass-through option, merged with the codegen-wired `localeFallback`:
124
+ End-to-end without rebuild requires `loaderStrategy: "fetch"`. Debounce/batch webhooks in the app; each call rewrites delivery JSON only, not app code.
879
125
 
880
- ```ts
881
- import { createI18n } from "./i18n/generated/instance.generated.js";
882
-
883
- const i18n = createI18n(dictionary, { onMissing: "key" });
884
- ```
885
-
886
- ## Commands
887
-
888
- Run from the repo root:
889
-
890
- ```bash
891
- # Install workspaces
892
- pnpm install
893
-
894
- # Build the library
895
- pnpm --filter @xndrjs/i18n build
896
-
897
- # Generate i18n types/dictionary/instance for the demo app
898
- pnpm --filter @xndrjs/i18n-demo i18n:codegen
126
+ ## Validation
899
127
 
900
- # Type-check the i18n package
901
- pnpm --filter @xndrjs/i18n typecheck
128
+ `@xndrjs/i18n/validation` (and generated `dictionary-schema.generated.ts`) validates external CMS payloads **before** writing authoring files. It is not a runtime patch API.
902
129
 
903
- # Run the demo app (single + multi)
904
- pnpm --filter @xndrjs/i18n-demo demo
905
-
906
- # Run library tests
907
- pnpm --filter @xndrjs/i18n test
908
- ```
909
-
910
- From inside `apps/i18n-demo/`:
130
+ ## Audit
911
131
 
912
132
  ```bash
913
- pnpm run i18n:codegen:single # xndrjs-i18n-codegen --config single/src/i18n/i18n.codegen.json
914
- pnpm run i18n:codegen:multi # xndrjs-i18n-codegen --config multi/src/i18n/i18n.codegen.json
915
- pnpm run i18n:audit:single # xndrjs-i18n-audit --config single/src/i18n/i18n.codegen.json
916
- pnpm run i18n:audit:multi # xndrjs-i18n-audit --config multi/src/i18n/i18n.codegen.json
917
- pnpm run demo:single # tsx single/src/index.ts
918
- pnpm run demo:multi # tsx multi/src/index.ts
133
+ npx xndrjs-i18n-audit --config i18n/i18n.codegen.json
134
+ npx xndrjs-i18n-audit --config i18n/i18n.codegen.json --fail-on effective
919
135
  ```
920
136
 
921
- ## Adding a translation key
922
-
923
- 1. Add the key with its ICU strings to the relevant JSON file under `translations/`.
924
- 2. Run `pnpm --filter @xndrjs/i18n-demo i18n:codegen:multi` (or `i18n:codegen:single`).
925
- 3. The generated `MyProjectParams` updates; TypeScript will flag any `t()` call sites that now need (or no longer need) parameters.
926
-
927
- ## Adding a namespace
928
-
929
- 1. Create a new dictionary file (`.json`, `.yaml`, or `.yml`, any path).
930
- 2. Add it to `namespaces` in `i18n/i18n.codegen.json`.
931
- 3. Re-run codegen. `dictionary.generated.ts` wires the import automatically.
932
-
933
- ## Migrating single-file to multi-namespace
934
-
935
- 1. Split the flat JSON into per-domain files.
936
- 2. Change `dictionary` to `namespaces` in the config.
937
- 3. Re-run codegen — types and API switch from flat to namespaced.
938
- 4. Update call sites: `t(key, locale)` → `t(namespace, key, locale)` on scopes (or use locale-bound scopes from `load()`).
939
-
940
137
  ## Tech stack
941
138
 
942
- - **TypeScript 6** (`strict`, `resolveJsonModule`, `moduleResolution: "bundler"`)
943
- - **[intl-messageformat](https://www.npmjs.com/package/intl-messageformat)** runtime ICU formatting
944
- - **[@formatjs/icu-messageformat-parser](https://www.npmjs.com/package/@formatjs/icu-messageformat-parser)** — build-time AST parsing
945
- - **[zod](https://www.npmjs.com/package/zod)** — peer dependency (config validation, external dictionary validation)
946
- - **[tsx](https://www.npmjs.com/package/tsx)** — runs TypeScript scripts directly
139
+ - TypeScript (strict)
140
+ - [intl-messageformat](https://www.npmjs.com/package/intl-messageformat) + [@formatjs/icu-messageformat-parser](https://www.npmjs.com/package/@formatjs/icu-messageformat-parser)
141
+ - [zod](https://www.npmjs.com/package/zod) (peer)