@xndrjs/i18n 0.2.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 (50) hide show
  1. package/README.md +572 -0
  2. package/bin/codegen.mjs +17 -0
  3. package/bin/setup.mjs +14 -0
  4. package/dist/index.d.ts +124 -0
  5. package/dist/index.js +320 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/types-CFWVT2PP.d.ts +77 -0
  8. package/dist/validation/index.d.ts +44 -0
  9. package/dist/validation/index.js +490 -0
  10. package/dist/validation/index.js.map +1 -0
  11. package/package.json +65 -0
  12. package/src/IcuTranslationProviderMulti.test.ts +290 -0
  13. package/src/IcuTranslationProviderMulti.ts +195 -0
  14. package/src/IcuTranslationProviderSingle.test.ts +314 -0
  15. package/src/IcuTranslationProviderSingle.ts +155 -0
  16. package/src/codegen/config.ts +63 -0
  17. package/src/codegen/emit/dictionary-file.ts +73 -0
  18. package/src/codegen/emit/dictionary-schema-file.ts +129 -0
  19. package/src/codegen/emit/instance-file.ts +60 -0
  20. package/src/codegen/emit/namespace-loaders-file.ts +44 -0
  21. package/src/codegen/emit/types-file.ts +118 -0
  22. package/src/codegen/generate-i18n-types.test.ts +482 -0
  23. package/src/codegen/generate-i18n-types.ts +173 -0
  24. package/src/codegen/icu-analysis.ts +88 -0
  25. package/src/codegen/locale-fallback.ts +64 -0
  26. package/src/codegen/paths.ts +27 -0
  27. package/src/codegen/types.ts +30 -0
  28. package/src/deep-freeze.test.ts +27 -0
  29. package/src/deep-freeze.ts +17 -0
  30. package/src/ensure-namespace.integration.test.ts +66 -0
  31. package/src/ensure-namespace.test.ts +125 -0
  32. package/src/ensure-namespace.ts +70 -0
  33. package/src/icu/extract-variables.ts +87 -0
  34. package/src/icu/parse-template.ts +24 -0
  35. package/src/index.ts +32 -0
  36. package/src/resolve-locale.test.ts +91 -0
  37. package/src/resolve-locale.ts +88 -0
  38. package/src/setup/setup-i18n.test.ts +86 -0
  39. package/src/setup/setup-i18n.ts +179 -0
  40. package/src/setup/type-names.ts +20 -0
  41. package/src/types.ts +34 -0
  42. package/src/validation/create-args-schema.ts +76 -0
  43. package/src/validation/create-normalized-schema.ts +52 -0
  44. package/src/validation/errors.ts +41 -0
  45. package/src/validation/index.ts +90 -0
  46. package/src/validation/normalize.ts +158 -0
  47. package/src/validation/to-dictionary.ts +67 -0
  48. package/src/validation/types.ts +78 -0
  49. package/src/validation/validate-normalized.ts +91 -0
  50. package/src/validation/validation.test.ts +265 -0
package/README.md ADDED
@@ -0,0 +1,572 @@
1
+ # @xndrjs/i18n
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).
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 provider caches compiled messages and lets you replace the whole dictionary (or a single namespace) on the fly from any source (i.e. a CMS).
6
+
7
+ ## Key features
8
+
9
+ - **Type-safe `.get()`** — 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** — hydrate translations from an external source via `setAll()` / `setNamespace()` 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 `ensureNamespacesLoaded()` (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
+ - **Publishable library** — the runtime and codegen live in a standalone package (`@xndrjs/i18n`) that carries no project-specific types.
17
+
18
+ ## Getting started
19
+
20
+ ### Install
21
+
22
+ ```bash
23
+ npm install @xndrjs/i18n tsx
24
+ # optional — external dictionary validation
25
+ npm install zod
26
+ ```
27
+
28
+ `tsx` is a **peer dependency** required by `xndrjs-i18n-codegen`: the CLI runs the TypeScript codegen script directly (no precompiled JS bundle). Add it to your app or monorepo root.
29
+
30
+ `zod` is optional — only needed when you use `dictionarySchemaOutput` and the generated `validateExternalDictionary()` helpers.
31
+
32
+ ### Quick setup (single file)
33
+
34
+ **1. Translation JSON** (`src/i18n/translations/translations.json`):
35
+
36
+ ```json
37
+ {
38
+ "login_button": { "en": "Login", "it": "Accedi" },
39
+ "welcome": { "en": "Welcome {name}!" }
40
+ }
41
+ ```
42
+
43
+ **2. Codegen config** (`i18n.codegen.json` at project root):
44
+
45
+ ```json
46
+ {
47
+ "dictionary": "src/i18n/translations/translations.json",
48
+ "typesOutput": "src/i18n/generated/i18n-types.generated.ts",
49
+ "dictionaryOutput": "src/i18n/generated/dictionary.generated.ts",
50
+ "instanceOutput": "src/i18n/generated/instance.generated.ts",
51
+ "paramsTypeName": "MyProjectParams",
52
+ "schemaTypeName": "MyProjectSchema"
53
+ }
54
+ ```
55
+
56
+ **3. npm script** (`package.json`):
57
+
58
+ ```json
59
+ {
60
+ "scripts": {
61
+ "i18n:codegen": "xndrjs-i18n-codegen --config i18n.codegen.json"
62
+ }
63
+ }
64
+ ```
65
+
66
+ **4. Generate and use:**
67
+
68
+ ```bash
69
+ npm run i18n:codegen
70
+ ```
71
+
72
+ ```ts
73
+ // src/i18n/index.ts
74
+ import { createI18n } from "./generated/instance.generated.js";
75
+
76
+ export * from "./generated/instance.generated.js";
77
+ export * from "./generated/i18n-types.generated.js";
78
+
79
+ export const i18n = createI18n();
80
+ ```
81
+
82
+ ```ts
83
+ import { i18n } from "./i18n";
84
+
85
+ i18n.get("login_button", "it"); // "Accedi"
86
+ i18n.get("welcome", "en", { name: "Ada" }); // "Welcome Ada!"
87
+ ```
88
+
89
+ Run codegen after every change to your JSON files (or wire it into your build).
90
+
91
+ Or scaffold the starter files with the setup CLI:
92
+
93
+ ```bash
94
+ xndrjs-i18n-setup single . --project MyApp
95
+ xndrjs-i18n-setup multi apps/myapp --project MyApp
96
+ ```
97
+
98
+ This creates `i18n.codegen.json`, starter translation JSON, and `src/i18n/index.ts`. Edit the config for lazy loading, validation, locale fallback, and extra namespaces, then run codegen.
99
+
100
+ ### Quick setup (multi namespace)
101
+
102
+ Use `namespaces` instead of `dictionary` in `i18n.codegen.json`. See [Configuration](#configuration-i18ncodegenjson) and the [multi-namespace example](#multi-namespace-example) below.
103
+
104
+ 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).
105
+
106
+ ## Repository layout
107
+
108
+ This package lives in the xndrjs-toolkit pnpm monorepo.
109
+
110
+ ```
111
+ xndrjs-toolkit/
112
+ ├── packages/
113
+ │ └── i18n/ # @xndrjs/i18n — the publishable library
114
+ │ ├── bin/
115
+ │ │ └── codegen.mjs # CLI entry: xndrjs-i18n-codegen
116
+ │ └── src/
117
+ │ ├── index.ts # public exports
118
+ │ ├── types.ts # generic dictionary/cache types
119
+ │ ├── IcuTranslationProviderSingle.ts
120
+ │ ├── IcuTranslationProviderMulti.ts
121
+ │ └── codegen/
122
+ │ └── generate-i18n-types.ts
123
+ └── apps/
124
+ └── i18n-demo/ # @xndrjs/i18n-demo — workshop app
125
+ ├── single/ # single-file example
126
+ │ ├── i18n.codegen.json
127
+ │ └── src/i18n/
128
+ └── multi/ # multi-namespace example
129
+ ├── i18n.codegen.json
130
+ └── src/i18n/
131
+ ```
132
+
133
+ ### Library vs. consumer
134
+
135
+ `@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.
136
+
137
+ The consumer app owns its ICU JSON, its codegen config, and the generated files that bind the generic providers to concrete types.
138
+
139
+ ## How it works
140
+
141
+ ```mermaid
142
+ flowchart TB
143
+ config[i18n.codegen.json]
144
+ json["translations/*.json (ICU strings)"]
145
+ codegen["xndrjs-i18n-codegen"]
146
+ types["i18n-types.generated.ts (Params + Schema)"]
147
+ dict["dictionary.generated.ts"]
148
+ inst["instance.generated.ts (factory)"]
149
+ provider["IcuTranslationProvider (Single | Multi)"]
150
+ external["External runtime override (API, file, DB, CMS, ...)"]
151
+
152
+ config --> codegen
153
+ json --> codegen
154
+ codegen --> types
155
+ codegen --> dict
156
+ codegen --> inst
157
+ types --> provider
158
+ dict --> inst
159
+ inst --> provider
160
+ external -->|"setAll / setNamespace"| provider
161
+ ```
162
+
163
+ ### 1. Source JSON
164
+
165
+ Each translation key maps locale codes to ICU strings. Structure is identical in both modes: `key -> locale -> ICU string`.
166
+
167
+ ```json
168
+ {
169
+ "login_button": { "it": "Accedi", "en": "Login" },
170
+ "welcome": { "it": "Benvenuto {name}!", "en": "Welcome {name}!" },
171
+ "dashboard_status": {
172
+ "it": "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
173
+ "en": "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}"
174
+ }
175
+ }
176
+ ```
177
+
178
+ ### 2. Codegen (build-time)
179
+
180
+ `xndrjs-i18n-codegen` reads `i18n.codegen.json`, parses every ICU string with `@formatjs/icu-messageformat-parser`, and infers parameter types:
181
+
182
+ | ICU construct | Inferred type |
183
+ | ----------------------------------------- | ------------- |
184
+ | Simple argument `{name}` | `string` |
185
+ | `plural` argument `{count, plural, ...}` | `number` |
186
+ | `select` argument `{gender, select, ...}` | `string` |
187
+ | No variables | `never` |
188
+
189
+ 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).
190
+
191
+ ### 3. Generated files
192
+
193
+ - **`i18n-types.generated.ts`** — `I18N_MODE`, `MyProjectParams`, `MyProjectSchema`.
194
+ - **`dictionary.generated.ts`** — imports the JSON files and assembles the initial dictionary.
195
+ - **`instance.generated.ts`** — exports `createI18n()`, a typed factory with the default dictionary as fallback.
196
+ - **`i18n.ts`** (optional, hand-written) — app-owned singleton if desired.
197
+
198
+ Example generated types (multi-namespace):
199
+
200
+ ```ts
201
+ export const I18N_MODE = "multi" as const;
202
+
203
+ export type MyProjectParams = {
204
+ default: {
205
+ login_button: never;
206
+ welcome: { name: string };
207
+ dashboard_status: { msgCount: number; chatCount: number };
208
+ };
209
+ user: {
210
+ profile_title: never;
211
+ greeting: { name: string };
212
+ };
213
+ billing: {
214
+ invoice_summary: { count: number };
215
+ };
216
+ };
217
+
218
+ export type MyProjectSchema = {
219
+ default: typeof import("./translations/default.json");
220
+ user: typeof import("./translations/user.json");
221
+ billing: typeof import("./translations/billing.json");
222
+ };
223
+ ```
224
+
225
+ ### 4. Runtime provider
226
+
227
+ Create a provider with the generated factory (no side effects at import time):
228
+
229
+ ```ts
230
+ import { createI18n } from "./i18n";
231
+
232
+ const i18n = createI18n();
233
+ // or with an external dictionary at init:
234
+ const i18n = createI18n(externalDictionary);
235
+ ```
236
+
237
+ Optionally, wrap it in an app-owned singleton (`i18n.ts`):
238
+
239
+ ```ts
240
+ import { createI18n } from "./instance.generated.js";
241
+ export const i18n = createI18n();
242
+ ```
243
+
244
+ ### Locale-bound provider (`forLocale`)
245
+
246
+ Bind a locale once and omit it on every `.get()`:
247
+
248
+ ```ts
249
+ const i18n = createI18n();
250
+ const i18nEn = i18n.forLocale("en");
251
+
252
+ i18nEn.get("login_button"); // single-file
253
+ i18nEn.get("welcome", { name: "Ada" });
254
+
255
+ const i18nIt = i18n.forLocale("it");
256
+ i18nIt.get("default", "login_button"); // multi-namespace
257
+ i18nIt.get("billing", "invoice_summary", { count: 3 });
258
+ ```
259
+
260
+ The bound view shares the parent dictionary, cache, and fallback rules. It exposes `locale` and a narrower `get()` signature only.
261
+
262
+ Because `Params[K]` (or `Params[NS][K]`) is used in a conditional rest parameter, TypeScript enforces the exact argument shape:
263
+
264
+ ```ts
265
+ ...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
266
+ ```
267
+
268
+ ### 5. Locale fallback
269
+
270
+ 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.
271
+
272
+ ```json
273
+ "localeFallback": {
274
+ "en": null,
275
+ "de-DE": "en",
276
+ "de-CH": "de-DE",
277
+ "it": "en"
278
+ }
279
+ ```
280
+
281
+ - `null` marks a terminal locale (no further fallback).
282
+ - Any other value is the next locale to try, recursively.
283
+ - If the chain ends without finding a template, `.get()` throws and includes the full chain in the error message.
284
+
285
+ Codegen emits `LOCALE_FALLBACK` and extends `MyProjectLocale` with the fallback locales. The generated factory wires the map into the provider automatically.
286
+
287
+ You can also pass a fallback map manually when constructing a provider:
288
+
289
+ ```ts
290
+ import { IcuTranslationProviderMulti, type LocaleFallbackMap } from "@xndrjs/i18n";
291
+
292
+ const localeFallback = {
293
+ en: null,
294
+ "de-CH": "en",
295
+ } satisfies LocaleFallbackMap;
296
+
297
+ const i18n = new IcuTranslationProviderMulti(schema, { localeFallback });
298
+ ```
299
+
300
+ ## Configuration (`i18n.codegen.json`)
301
+
302
+ Specify **exactly one** of `dictionary` (single-file) or `namespaces` (multi-file).
303
+
304
+ ### Multi-namespace
305
+
306
+ ```json
307
+ {
308
+ "namespaces": {
309
+ "default": "src/i18n/translations/default.json",
310
+ "user": "src/i18n/translations/user.json",
311
+ "billing": "src/i18n/translations/billing.json"
312
+ },
313
+ "typesOutput": "src/i18n/i18n-types.generated.ts",
314
+ "dictionaryOutput": "src/i18n/dictionary.generated.ts",
315
+ "instanceOutput": "src/i18n/instance.generated.ts",
316
+ "paramsTypeName": "MyProjectParams",
317
+ "schemaTypeName": "MyProjectSchema",
318
+ "localeTypeName": "MyProjectLocale",
319
+ "factoryName": "createI18n",
320
+ "localeFallback": {
321
+ "en": null,
322
+ "de-DE": "en",
323
+ "de-CH": "de-DE",
324
+ "it": "en"
325
+ }
326
+ }
327
+ ```
328
+
329
+ ### Single-file
330
+
331
+ ```json
332
+ {
333
+ "dictionary": "src/i18n/translations/translations.json",
334
+ "defaultNamespace": "default",
335
+ "typesOutput": "src/i18n/i18n-types.generated.ts",
336
+ "dictionaryOutput": "src/i18n/dictionary.generated.ts",
337
+ "instanceOutput": "src/i18n/instance.generated.ts",
338
+ "paramsTypeName": "MyProjectParams",
339
+ "schemaTypeName": "MyProjectSchema",
340
+ "localeTypeName": "MyProjectLocale",
341
+ "factoryName": "createI18n",
342
+ "localeFallback": {
343
+ "en": null,
344
+ "de-DE": "en",
345
+ "de-CH": "de-DE",
346
+ "it": "en"
347
+ }
348
+ }
349
+ ```
350
+
351
+ | Field | Description |
352
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
353
+ | `dictionary` | Path to a single JSON file (flat API). Mutually exclusive with `namespaces`. |
354
+ | `namespaces` | Map of `namespace -> JSON path` (namespaced API). Mutually exclusive with `dictionary`. |
355
+ | `defaultNamespace` | Optional. Namespace label used internally in single-file mode (default `"default"`). Not exposed in the flat API. |
356
+ | `typesOutput` | Output path for the generated types. |
357
+ | `dictionaryOutput` | Output path for the generated dictionary manifest. |
358
+ | `instanceOutput` | Output path for the generated factory (`createI18n`). |
359
+ | `factoryName` | Name of the exported factory function (default `createI18n`). |
360
+ | `paramsTypeName` / `schemaTypeName` | Names of the exported types (customizable per project). |
361
+ | `localeTypeName` | Name of the exported locale union type (default `MyProjectLocale`). |
362
+ | `localeFallback` | Optional map of `locale -> next locale | null` for runtime fallback resolution. |
363
+ | `localeFallbackConstName` | Name of the generated fallback constant (default `LOCALE_FALLBACK`). |
364
+ | `dictionarySchemaOutput` | Optional path for generated external dictionary validation (`dictionary-schema.generated.ts`). Requires `zod` in the consumer app. |
365
+ | `loadOnInit` | Multi mode only. Namespaces to include in the initial bundle via static imports. When omitted, all namespaces are eager (default). |
366
+ | `namespaceLoadersOutput` | Output path for generated lazy loaders and `ensureNamespacesLoaded()`. Defaults to `{dirname(instanceOutput)}/namespace-loaders.generated.ts`. Required when lazy namespaces exist. |
367
+
368
+ > Paths are resolved relative to the directory containing `i18n.codegen.json` (i.e. the consumer app root).
369
+
370
+ ## Usage
371
+
372
+ ### Single vs. multi-namespace API
373
+
374
+ | | Single-file | Multi-namespace |
375
+ | ----------- | ------------------------------ | --------------------------------------------- |
376
+ | `I18N_MODE` | `'single'` | `'multi'` |
377
+ | Provider | `IcuTranslationProviderSingle` | `IcuTranslationProviderMulti` |
378
+ | `.get()` | `get(key, locale, params?)` | `get(namespace, key, locale, params?)` |
379
+ | Override | `setAll(schema)` | `setAll(schema)` + `setNamespace(ns, values)` |
380
+
381
+ ### Multi-namespace example
382
+
383
+ ```ts
384
+ import { i18n } from "./i18n"; // app singleton from i18n.ts
385
+ // or: import { createI18n } from './i18n'; const i18n = createI18n();
386
+
387
+ i18n.get("default", "login_button", "it"); // "Accedi"
388
+ i18n.get("default", "welcome", "en", { name: "Ada" }); // "Welcome Ada!"
389
+ i18n.get("default", "dashboard_status", "it", { msgCount: 3, chatCount: 2 });
390
+ i18n.get("billing", "invoice_summary", "en", { count: 12 });
391
+
392
+ // Compile-time errors:
393
+ i18n.get("default", "welcome", "it"); // ✗ missing { name }
394
+ i18n.get("billing", "login_button", "it"); // ✗ key not in namespace
395
+ ```
396
+
397
+ ### Single-file example
398
+
399
+ ```ts
400
+ import { i18n } from "./i18n"; // app singleton from i18n.ts
401
+ // or: import { createI18n } from './i18n'; const i18n = createI18n();
402
+
403
+ i18n.get("login_button", "it");
404
+ i18n.get("welcome", "en", { name: "Ada" });
405
+ ```
406
+
407
+ ### Runtime override
408
+
409
+ ```ts
410
+ // Full override — replaces the entire dictionary and clears the cache
411
+ i18n.setAll(externalPayload);
412
+
413
+ // Partial patch — updates a single namespace and invalidates only its cache
414
+ i18n.setNamespace("billing", externalBillingPayload);
415
+ ```
416
+
417
+ ### Lazy namespace loading (multi mode)
418
+
419
+ 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.
420
+
421
+ ```json
422
+ {
423
+ "namespaces": {
424
+ "default": "src/i18n/translations/default.json",
425
+ "billing": "src/i18n/translations/billing.json"
426
+ },
427
+ "loadOnInit": ["default"],
428
+ "dictionarySchemaOutput": "src/i18n/generated/dictionary-schema.generated.ts",
429
+ "namespaceLoadersOutput": "src/i18n/generated/namespace-loaders.generated.ts"
430
+ }
431
+ ```
432
+
433
+ Codegen also emits `LoadOnInitNamespace`, `LazyNamespace`, and `InitialSchema` types. The generated factory accepts a partial `InitialSchema` at init time.
434
+
435
+ ```ts
436
+ import { i18n, ensureNamespacesLoaded } from "./i18n";
437
+
438
+ i18n.get("default", "login_button", "en"); // available immediately
439
+
440
+ await ensureNamespacesLoaded(i18n, ["billing"]);
441
+ i18n.get("billing", "invoice_summary", "en", { count: 12 });
442
+
443
+ // batch preload
444
+ await ensureNamespacesLoaded(i18n, ["user", "billing"]);
445
+ ```
446
+
447
+ Calling `.get()` on a namespace that is not loaded throws:
448
+
449
+ `[i18n] Namespace not loaded: "billing". Call ensureNamespacesLoaded(i18n, ["billing"]) first.`
450
+
451
+ When `loadOnInit` is omitted, behavior is unchanged: all namespaces are statically imported.
452
+
453
+ ### External dictionary validation
454
+
455
+ When translations arrive from an external source (i.e. a CMS), validate the `unknown` input before calling `setAll()` or `setNamespace()`.
456
+
457
+ Enable validation by adding `dictionarySchemaOutput` to `i18n.codegen.json`:
458
+
459
+ ```json
460
+ {
461
+ "dictionarySchemaOutput": "src/i18n/generated/dictionary-schema.generated.ts"
462
+ }
463
+ ```
464
+
465
+ Add `zod` as a dependency in the consumer app (optional peer of `@xndrjs/i18n`).
466
+
467
+ Codegen emits `DICTIONARY_SPEC` and `validateExternalDictionary()`. Validation runs in two phases:
468
+
469
+ 1. **Normalize** — parse ICU templates, extract variables, check required keys (missing keys fail; extra keys are ignored; partial locales are OK).
470
+ 2. **Validate** — compare extracted arguments against the static `Params` schema via Zod.
471
+
472
+ ```ts
473
+ import { formatIssues } from "@xndrjs/i18n/validation";
474
+ import { validateExternalDictionary } from "./i18n/generated/dictionary-schema.generated.js";
475
+ import { i18n } from "./i18n";
476
+
477
+ const raw: unknown = await loadTranslations();
478
+
479
+ const result = validateExternalDictionary(raw);
480
+ if (!result.ok) {
481
+ console.error(formatIssues(result.issues));
482
+ return;
483
+ }
484
+
485
+ i18n.setAll(result.data);
486
+ ```
487
+
488
+ For a namespace patch (multi mode only):
489
+
490
+ ```ts
491
+ import { validateExternalNamespace } from "./i18n/generated/dictionary-schema.generated.js";
492
+
493
+ const result = validateExternalNamespace("billing", rawBilling);
494
+ if (result.ok) {
495
+ i18n.setNamespace("billing", result.data);
496
+ }
497
+ ```
498
+
499
+ Low-level API is also available from `@xndrjs/i18n/validation` for custom wiring.
500
+
501
+ ## Provider API
502
+
503
+ Both providers share this behavior:
504
+
505
+ - **Compilation cache** — compiled `IntlMessageFormat` instances are cached per locale (and per namespace in multi mode).
506
+ - **`getAll()`** — returns a deep-frozen snapshot of the current dictionary (not a live reference).
507
+ - **`hasNamespace(ns)`** — (multi only) returns whether a namespace has been loaded (eager init, lazy load, or `setNamespace`).
508
+ - **`setAll(values)`** — replaces the dictionary and clears the entire cache.
509
+ - **`setNamespace(ns, values)`** — (multi only) replaces one namespace and invalidates only its cache entries.
510
+ - **Missing key/locale** — throws an error if the template is `undefined`. An empty string (`""`) is treated as a valid template.
511
+ - **ICU syntax error** — throws `[i18n ICU Syntax Error] ...`.
512
+ - **Formatting error** (missing/invalid params) — throws `[i18n Formatting Error] ...`.
513
+
514
+ ## Commands
515
+
516
+ Run from the repo root:
517
+
518
+ ```bash
519
+ # Install workspaces
520
+ pnpm install
521
+
522
+ # Build the library
523
+ pnpm --filter @xndrjs/i18n build
524
+
525
+ # Generate i18n types/dictionary/instance for the demo app
526
+ pnpm --filter @xndrjs/i18n-demo i18n:codegen
527
+
528
+ # Type-check the i18n package
529
+ pnpm --filter @xndrjs/i18n typecheck
530
+
531
+ # Run the demo app (single + multi)
532
+ pnpm --filter @xndrjs/i18n-demo demo
533
+
534
+ # Run library tests
535
+ pnpm --filter @xndrjs/i18n test
536
+ ```
537
+
538
+ From inside `apps/i18n-demo/`:
539
+
540
+ ```bash
541
+ pnpm run i18n:codegen:single # xndrjs-i18n-codegen --config single/i18n.codegen.json
542
+ pnpm run i18n:codegen:multi # xndrjs-i18n-codegen --config multi/i18n.codegen.json
543
+ pnpm run demo:single # tsx single/src/index.ts
544
+ pnpm run demo:multi # tsx multi/src/index.ts
545
+ ```
546
+
547
+ ## Adding a translation key
548
+
549
+ 1. Add the key with its ICU strings to the relevant JSON file under `translations/`.
550
+ 2. Run `pnpm --filter @xndrjs/i18n-demo i18n:codegen:multi` (or `i18n:codegen:single`).
551
+ 3. The generated `MyProjectParams` updates; TypeScript will flag any `.get()` call sites that now need (or no longer need) parameters.
552
+
553
+ ## Adding a namespace
554
+
555
+ 1. Create a new JSON file (any name/path).
556
+ 2. Add it to `namespaces` in `i18n.codegen.json`.
557
+ 3. Re-run codegen. `dictionary.generated.ts` wires the import automatically.
558
+
559
+ ## Migrating single-file to multi-namespace
560
+
561
+ 1. Split the flat JSON into per-domain files.
562
+ 2. Change `dictionary` to `namespaces` in the config.
563
+ 3. Re-run codegen — types and API switch from flat to namespaced.
564
+ 4. Update call sites: `get('key', locale)` → `get('namespace', 'key', locale)`.
565
+
566
+ ## Tech stack
567
+
568
+ - **TypeScript 6** (`strict`, `resolveJsonModule`, `moduleResolution: "bundler"`)
569
+ - **[intl-messageformat](https://www.npmjs.com/package/intl-messageformat)** — runtime ICU formatting
570
+ - **[@formatjs/icu-messageformat-parser](https://www.npmjs.com/package/@formatjs/icu-messageformat-parser)** — build-time AST parsing
571
+ - **[zod](https://www.npmjs.com/package/zod)** — optional peer for external dictionary validation
572
+ - **[tsx](https://www.npmjs.com/package/tsx)** — runs TypeScript scripts directly
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const script = join(
7
+ dirname(fileURLToPath(import.meta.url)),
8
+ "../src/codegen/generate-i18n-types.ts"
9
+ );
10
+
11
+ const result = spawnSync("tsx", [script, ...process.argv.slice(2)], {
12
+ stdio: "inherit",
13
+ cwd: process.cwd(),
14
+ env: process.env,
15
+ });
16
+
17
+ process.exit(result.status ?? 1);
package/bin/setup.mjs ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const script = join(dirname(fileURLToPath(import.meta.url)), "../src/setup/setup-i18n.ts");
7
+
8
+ const result = spawnSync("tsx", [script, ...process.argv.slice(2)], {
9
+ stdio: "inherit",
10
+ cwd: process.cwd(),
11
+ env: process.env,
12
+ });
13
+
14
+ process.exit(result.status ?? 1);