@xndrjs/i18n 0.3.3 → 0.5.0-alpha.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 (61) hide show
  1. package/README.md +193 -44
  2. package/dist/codegen/index.d.ts +85 -0
  3. package/dist/codegen/index.js +181 -0
  4. package/dist/codegen/index.js.map +1 -0
  5. package/dist/index.d.ts +34 -18
  6. package/dist/index.js +174 -145
  7. package/dist/index.js.map +1 -1
  8. package/dist/validation/index.d.ts +86 -4
  9. package/dist/validation/index.js.map +1 -1
  10. package/package.json +6 -1
  11. package/src/IcuTranslationProviderMulti.test.ts +48 -1
  12. package/src/IcuTranslationProviderMulti.ts +41 -61
  13. package/src/IcuTranslationProviderSingle.test.ts +67 -0
  14. package/src/IcuTranslationProviderSingle.ts +27 -51
  15. package/src/audit/audit-dictionaries.ts +4 -1
  16. package/src/audit/run-audit.test.ts +35 -1
  17. package/src/audit/run-audit.ts +15 -7
  18. package/src/codegen/codegen-config-schema.ts +68 -1
  19. package/src/codegen/config.test.ts +174 -39
  20. package/src/codegen/config.ts +14 -5
  21. package/src/codegen/constants.ts +8 -0
  22. package/src/codegen/delivery-artifacts.test.ts +142 -0
  23. package/src/codegen/delivery-artifacts.ts +124 -0
  24. package/src/codegen/emit/dictionary-file.test.ts +190 -0
  25. package/src/codegen/emit/dictionary-file.ts +165 -2
  26. package/src/codegen/emit/dictionary-schema-file.ts +5 -0
  27. package/src/codegen/emit/instance-file.ts +119 -38
  28. package/src/codegen/emit/namespace-loaders-file.test.ts +176 -0
  29. package/src/codegen/emit/namespace-loaders-file.ts +118 -32
  30. package/src/codegen/emit/types-file.test.ts +114 -0
  31. package/src/codegen/emit/types-file.ts +48 -11
  32. package/src/codegen/generate-i18n-types.test.ts +765 -22
  33. package/src/codegen/generate-i18n-types.ts +111 -58
  34. package/src/codegen/icu-analysis.ts +9 -2
  35. package/src/codegen/locale-fallback.ts +19 -10
  36. package/src/codegen/locale-policy.ts +4 -0
  37. package/src/codegen/paths.ts +9 -5
  38. package/src/codegen/project-locales-set-namespace.test.ts +11 -9
  39. package/src/codegen/read-dictionary.test.ts +474 -2
  40. package/src/codegen/read-dictionary.ts +164 -15
  41. package/src/codegen/write-file-if-changed.test.ts +42 -0
  42. package/src/codegen/write-file-if-changed.ts +20 -0
  43. package/src/codegen-config/build-config.ts +34 -0
  44. package/src/codegen-config/codegen-config.test.ts +32 -0
  45. package/src/codegen-config/index.ts +21 -0
  46. package/src/codegen-config/write-config.ts +8 -0
  47. package/src/deep-freeze.test.ts +13 -0
  48. package/src/deep-freeze.ts +5 -0
  49. package/src/format-core.ts +91 -0
  50. package/src/index.ts +8 -5
  51. package/src/project-locales.test.ts +129 -10
  52. package/src/project-locales.ts +90 -3
  53. package/src/setup/setup-i18n.test.ts +1 -1
  54. package/src/setup/setup-i18n.ts +9 -32
  55. package/src/types.ts +19 -4
  56. package/src/validation/normalize.ts +4 -0
  57. package/dist/types-C1CpXVOJ.d.ts +0 -83
  58. package/src/ensure-namespace.integration.test.ts +0 -66
  59. package/src/ensure-namespace.test.ts +0 -125
  60. package/src/ensure-namespace.ts +0 -70
  61. /package/src/{setup → codegen-config}/type-names.ts +0 -0
package/README.md CHANGED
@@ -10,7 +10,7 @@ The core idea: your ICU strings live in local JSON files that act as **type-safe
10
10
  - **ICU MessageFormat** — full support for interpolation, plurals, and select.
11
11
  - **Runtime override** — hydrate translations from an external source via `setAll()` / `setNamespace()` without rebuilding.
12
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 `ensureNamespacesLoaded()` (multi mode).
13
+ - **Lazy namespace loading** — optional code-splitting via `loadOnInit` and generated `namespaceLoaders` (multi mode).
14
14
  - **Hot compilation cache** — compiled `IntlMessageFormat` instances are cached and invalidated on override.
15
15
  - **Explicit runtime errors** — malformed ICU (e.g. a corrupt remote payload) or missing parameters throw descriptive errors.
16
16
  - **Translation audit** — `xndrjs-i18n-audit` reports missing locales per key (direct vs effective after fallback); optional CI gate via `--fail-on`.
@@ -75,11 +75,13 @@ npm run i18n:audit
75
75
  ```ts
76
76
  // i18n/index.ts
77
77
  import { createI18n } from "./generated/instance.generated.js";
78
+ import { defaultDictionary } from "./generated/dictionary.generated.js";
78
79
 
79
80
  export * from "./generated/instance.generated.js";
81
+ export * from "./generated/dictionary.generated.js";
80
82
  export * from "./generated/i18n-types.generated.js";
81
83
 
82
- export const i18n = createI18n();
84
+ export const i18n = createI18n(defaultDictionary);
83
85
  ```
84
86
 
85
87
  ```ts
@@ -104,7 +106,7 @@ This creates `i18n/i18n.codegen.json`, starter translation JSON, and `i18n/index
104
106
 
105
107
  Use `namespaces` instead of `dictionary` in `i18n/i18n.codegen.json`. See [Configuration](#configuration-i18ncodegenjson) and the [multi-namespace example](#multi-namespace-example) below.
106
108
 
107
- For lazy loading, add `loadOnInit`, `dictionarySchemaOutput`, and `namespaceLoadersOutput` — see [Lazy namespace loading](#lazy-namespace-loading-multi-mode). Lazy mode requires `zod` (validation runs before a namespace is registered).
109
+ 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.
108
110
 
109
111
  ## Repository layout
110
112
 
@@ -194,8 +196,8 @@ Variables found across **all locales** of the same key are merged. If parsing fa
194
196
  ### 3. Generated files
195
197
 
196
198
  - **`i18n-types.generated.ts`** — `I18N_MODE`, `MyProjectParams`, `MyProjectSchema`.
197
- - **`dictionary.generated.ts`** — imports the JSON files and assembles the initial dictionary.
198
- - **`instance.generated.ts`** — exports `createI18n()`, typed `projectLocales()` (full schema), and in multi mode `projectNamespaceLocales()` (single namespace); `LOCALE_FALLBACK` wired in when configured.
199
+ - **`dictionary.generated.ts`** — imports the JSON files and exports `defaultDictionary` (canonical delivery) or `defaultDictionaryFor(locale)` (split-by-locale delivery).
200
+ - **`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.
199
201
  - **`i18n.ts`** (optional, hand-written) — app-owned singleton if desired.
200
202
 
201
203
  Example generated types (multi-namespace):
@@ -219,20 +221,30 @@ export type MyProjectParams = {
219
221
  };
220
222
 
221
223
  export type MyProjectSchema = {
222
- default: typeof import("./translations/default.json");
223
- user: typeof import("./translations/user.json");
224
- billing: typeof import("./translations/billing.json");
224
+ default: {
225
+ login_button: Partial<Record<MyProjectLocale, string>>;
226
+ welcome: Partial<Record<MyProjectLocale, string>>;
227
+ dashboard_status: Partial<Record<MyProjectLocale, string>>;
228
+ };
229
+ user: {
230
+ profile_title: Partial<Record<MyProjectLocale, string>>;
231
+ greeting: Partial<Record<MyProjectLocale, string>>;
232
+ };
233
+ billing: {
234
+ invoice_summary: Partial<Record<MyProjectLocale, string>>;
235
+ };
225
236
  };
226
237
  ```
227
238
 
228
239
  ### 4. Runtime provider
229
240
 
230
- Create a provider with the generated factory (no side effects at import time):
241
+ 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:
231
242
 
232
243
  ```ts
233
244
  import { createI18n } from "./i18n";
245
+ import { defaultDictionary } from "./i18n/generated/dictionary.generated.js";
234
246
 
235
- const i18n = createI18n();
247
+ const i18n = createI18n(defaultDictionary);
236
248
  // or with an external dictionary at init:
237
249
  const i18n = createI18n(externalDictionary);
238
250
  ```
@@ -241,7 +253,8 @@ Optionally, wrap it in an app-owned singleton (`i18n.ts`):
241
253
 
242
254
  ```ts
243
255
  import { createI18n } from "./instance.generated.js";
244
- export const i18n = createI18n();
256
+ import { defaultDictionary } from "./dictionary.generated.js";
257
+ export const i18n = createI18n(defaultDictionary);
245
258
  ```
246
259
 
247
260
  ### Locale-bound provider (`forLocale`)
@@ -249,7 +262,7 @@ export const i18n = createI18n();
249
262
  Bind a locale once and omit it on every `.get()`:
250
263
 
251
264
  ```ts
252
- const i18n = createI18n();
265
+ const i18n = createI18n(defaultDictionary);
253
266
  const i18nEn = i18n.forLocale("en");
254
267
 
255
268
  i18nEn.get("login_button"); // single-file
@@ -302,24 +315,25 @@ const localeFallback = {
302
315
  const i18n = new IcuTranslationProviderMulti(schema, { localeFallback });
303
316
  ```
304
317
 
305
- #### `projectLocales` / `projectNamespaceLocales`
318
+ #### `projectDictionaryLocales` / `projectNamespaceLocales`
306
319
 
307
320
  Namespaces split the dictionary by domain; locale projection splits it by **locale**. Use before `setAll()` / `setNamespace()` when you only need one locale (or a small regional group) in memory.
308
321
 
309
322
  ```ts
310
323
  import {
311
324
  createI18n,
312
- projectLocales,
325
+ projectDictionaryLocales,
313
326
  projectNamespaceLocales,
314
327
  } from "./i18n/generated/instance.generated.js";
328
+ import { defaultDictionary } from "./i18n/generated/dictionary.generated.js";
315
329
  import billingDictionary from "./i18n/translations/billing.json";
316
330
 
317
- const i18n = createI18n();
331
+ const i18n = createI18n(defaultDictionary);
318
332
  i18n.setNamespace("billing", projectNamespaceLocales(billingDictionary, [activeLocale]));
319
- i18n.setAll(projectLocales(fullDictionary, [activeLocale])); // multi: all namespaces
333
+ i18n.setAll(projectDictionaryLocales(fullDictionary, [activeLocale])); // multi: all namespaces
320
334
  ```
321
335
 
322
- Codegen emits typed wrappers in `instance.generated.ts` (`projectLocales` for the full schema; `projectNamespaceLocales` in multi mode for one namespace). The low-level `@xndrjs/i18n` exports remain for tooling without codegen.
336
+ 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.
323
337
 
324
338
  ### 6. Translation audit (`xndrjs-i18n-audit`)
325
339
 
@@ -393,29 +407,31 @@ Specify **exactly one** of `dictionary` (single-file) or `namespaces` (multi-fil
393
407
  }
394
408
  ```
395
409
 
396
- | Field | Description |
397
- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
398
- | `dictionary` | Path to a single dictionary file (`.json`, `.yaml`, or `.yml`) for the flat API. Mutually exclusive with `namespaces`. |
399
- | `namespaces` | Map of `namespace -> dictionary path` (`.json`, `.yaml`, or `.yml`) for the namespaced API. Mutually exclusive with `dictionary`. |
400
- | `defaultNamespace` | Optional. Namespace label used internally in single-file mode (default `"default"`). Not exposed in the flat API. |
401
- | `typesOutput` | Output path for the generated types. |
402
- | `dictionaryOutput` | Output path for the generated dictionary manifest. |
403
- | `instanceOutput` | Output path for the generated factory (`createI18n`). |
404
- | `importExtension` | Optional. Relative import suffix between generated `.ts` modules: `"none"` (default, extensionless), `".ts"`, or `".js"`. |
405
- | `factoryName` | Name of the exported factory function (default `createI18n`). |
406
- | `paramsTypeName` / `schemaTypeName` | Names of the exported types (customizable per project). |
407
- | `localeTypeName` | Name of the exported locale union type (default `MyProjectLocale`). |
408
- | `localeFallback` | Optional map of `locale -> next locale | null` for runtime fallback resolution. |
409
- | `localeFallbackConstName` | Name of the generated fallback constant (default `LOCALE_FALLBACK`). |
410
- | `dictionarySchemaOutput` | Optional path for generated external dictionary validation (`dictionary-schema.generated.ts`). Requires `zod` in the consumer app. |
411
- | `loadOnInit` | Multi mode only. Namespaces to include in the initial bundle via static imports. When omitted, all namespaces are eager (default). |
412
- | `namespaceLoadersOutput` | Output path for generated lazy loaders and `ensureNamespacesLoaded()`. Defaults to `{dirname(instanceOutput)}/namespace-loaders.generated.ts`. Required when lazy namespaces exist. |
410
+ | Field | Description |
411
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
412
+ | `dictionary` | Path to a single dictionary file (`.json`, `.yaml`, or `.yml`) for the flat API. Mutually exclusive with `namespaces`. |
413
+ | `namespaces` | Map of `namespace -> dictionary path` (`.json`, `.yaml`, or `.yml`) for the namespaced API. Mutually exclusive with `dictionary`. |
414
+ | `defaultNamespace` | Optional. Namespace label used internally in single-file mode (default `"default"`). Not exposed in the flat API. |
415
+ | `typesOutput` | Output path for the generated types. |
416
+ | `dictionaryOutput` | Output path for the generated dictionary manifest. |
417
+ | `instanceOutput` | Output path for the generated factory (`createI18n`). |
418
+ | `importExtension` | Optional. Relative import suffix between generated `.ts` modules: `"none"` (default, extensionless), `".ts"`, or `".js"`. |
419
+ | `factoryName` | Name of the exported factory function (default `createI18n`). |
420
+ | `paramsTypeName` / `schemaTypeName` | Names of the exported types (customizable per project). |
421
+ | `localeTypeName` | Name of the exported locale union type (default `MyProjectLocale`). |
422
+ | `localeFallback` | Optional map of `locale -> next locale | null` for runtime fallback resolution. |
423
+ | `localeFallbackConstName` | Name of the generated fallback constant (default `LOCALE_FALLBACK`). |
424
+ | `dictionarySchemaOutput` | Optional path for generated external dictionary validation (`dictionary-schema.generated.ts`). Requires `zod` in the consumer app. |
425
+ | `loadOnInit` | Multi mode only. Namespaces to include in the initial bundle via static imports. When omitted, all namespaces are eager (default). |
426
+ | `namespaceLoadersOutput` | Output path for generated `namespaceLoaders` (dynamic `import()` per lazy namespace). Defaults to `{dirname(instanceOutput)}/namespace-loaders.generated.ts`. Required when lazy namespaces exist. |
427
+ | `delivery` | Optional. `"canonical"` (default) — one multilocale JSON per namespace. `"split-by-locale"` — emits `{basename}.{locale}.json` under `{deliveryOutput}/translations/` and per-locale loaders. See [Split-by-locale delivery](#split-by-locale-delivery). |
428
+ | `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/`. |
413
429
 
414
430
  > Paths are resolved relative to the directory containing `i18n.codegen.json` (e.g. `i18n/` when using `xndrjs-i18n-setup .`).
415
431
 
416
432
  ### YAML authoring
417
433
 
418
- 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 generated output directory (for example `translations/billing.yaml` → `{dirname(typesOutput)}/translations/billing.json`); generated TypeScript imports the compiled JSON at runtime. Edit only the YAML source — the compiled JSON is overwritten on each codegen run.
434
+ 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.
419
435
 
420
436
  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.
421
437
 
@@ -438,6 +454,82 @@ Workflow:
438
454
 
439
455
  Mixed namespaces are supported: some namespaces can stay `.json` while others use YAML.
440
456
 
457
+ ### Split-by-locale delivery
458
+
459
+ 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/`).
460
+
461
+ Set `"delivery": "split-by-locale"` to emit **one JSON file per locale** instead:
462
+
463
+ ```json
464
+ {
465
+ "delivery": "split-by-locale",
466
+ "namespaces": {
467
+ "default": "translations/default.json",
468
+ "billing": "translations/billing.yaml"
469
+ },
470
+ "loadOnInit": ["default"]
471
+ }
472
+ ```
473
+
474
+ 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`).
475
+
476
+ **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.
477
+
478
+ **Generated API changes (opt-in):**
479
+
480
+ | Canonical | Split-by-locale |
481
+ | -------------------------------- | ---------------------------------------------- |
482
+ | `export const defaultDictionary` | `export function defaultDictionaryFor(locale)` |
483
+ | `namespaceLoaders.billing()` | `namespaceLoaders.billing(locale)` |
484
+
485
+ Example `dictionary.generated.ts` (multi, `loadOnInit: ["default"]`):
486
+
487
+ ```ts
488
+ import defaultEn from "./translations/default.en.json";
489
+ import defaultIt from "./translations/default.it.json";
490
+
491
+ const defaultByLocale = {
492
+ en: defaultEn,
493
+ it: defaultIt,
494
+ } as const satisfies Record<MyProjectLocale, MyProjectSchema["default"]>;
495
+
496
+ export function defaultDictionaryFor(locale: MyProjectLocale): InitialSchema {
497
+ return { default: defaultByLocale[locale] };
498
+ }
499
+ ```
500
+
501
+ Example `namespace-loaders.generated.ts`:
502
+
503
+ ```ts
504
+ export const namespaceLoaders = {
505
+ billing: (locale: MyProjectLocale) => {
506
+ switch (locale) {
507
+ case "en":
508
+ return import("./translations/billing.en.json").then((m) => m.default);
509
+ case "it":
510
+ return import("./translations/billing.it.json").then((m) => m.default);
511
+ case "de-CH":
512
+ return import("./translations/billing.de-CH.json").then((m) => m.default);
513
+ }
514
+ },
515
+ };
516
+ ```
517
+
518
+ Init with the active locale:
519
+
520
+ ```ts
521
+ import { createI18n } from "./generated/instance.generated.js";
522
+ import { defaultDictionaryFor } from "./generated/dictionary.generated.js";
523
+
524
+ const i18n = createI18n(defaultDictionaryFor(activeLocale));
525
+
526
+ if (!i18n.hasNamespace("billing")) {
527
+ i18n.setNamespace("billing", await namespaceLoaders.billing(activeLocale));
528
+ }
529
+ ```
530
+
531
+ 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.
532
+
441
533
  ## Usage
442
534
 
443
535
  ### Single vs. multi-namespace API
@@ -453,7 +545,7 @@ Mixed namespaces are supported: some namespaces can stay `.json` while others us
453
545
 
454
546
  ```ts
455
547
  import { i18n } from "./i18n"; // app singleton from i18n.ts
456
- // or: import { createI18n } from './i18n'; const i18n = createI18n();
548
+ // or: import { createI18n, defaultDictionary } from './i18n'; const i18n = createI18n(defaultDictionary);
457
549
 
458
550
  i18n.get("default", "login_button", "it"); // "Accedi"
459
551
  i18n.get("default", "welcome", "en", { name: "Ada" }); // "Welcome Ada!"
@@ -469,7 +561,7 @@ i18n.get("billing", "login_button", "it"); // ✗ key not in namespace
469
561
 
470
562
  ```ts
471
563
  import { i18n } from "./i18n"; // app singleton from i18n.ts
472
- // or: import { createI18n } from './i18n'; const i18n = createI18n();
564
+ // or: import { createI18n, defaultDictionary } from './i18n'; const i18n = createI18n(defaultDictionary);
473
565
 
474
566
  i18n.get("login_button", "it");
475
567
  i18n.get("welcome", "en", { name: "Ada" });
@@ -487,7 +579,7 @@ i18n.setNamespace("billing", externalBillingPayload);
487
579
 
488
580
  ### Lazy namespace loading (multi mode)
489
581
 
490
- Split namespaces across chunks by listing only the namespaces you need at startup in `loadOnInit`. Codegen emits dynamic `import()` loaders and a generated `ensureNamespacesLoaded(i18n, namespaces)` helper. `.get()` stays synchronous — preload lazy namespaces before rendering.
582
+ Split namespaces across chunks by listing only the namespaces you need at startup in `loadOnInit`. Codegen emits typed `namespaceLoaders` — one dynamic `import()` per lazy namespace (or per lazy namespace **and locale** when `delivery: "split-by-locale"`). `.get()` stays synchronous — register lazy namespaces with `setNamespace()` before rendering.
491
583
 
492
584
  ```json
493
585
  {
@@ -496,7 +588,6 @@ Split namespaces across chunks by listing only the namespaces you need at startu
496
588
  "billing": "translations/billing.yaml"
497
589
  },
498
590
  "loadOnInit": ["default"],
499
- "dictionarySchemaOutput": "generated/dictionary-schema.generated.ts",
500
591
  "namespaceLoadersOutput": "generated/namespace-loaders.generated.ts"
501
592
  }
502
593
  ```
@@ -504,23 +595,51 @@ Split namespaces across chunks by listing only the namespaces you need at startu
504
595
  Codegen also emits `LoadOnInitNamespace`, `LazyNamespace`, and `InitialSchema` types. The generated factory accepts a partial `InitialSchema` at init time.
505
596
 
506
597
  ```ts
507
- import { i18n, ensureNamespacesLoaded } from "./i18n";
598
+ import { i18n, namespaceLoaders, type LazyNamespace } from "./i18n";
508
599
 
509
600
  i18n.get("default", "login_button", "en"); // available immediately
510
601
 
511
- await ensureNamespacesLoaded(i18n, ["billing"]);
602
+ if (!i18n.hasNamespace("billing")) {
603
+ i18n.setNamespace("billing", await namespaceLoaders.billing());
604
+ }
512
605
  i18n.get("billing", "invoice_summary", "en", { count: 12 });
513
606
 
514
607
  // batch preload
515
- await ensureNamespacesLoaded(i18n, ["user", "billing"]);
608
+ await Promise.all(
609
+ (["user", "billing"] as const satisfies readonly LazyNamespace[]).map(async (namespace) => {
610
+ if (i18n.hasNamespace(namespace)) return;
611
+ i18n.setNamespace(namespace, await namespaceLoaders[namespace]());
612
+ })
613
+ );
614
+ ```
615
+
616
+ With `delivery: "split-by-locale"`, pick the locale on each loader:
617
+
618
+ ```ts
619
+ const locale = "it" as const;
620
+
621
+ if (!i18n.hasNamespace("billing")) {
622
+ i18n.setNamespace("billing", await namespaceLoaders.billing(locale));
623
+ }
624
+ ```
625
+
626
+ Optional locale projection before register:
627
+
628
+ ```ts
629
+ import { projectNamespaceLocales } from "./i18n/generated/instance.generated.js";
630
+
631
+ const billing = await namespaceLoaders.billing();
632
+ i18n.setNamespace("billing", projectNamespaceLocales(billing, [userLocale]));
516
633
  ```
517
634
 
518
635
  Calling `.get()` on a namespace that is not loaded throws:
519
636
 
520
- `[i18n] Namespace not loaded: "billing". Call ensureNamespacesLoaded(i18n, ["billing"]) first.`
637
+ `[i18n] Namespace not loaded: "billing". Register it with setNamespace() before calling .get().`
521
638
 
522
639
  When `loadOnInit` is omitted, behavior is unchanged: all namespaces are statically imported.
523
640
 
641
+ Fetch/CMS hydration is app-owned: fetch → `validateExternalNamespace()` (requires `dictionarySchemaOutput` + `zod`) → `setNamespace()`.
642
+
524
643
  ### External dictionary validation
525
644
 
526
645
  When translations arrive from an external source (i.e. a CMS), validate the `unknown` input before calling `setAll()` or `setNamespace()`.
@@ -578,10 +697,40 @@ Both providers share this behavior:
578
697
  - **`hasNamespace(ns)`** — (multi only) returns whether a namespace has been loaded (eager init, lazy load, or `setNamespace`).
579
698
  - **`setAll(values)`** — replaces the dictionary and clears the entire cache.
580
699
  - **`setNamespace(ns, values)`** — (multi only) replaces one namespace and invalidates only its cache entries.
581
- - **Missing key/locale** — throws an error if the template is `undefined`. An empty string (`""`) is treated as a valid template.
700
+ - **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.
582
701
  - **ICU syntax error** — throws `[i18n ICU Syntax Error] ...`.
583
702
  - **Formatting error** (missing/invalid params) — throws `[i18n Formatting Error] ...`.
584
703
 
704
+ ### Missing-translation strategy (`onMissing`)
705
+
706
+ Both providers accept an `onMissing` option controlling what happens when no template resolves for a key/locale (after walking the full fallback chain):
707
+
708
+ ```ts
709
+ onMissing?: "throw" | "key" | ((context: MissingTranslationContext) => string);
710
+ // MissingTranslationContext = { namespace?: string; key: string; locale: string; fallbackChain: string }
711
+ ```
712
+
713
+ - **`"throw"`** (default) — throws `[i18n] Missing key or locale: ...` including the fallback chain.
714
+ - **`"key"`** — returns the key itself: `key` in single mode, `namespace.key` in multi mode.
715
+ - **Function** — receives the missing-translation context (`namespace` is only set in multi mode) and returns the string to display.
716
+
717
+ ```ts
718
+ const i18n = new IcuTranslationProviderMulti(schema, {
719
+ localeFallback,
720
+ onMissing: ({ namespace, key }) => `[missing: ${namespace}.${key}]`,
721
+ });
722
+ ```
723
+
724
+ `onMissing` only applies to missing-template resolution. ICU syntax errors, formatting errors, and (in multi mode) unloaded namespaces still throw.
725
+
726
+ The generated factory accepts `onMissing` as a pass-through option, merged with the codegen-wired `localeFallback`:
727
+
728
+ ```ts
729
+ import { createI18n } from "./i18n/generated/instance.generated.js";
730
+
731
+ const i18n = createI18n(dictionary, { onMissing: "key" });
732
+ ```
733
+
585
734
  ## Commands
586
735
 
587
736
  Run from the repo root:
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod';
2
+
3
+ declare const DELIVERY_MODES: readonly ["canonical", "split-by-locale", "custom"];
4
+ type DeliveryMode = (typeof DELIVERY_MODES)[number];
5
+ declare const codegenConfigShape: {
6
+ dictionary: z.ZodOptional<z.ZodString>;
7
+ namespaces: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
8
+ defaultNamespace: z.ZodOptional<z.ZodString>;
9
+ typesOutput: z.ZodString;
10
+ dictionaryOutput: z.ZodString;
11
+ instanceOutput: z.ZodString;
12
+ dictionarySchemaOutput: z.ZodOptional<z.ZodString>;
13
+ loadOnInit: z.ZodOptional<z.ZodArray<z.ZodString>>;
14
+ namespaceLoadersOutput: z.ZodOptional<z.ZodString>;
15
+ importExtension: z.ZodOptional<z.ZodEnum<{
16
+ none: "none";
17
+ ".ts": ".ts";
18
+ ".js": ".js";
19
+ }>>;
20
+ paramsTypeName: z.ZodString;
21
+ schemaTypeName: z.ZodString;
22
+ localeTypeName: z.ZodOptional<z.ZodString>;
23
+ localeFallbackConstName: z.ZodOptional<z.ZodString>;
24
+ localeFallback: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>>;
25
+ factoryName: z.ZodOptional<z.ZodString>;
26
+ delivery: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
27
+ custom: "custom";
28
+ canonical: "canonical";
29
+ "split-by-locale": "split-by-locale";
30
+ }>>>;
31
+ deliveryArtifacts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
32
+ deliveryOutput: z.ZodOptional<z.ZodString>;
33
+ };
34
+ declare const codegenConfigKeys: (keyof typeof codegenConfigShape)[];
35
+ declare const codegenConfigSchema: z.ZodObject<{
36
+ dictionary: z.ZodOptional<z.ZodString>;
37
+ namespaces: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
38
+ defaultNamespace: z.ZodOptional<z.ZodString>;
39
+ typesOutput: z.ZodString;
40
+ dictionaryOutput: z.ZodString;
41
+ instanceOutput: z.ZodString;
42
+ dictionarySchemaOutput: z.ZodOptional<z.ZodString>;
43
+ loadOnInit: z.ZodOptional<z.ZodArray<z.ZodString>>;
44
+ namespaceLoadersOutput: z.ZodOptional<z.ZodString>;
45
+ importExtension: z.ZodOptional<z.ZodEnum<{
46
+ none: "none";
47
+ ".ts": ".ts";
48
+ ".js": ".js";
49
+ }>>;
50
+ paramsTypeName: z.ZodString;
51
+ schemaTypeName: z.ZodString;
52
+ localeTypeName: z.ZodOptional<z.ZodString>;
53
+ localeFallbackConstName: z.ZodOptional<z.ZodString>;
54
+ localeFallback: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>>;
55
+ factoryName: z.ZodOptional<z.ZodString>;
56
+ delivery: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
57
+ custom: "custom";
58
+ canonical: "canonical";
59
+ "split-by-locale": "split-by-locale";
60
+ }>>>;
61
+ deliveryArtifacts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
62
+ deliveryOutput: z.ZodOptional<z.ZodString>;
63
+ }, z.core.$strict>;
64
+ type CodegenConfigInput = z.input<typeof codegenConfigSchema>;
65
+ type CodegenConfig = z.infer<typeof codegenConfigSchema>;
66
+ declare function resolveDeliveryOutputDir(config: Pick<CodegenConfig, "typesOutput" | "deliveryOutput">): string;
67
+
68
+ type DeliveryArtifactsMap = Record<string, readonly string[]>;
69
+
70
+ declare const SUPPORTED_IMPORT_EXTENSIONS: readonly ["none", ".ts", ".js"];
71
+ type SupportedImportExtension = (typeof SUPPORTED_IMPORT_EXTENSIONS)[number];
72
+
73
+ type SetupMode = "single" | "multi";
74
+ declare function buildCodegenConfig(mode: SetupMode, project: string): CodegenConfigInput;
75
+
76
+ declare function inferProjectName(dirName: string): string;
77
+ declare function typeNamesForProject(project: string): {
78
+ paramsTypeName: string;
79
+ schemaTypeName: string;
80
+ localeTypeName: string;
81
+ };
82
+
83
+ declare function writeCodegenConfig(configPath: string, config: CodegenConfigInput): void;
84
+
85
+ export { type CodegenConfig, type CodegenConfigInput, DELIVERY_MODES, type DeliveryArtifactsMap, type DeliveryMode, SUPPORTED_IMPORT_EXTENSIONS, type SetupMode, type SupportedImportExtension, buildCodegenConfig, codegenConfigKeys, inferProjectName, resolveDeliveryOutputDir, typeNamesForProject, writeCodegenConfig };