@xndrjs/i18n 0.7.0-alpha.0 → 0.8.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.
- package/README.md +67 -872
- package/dist/codegen/index.d.ts +117 -51
- package/dist/codegen/index.js +1585 -93
- package/dist/codegen/index.js.map +1 -1
- package/dist/index.d.ts +100 -234
- package/dist/index.js +74 -638
- package/dist/index.js.map +1 -1
- package/dist/validation/index.d.ts +5 -0
- package/dist/validation/index.js.map +1 -1
- package/package.json +2 -2
- package/src/IcuTranslationProviderMulti.test.ts +18 -40
- package/src/IcuTranslationProviderMulti.ts +27 -37
- package/src/audit/audit-dictionaries.ts +1 -1
- package/src/audit/run-audit.test.ts +6 -7
- package/src/builder-load-registry.ts +26 -5
- package/src/builder-loaders.ts +8 -11
- package/src/builder-types.test.ts +11 -42
- package/src/builder.test.ts +82 -243
- package/src/builder.ts +8 -110
- package/src/codegen/codegen-config-schema.ts +82 -77
- package/src/codegen/config.test.ts +55 -143
- package/src/codegen/config.ts +10 -67
- package/src/codegen/dictionary-spec-contract.test.ts +28 -0
- package/src/codegen/dictionary-spec-contract.ts +138 -0
- package/src/codegen/emit/dictionary-schema-file.ts +7 -43
- package/src/codegen/emit/instance-file.test.ts +35 -52
- package/src/codegen/emit/instance-file.ts +170 -268
- package/src/codegen/emit/namespace-loaders-file.test.ts +72 -74
- package/src/codegen/emit/namespace-loaders-file.ts +147 -90
- package/src/codegen/emit/types-file.test.ts +20 -41
- package/src/codegen/emit/types-file.ts +48 -74
- package/src/codegen/generate-i18n-types.test.ts +163 -492
- package/src/codegen/generate-i18n-types.ts +7 -269
- package/src/codegen/paths.ts +0 -14
- package/src/codegen/project-locales-set-namespace.test.ts +44 -86
- package/src/codegen/read-dictionary.test.ts +27 -9
- package/src/codegen/read-dictionary.ts +18 -40
- package/src/codegen/regenerate-namespaces.ts +180 -0
- package/src/codegen/run-codegen.test.ts +230 -0
- package/src/codegen/run-codegen.ts +252 -0
- package/src/codegen/types.ts +0 -6
- package/src/codegen-config/build-config.ts +4 -23
- package/src/codegen-config/codegen-config.test.ts +10 -6
- package/src/codegen-config/index.ts +18 -3
- package/src/engine.ts +3 -27
- package/src/fetch-artifact.ts +16 -0
- package/src/i18n-handle.ts +202 -0
- package/src/index.ts +15 -34
- package/src/project-locales.ts +2 -2
- package/src/scope-multi.ts +1 -20
- package/src/scope-types.ts +14 -6
- package/src/scope.test.ts +3 -312
- package/src/serialized-state.test.ts +114 -0
- package/src/serialized-state.ts +28 -0
- package/src/setup/setup-i18n.test.ts +22 -33
- package/src/setup/setup-i18n.ts +22 -27
- package/src/types.ts +3 -3
- package/src/validation/index.ts +4 -0
- package/src/IcuTranslationProviderSingle.test.ts +0 -177
- package/src/IcuTranslationProviderSingle.ts +0 -114
- package/src/builder-multi.ts +0 -481
- package/src/codegen/emit/dictionary-file.test.ts +0 -215
- package/src/codegen/emit/dictionary-file.ts +0 -244
- package/src/deep-freeze.test.ts +0 -40
- package/src/deep-freeze.ts +0 -22
- package/src/patch-key.test.ts +0 -186
- package/src/patch-key.ts +0 -140
- package/src/scope-single.ts +0 -85
- package/src/single-builder.ts +0 -153
package/README.md
CHANGED
|
@@ -1,946 +1,141 @@
|
|
|
1
1
|
# @xndrjs/i18n
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Compiler-first, type-safe ICU MessageFormat i18n for TypeScript apps.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
**2. Generate:**
|
|
705
38
|
|
|
706
|
-
```
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
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
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
61
|
+
SSR → CSR hydrate:
|
|
745
62
|
|
|
746
63
|
```ts
|
|
747
|
-
const
|
|
748
|
-
|
|
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
|
-
|
|
68
|
+
Scaffold:
|
|
758
69
|
|
|
759
|
-
|
|
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
|
-
|
|
74
|
+
## Delivery modes
|
|
774
75
|
|
|
775
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
85
|
+
"loaderStrategy": "fetch"
|
|
786
86
|
}
|
|
787
87
|
```
|
|
788
88
|
|
|
789
|
-
|
|
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
|
-
|
|
92
|
+
Hydrate from SSR:
|
|
829
93
|
|
|
830
94
|
```ts
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
## Provider API
|
|
101
|
+
## Content updates without build (CMS)
|
|
844
102
|
|
|
845
|
-
|
|
103
|
+
Two APIs from `@xndrjs/i18n/codegen`:
|
|
846
104
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
|
|
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
|
-
|
|
862
|
-
// MissingTranslationContext = { namespace?: string; key: string; locale: string; fallbackChain: string }
|
|
863
|
-
```
|
|
113
|
+
import { regenerateNamespaces } from "@xndrjs/i18n/codegen";
|
|
864
114
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
914
|
-
|
|
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
|
-
-
|
|
943
|
-
-
|
|
944
|
-
-
|
|
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)
|