@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.
- package/README.md +572 -0
- package/bin/codegen.mjs +17 -0
- package/bin/setup.mjs +14 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +320 -0
- package/dist/index.js.map +1 -0
- package/dist/types-CFWVT2PP.d.ts +77 -0
- package/dist/validation/index.d.ts +44 -0
- package/dist/validation/index.js +490 -0
- package/dist/validation/index.js.map +1 -0
- package/package.json +65 -0
- package/src/IcuTranslationProviderMulti.test.ts +290 -0
- package/src/IcuTranslationProviderMulti.ts +195 -0
- package/src/IcuTranslationProviderSingle.test.ts +314 -0
- package/src/IcuTranslationProviderSingle.ts +155 -0
- package/src/codegen/config.ts +63 -0
- package/src/codegen/emit/dictionary-file.ts +73 -0
- package/src/codegen/emit/dictionary-schema-file.ts +129 -0
- package/src/codegen/emit/instance-file.ts +60 -0
- package/src/codegen/emit/namespace-loaders-file.ts +44 -0
- package/src/codegen/emit/types-file.ts +118 -0
- package/src/codegen/generate-i18n-types.test.ts +482 -0
- package/src/codegen/generate-i18n-types.ts +173 -0
- package/src/codegen/icu-analysis.ts +88 -0
- package/src/codegen/locale-fallback.ts +64 -0
- package/src/codegen/paths.ts +27 -0
- package/src/codegen/types.ts +30 -0
- package/src/deep-freeze.test.ts +27 -0
- package/src/deep-freeze.ts +17 -0
- package/src/ensure-namespace.integration.test.ts +66 -0
- package/src/ensure-namespace.test.ts +125 -0
- package/src/ensure-namespace.ts +70 -0
- package/src/icu/extract-variables.ts +87 -0
- package/src/icu/parse-template.ts +24 -0
- package/src/index.ts +32 -0
- package/src/resolve-locale.test.ts +91 -0
- package/src/resolve-locale.ts +88 -0
- package/src/setup/setup-i18n.test.ts +86 -0
- package/src/setup/setup-i18n.ts +179 -0
- package/src/setup/type-names.ts +20 -0
- package/src/types.ts +34 -0
- package/src/validation/create-args-schema.ts +76 -0
- package/src/validation/create-normalized-schema.ts +52 -0
- package/src/validation/errors.ts +41 -0
- package/src/validation/index.ts +90 -0
- package/src/validation/normalize.ts +158 -0
- package/src/validation/to-dictionary.ts +67 -0
- package/src/validation/types.ts +78 -0
- package/src/validation/validate-normalized.ts +91 -0
- 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
|
package/bin/codegen.mjs
ADDED
|
@@ -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);
|