@xndrjs/i18n 0.2.1 → 0.3.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 +58 -8
- package/bin/audit.mjs +14 -0
- package/package.json +5 -3
- package/src/IcuTranslationProviderSingle.test.ts +54 -0
- package/src/audit/audit-dictionaries.test.ts +134 -0
- package/src/audit/audit-dictionaries.ts +172 -0
- package/src/audit/run-audit.test.ts +136 -0
- package/src/audit/run-audit.ts +81 -0
- package/src/codegen/emit/dictionary-file.ts +7 -3
- package/src/codegen/emit/dictionary-schema-file.ts +6 -4
- package/src/codegen/emit/instance-file.ts +10 -5
- package/src/codegen/emit/namespace-loaders-file.ts +10 -6
- package/src/codegen/generate-i18n-types.test.ts +374 -0
- package/src/codegen/generate-i18n-types.ts +33 -5
- package/src/codegen/icu-analysis.ts +3 -2
- package/src/codegen/locale-policy.test.ts +35 -0
- package/src/codegen/locale-policy.ts +26 -0
- package/src/codegen/paths.ts +27 -1
- package/src/codegen/read-dictionary.test.ts +104 -0
- package/src/codegen/read-dictionary.ts +133 -0
- package/src/codegen/types.ts +5 -0
- package/src/setup/setup-i18n.ts +7 -4
package/README.md
CHANGED
|
@@ -112,7 +112,8 @@ xndrjs-toolkit/
|
|
|
112
112
|
├── packages/
|
|
113
113
|
│ └── i18n/ # @xndrjs/i18n — the publishable library
|
|
114
114
|
│ ├── bin/
|
|
115
|
-
│ │
|
|
115
|
+
│ │ ├── codegen.mjs # CLI entry: xndrjs-i18n-codegen
|
|
116
|
+
│ │ └── audit.mjs # CLI entry: xndrjs-i18n-audit
|
|
116
117
|
│ └── src/
|
|
117
118
|
│ ├── index.ts # public exports
|
|
118
119
|
│ ├── types.ts # generic dictionary/cache types
|
|
@@ -283,6 +284,8 @@ When a translation is missing for the requested locale (`undefined` in the dicti
|
|
|
283
284
|
|
|
284
285
|
Codegen emits `LOCALE_FALLBACK` and extends `MyProjectLocale` with the fallback locales. The generated factory wires the map into the provider automatically.
|
|
285
286
|
|
|
287
|
+
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).
|
|
288
|
+
|
|
286
289
|
You can also pass a fallback map manually when constructing a provider:
|
|
287
290
|
|
|
288
291
|
```ts
|
|
@@ -296,6 +299,27 @@ const localeFallback = {
|
|
|
296
299
|
const i18n = new IcuTranslationProviderMulti(schema, { localeFallback });
|
|
297
300
|
```
|
|
298
301
|
|
|
302
|
+
### 6. Translation audit (`xndrjs-i18n-audit`)
|
|
303
|
+
|
|
304
|
+
Report missing translations as JSON — useful for translator backlogs and optional CI gates.
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
xndrjs-i18n-audit --config i18n/i18n.codegen.json
|
|
308
|
+
xndrjs-i18n-audit --config i18n/i18n.codegen.json --out audit.json
|
|
309
|
+
xndrjs-i18n-audit --config i18n/i18n.codegen.json --fail-on effective
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**`requiredLocales`** in the report match generated `MyProjectLocale` (dictionary locales ∪ fallback keys and targets).
|
|
313
|
+
|
|
314
|
+
| Field | Meaning |
|
|
315
|
+
| -------------------------- | ---------------------------------------------------------------------------------------- |
|
|
316
|
+
| `missingDirectByLocale` | No template for that locale on the key (or empty string when `--allow-empty` is not set) |
|
|
317
|
+
| `missingEffectiveByLocale` | Runtime would fail after walking `LOCALE_FALLBACK` — real coverage gaps |
|
|
318
|
+
|
|
319
|
+
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).
|
|
320
|
+
|
|
321
|
+
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.
|
|
322
|
+
|
|
299
323
|
## Configuration (`i18n/i18n.codegen.json`)
|
|
300
324
|
|
|
301
325
|
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).
|
|
@@ -307,7 +331,7 @@ Specify **exactly one** of `dictionary` (single-file) or `namespaces` (multi-fil
|
|
|
307
331
|
"namespaces": {
|
|
308
332
|
"default": "translations/default.json",
|
|
309
333
|
"user": "translations/user.json",
|
|
310
|
-
"billing": "translations/billing.
|
|
334
|
+
"billing": "translations/billing.yaml"
|
|
311
335
|
},
|
|
312
336
|
"typesOutput": "generated/i18n-types.generated.ts",
|
|
313
337
|
"dictionaryOutput": "generated/dictionary.generated.ts",
|
|
@@ -349,12 +373,13 @@ Specify **exactly one** of `dictionary` (single-file) or `namespaces` (multi-fil
|
|
|
349
373
|
|
|
350
374
|
| Field | Description |
|
|
351
375
|
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
|
352
|
-
| `dictionary` | Path to a single
|
|
353
|
-
| `namespaces` | Map of `namespace ->
|
|
376
|
+
| `dictionary` | Path to a single dictionary file (`.json`, `.yaml`, or `.yml`) for the flat API. Mutually exclusive with `namespaces`. |
|
|
377
|
+
| `namespaces` | Map of `namespace -> dictionary path` (`.json`, `.yaml`, or `.yml`) for the namespaced API. Mutually exclusive with `dictionary`. |
|
|
354
378
|
| `defaultNamespace` | Optional. Namespace label used internally in single-file mode (default `"default"`). Not exposed in the flat API. |
|
|
355
379
|
| `typesOutput` | Output path for the generated types. |
|
|
356
380
|
| `dictionaryOutput` | Output path for the generated dictionary manifest. |
|
|
357
381
|
| `instanceOutput` | Output path for the generated factory (`createI18n`). |
|
|
382
|
+
| `importExtension` | Optional. Relative import suffix between generated `.ts` modules: `"none"` (default, extensionless), `".ts"`, or `".js"`. |
|
|
358
383
|
| `factoryName` | Name of the exported factory function (default `createI18n`). |
|
|
359
384
|
| `paramsTypeName` / `schemaTypeName` | Names of the exported types (customizable per project). |
|
|
360
385
|
| `localeTypeName` | Name of the exported locale union type (default `MyProjectLocale`). |
|
|
@@ -366,6 +391,29 @@ Specify **exactly one** of `dictionary` (single-file) or `namespaces` (multi-fil
|
|
|
366
391
|
|
|
367
392
|
> Paths are resolved relative to the directory containing `i18n.codegen.json` (e.g. `i18n/` when using `xndrjs-i18n-setup .`).
|
|
368
393
|
|
|
394
|
+
### YAML authoring
|
|
395
|
+
|
|
396
|
+
Dictionary paths may use `.yaml` or `.yml` instead of `.json`. **YAML is an authoring format, not a runtime format** — codegen compiles YAML to JSON under the generated output directory (for example `translations/billing.yaml` → `{dirname(typesOutput)}/translations/billing.json`); generated TypeScript imports the compiled JSON at runtime. Edit only the YAML source — the compiled JSON is overwritten on each codegen run.
|
|
397
|
+
|
|
398
|
+
YAML block scalars make long ICU messages much easier to read while preserving (or intentionally folding) line breaks.
|
|
399
|
+
|
|
400
|
+
```yaml
|
|
401
|
+
# translations/billing.yaml
|
|
402
|
+
appointment_summary:
|
|
403
|
+
en: |
|
|
404
|
+
Due {dueDate, date, short}
|
|
405
|
+
at {startTime, time, short}
|
|
406
|
+
it: "Scade il {dueDate, date, short} alle {startTime, time, short}"
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Workflow:
|
|
410
|
+
|
|
411
|
+
1. Edit the `.yaml` / `.yml` source file under `translations/`.
|
|
412
|
+
2. Run `xndrjs-i18n-codegen`.
|
|
413
|
+
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.
|
|
414
|
+
|
|
415
|
+
Mixed namespaces are supported: some namespaces can stay `.json` while others use YAML.
|
|
416
|
+
|
|
369
417
|
## Usage
|
|
370
418
|
|
|
371
419
|
### Single vs. multi-namespace API
|
|
@@ -421,7 +469,7 @@ Split namespaces across chunks by listing only the namespaces you need at startu
|
|
|
421
469
|
{
|
|
422
470
|
"namespaces": {
|
|
423
471
|
"default": "translations/default.json",
|
|
424
|
-
"billing": "translations/billing.
|
|
472
|
+
"billing": "translations/billing.yaml"
|
|
425
473
|
},
|
|
426
474
|
"loadOnInit": ["default"],
|
|
427
475
|
"dictionarySchemaOutput": "generated/dictionary-schema.generated.ts",
|
|
@@ -537,8 +585,10 @@ pnpm --filter @xndrjs/i18n test
|
|
|
537
585
|
From inside `apps/i18n-demo/`:
|
|
538
586
|
|
|
539
587
|
```bash
|
|
540
|
-
pnpm run i18n:codegen:single # xndrjs-i18n-codegen --config single/i18n.codegen.json
|
|
541
|
-
pnpm run i18n:codegen:multi # xndrjs-i18n-codegen --config multi/i18n.codegen.json
|
|
588
|
+
pnpm run i18n:codegen:single # xndrjs-i18n-codegen --config single/src/i18n/i18n.codegen.json
|
|
589
|
+
pnpm run i18n:codegen:multi # xndrjs-i18n-codegen --config multi/src/i18n/i18n.codegen.json
|
|
590
|
+
pnpm run i18n:audit:single # xndrjs-i18n-audit --config single/src/i18n/i18n.codegen.json
|
|
591
|
+
pnpm run i18n:audit:multi # xndrjs-i18n-audit --config multi/src/i18n/i18n.codegen.json
|
|
542
592
|
pnpm run demo:single # tsx single/src/index.ts
|
|
543
593
|
pnpm run demo:multi # tsx multi/src/index.ts
|
|
544
594
|
```
|
|
@@ -551,7 +601,7 @@ pnpm run demo:multi # tsx multi/src/index.ts
|
|
|
551
601
|
|
|
552
602
|
## Adding a namespace
|
|
553
603
|
|
|
554
|
-
1. Create a new
|
|
604
|
+
1. Create a new dictionary file (`.json`, `.yaml`, or `.yml`, any path).
|
|
555
605
|
2. Add it to `namespaces` in `i18n/i18n.codegen.json`.
|
|
556
606
|
3. Re-run codegen. `dictionary.generated.ts` wires the import automatically.
|
|
557
607
|
|
package/bin/audit.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/audit/run-audit.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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xndrjs/i18n",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Compiler-first, type-safe ICU MessageFormat i18n with runtime dictionary override from external sources.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"type": "module",
|
|
13
13
|
"bin": {
|
|
14
14
|
"xndrjs-i18n-codegen": "./bin/codegen.mjs",
|
|
15
|
-
"xndrjs-i18n-setup": "./bin/setup.mjs"
|
|
15
|
+
"xndrjs-i18n-setup": "./bin/setup.mjs",
|
|
16
|
+
"xndrjs-i18n-audit": "./bin/audit.mjs"
|
|
16
17
|
},
|
|
17
18
|
"types": "./dist/index.d.ts",
|
|
18
19
|
"exports": {
|
|
@@ -37,7 +38,8 @@
|
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"@formatjs/icu-messageformat-parser": "^3.5.12",
|
|
40
|
-
"intl-messageformat": "^11.2.9"
|
|
41
|
+
"intl-messageformat": "^11.2.9",
|
|
42
|
+
"yaml": "^2.8.1"
|
|
41
43
|
},
|
|
42
44
|
"peerDependencies": {
|
|
43
45
|
"tsx": ">=4",
|
|
@@ -8,11 +8,15 @@ type TestSchema = {
|
|
|
8
8
|
empty_label: { en: string };
|
|
9
9
|
broken: { en: string };
|
|
10
10
|
invoice_count: { en: string; it: string };
|
|
11
|
+
item_count_zero: { en: string };
|
|
12
|
+
item_count_exact: { en: string };
|
|
11
13
|
dashboard_status: { en: string; it: string };
|
|
12
14
|
inbox_owner: { en: string; it: string };
|
|
13
15
|
ranking_position: { en: string; it: string };
|
|
14
16
|
account_balance: { en: string; it: string };
|
|
15
17
|
appointment_summary: { en: string; it: string };
|
|
18
|
+
invoice_due_long: { en: string; it: string };
|
|
19
|
+
discount_rate: { en: string; it: string };
|
|
16
20
|
};
|
|
17
21
|
|
|
18
22
|
type TestParams = {
|
|
@@ -21,11 +25,15 @@ type TestParams = {
|
|
|
21
25
|
empty_label: never;
|
|
22
26
|
broken: { name: string };
|
|
23
27
|
invoice_count: { count: number };
|
|
28
|
+
item_count_zero: { count: number };
|
|
29
|
+
item_count_exact: { count: number };
|
|
24
30
|
dashboard_status: { msgCount: number; chatCount: number };
|
|
25
31
|
inbox_owner: { gender: string; name: string };
|
|
26
32
|
ranking_position: { position: number };
|
|
27
33
|
account_balance: { amount: number };
|
|
28
34
|
appointment_summary: { dueDate: Date | number; startTime: Date | number };
|
|
35
|
+
invoice_due_long: { dueDate: Date | number };
|
|
36
|
+
discount_rate: { rate: number };
|
|
29
37
|
};
|
|
30
38
|
|
|
31
39
|
const dictionary: TestSchema = {
|
|
@@ -37,6 +45,12 @@ const dictionary: TestSchema = {
|
|
|
37
45
|
en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
|
|
38
46
|
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
|
|
39
47
|
},
|
|
48
|
+
item_count_zero: {
|
|
49
|
+
en: "{count, plural, zero {no items} one {1 item} other {{count} items}}",
|
|
50
|
+
},
|
|
51
|
+
item_count_exact: {
|
|
52
|
+
en: "{count, plural, =5 {five items} one {1 item} other {{count} items}}",
|
|
53
|
+
},
|
|
40
54
|
dashboard_status: {
|
|
41
55
|
en: "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}",
|
|
42
56
|
it: "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
|
|
@@ -57,6 +71,14 @@ const dictionary: TestSchema = {
|
|
|
57
71
|
en: "Due {dueDate, date, short} at {startTime, time, short}",
|
|
58
72
|
it: "Scade il {dueDate, date, short} alle {startTime, time, short}",
|
|
59
73
|
},
|
|
74
|
+
invoice_due_long: {
|
|
75
|
+
en: "Payment due on {dueDate, date, ::yMMMMd}",
|
|
76
|
+
it: "Pagamento entro il {dueDate, date, ::yMMMMd}",
|
|
77
|
+
},
|
|
78
|
+
discount_rate: {
|
|
79
|
+
en: "Save {rate, number, ::percent} today",
|
|
80
|
+
it: "Risparmia il {rate, number, ::percent} oggi",
|
|
81
|
+
},
|
|
60
82
|
};
|
|
61
83
|
|
|
62
84
|
describe("IcuTranslationProviderSingle", () => {
|
|
@@ -78,6 +100,15 @@ describe("IcuTranslationProviderSingle", () => {
|
|
|
78
100
|
expect(provider.get("invoice_count", "it", { count: 5 })).toBe("Hai 5 fatture");
|
|
79
101
|
});
|
|
80
102
|
|
|
103
|
+
it("uses =5 for an exact match; zero is a locale plural category, not exact match in English", () => {
|
|
104
|
+
// ICU "zero" is a plural rule category (e.g. Arabic); in English, 0 maps to "other".
|
|
105
|
+
expect(provider.get("item_count_zero", "en", { count: 0 })).toBe("0 items");
|
|
106
|
+
// "=5" is an exact-value selector and matches count === 5 in any locale.
|
|
107
|
+
expect(provider.get("item_count_exact", "en", { count: 0 })).toBe("0 items");
|
|
108
|
+
expect(provider.get("item_count_exact", "en", { count: 1 })).toBe("1 item");
|
|
109
|
+
expect(provider.get("item_count_exact", "en", { count: 5 })).toBe("five items");
|
|
110
|
+
});
|
|
111
|
+
|
|
81
112
|
it("formats nested numeric plurals with double-brace references", () => {
|
|
82
113
|
expect(provider.get("dashboard_status", "en", { msgCount: 1, chatCount: 1 })).toBe(
|
|
83
114
|
"You have 1 message in one chat"
|
|
@@ -149,6 +180,25 @@ describe("IcuTranslationProviderSingle", () => {
|
|
|
149
180
|
})
|
|
150
181
|
).toBe(`Due ${expectedDate} at ${expectedTime}`);
|
|
151
182
|
});
|
|
183
|
+
|
|
184
|
+
it("formats ICU date and number skeletons (::yMMMMd, ::percent)", () => {
|
|
185
|
+
const when = new Date("2026-07-01T13:30:00Z");
|
|
186
|
+
const expectedLongDate = new Intl.DateTimeFormat("en", {
|
|
187
|
+
year: "numeric",
|
|
188
|
+
month: "long",
|
|
189
|
+
day: "numeric",
|
|
190
|
+
}).format(when);
|
|
191
|
+
const expectedPercent = new Intl.NumberFormat("en", {
|
|
192
|
+
style: "percent",
|
|
193
|
+
}).format(0.25);
|
|
194
|
+
|
|
195
|
+
expect(provider.get("invoice_due_long", "en", { dueDate: when })).toBe(
|
|
196
|
+
`Payment due on ${expectedLongDate}`
|
|
197
|
+
);
|
|
198
|
+
expect(provider.get("discount_rate", "en", { rate: 0.25 })).toBe(
|
|
199
|
+
`Save ${expectedPercent} today`
|
|
200
|
+
);
|
|
201
|
+
});
|
|
152
202
|
});
|
|
153
203
|
|
|
154
204
|
it("treats an empty string template as valid", () => {
|
|
@@ -209,11 +259,15 @@ describe("IcuTranslationProviderSingle", () => {
|
|
|
209
259
|
en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
|
|
210
260
|
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
|
|
211
261
|
},
|
|
262
|
+
item_count_zero: dictionary.item_count_zero,
|
|
263
|
+
item_count_exact: dictionary.item_count_exact,
|
|
212
264
|
dashboard_status: dictionary.dashboard_status,
|
|
213
265
|
inbox_owner: dictionary.inbox_owner,
|
|
214
266
|
ranking_position: dictionary.ranking_position,
|
|
215
267
|
account_balance: dictionary.account_balance,
|
|
216
268
|
appointment_summary: dictionary.appointment_summary,
|
|
269
|
+
invoice_due_long: dictionary.invoice_due_long,
|
|
270
|
+
discount_rate: dictionary.discount_rate,
|
|
217
271
|
};
|
|
218
272
|
const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(external);
|
|
219
273
|
external.login_button.en = "Sign in";
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { auditDictionaries, reportHasGaps } from "./audit-dictionaries.js";
|
|
3
|
+
|
|
4
|
+
describe("auditDictionaries", () => {
|
|
5
|
+
it("reports missingDirect for fallback-only locales while missingEffective stays empty when en covers", () => {
|
|
6
|
+
const report = auditDictionaries({
|
|
7
|
+
namespaces: {
|
|
8
|
+
default: {
|
|
9
|
+
login_button: { en: "Login", it: "Accedi" },
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
config: {
|
|
13
|
+
localeFallback: {
|
|
14
|
+
en: null,
|
|
15
|
+
"de-DE": "en",
|
|
16
|
+
"de-CH": "de-DE",
|
|
17
|
+
it: "en",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(report.requiredLocales).toEqual(["de-CH", "de-DE", "en", "it"]);
|
|
23
|
+
expect(report.missingDirectByLocale.default?.["de-CH"]).toEqual(["login_button"]);
|
|
24
|
+
expect(report.missingEffectiveByLocale.default?.["de-CH"]).toEqual([]);
|
|
25
|
+
expect(report.missingDirectByLocale.default?.en).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("treats empty strings as missing by default", () => {
|
|
29
|
+
const report = auditDictionaries({
|
|
30
|
+
namespaces: {
|
|
31
|
+
default: {
|
|
32
|
+
empty_label: { en: "" },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
config: {},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(report.missingDirectByLocale.default?.en).toEqual(["empty_label"]);
|
|
39
|
+
expect(report.missingEffectiveByLocale.default?.en).toEqual(["empty_label"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("allows empty strings when treatEmptyAsMissing is false", () => {
|
|
43
|
+
const report = auditDictionaries({
|
|
44
|
+
namespaces: {
|
|
45
|
+
default: {
|
|
46
|
+
empty_label: { en: "" },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
config: {},
|
|
50
|
+
treatEmptyAsMissing: false,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(report.missingDirectByLocale.default?.en).toEqual([]);
|
|
54
|
+
expect(report.missingEffectiveByLocale.default?.en).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("uses enriched locale fallback for effective gaps", () => {
|
|
58
|
+
const report = auditDictionaries({
|
|
59
|
+
namespaces: {
|
|
60
|
+
default: {
|
|
61
|
+
welcome: { en: "Welcome {name}!" },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
config: {
|
|
65
|
+
localeFallback: {
|
|
66
|
+
en: null,
|
|
67
|
+
it: "en",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(report.localeFallback).toEqual({
|
|
73
|
+
en: null,
|
|
74
|
+
it: "en",
|
|
75
|
+
});
|
|
76
|
+
expect(report.missingEffectiveByLocale.default?.it).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("matches direct and effective when localeFallback is absent", () => {
|
|
80
|
+
const report = auditDictionaries({
|
|
81
|
+
namespaces: {
|
|
82
|
+
default: {
|
|
83
|
+
login_button: { en: "Login" },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
config: {},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(report.localeFallback).toBeUndefined();
|
|
90
|
+
expect(report.requiredLocales).toEqual(["en"]);
|
|
91
|
+
expect(report.missingDirectByLocale.default?.en).toEqual([]);
|
|
92
|
+
expect(report.missingEffectiveByLocale.default?.en).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("reports effective gaps when fallback chain cannot resolve", () => {
|
|
96
|
+
const report = auditDictionaries({
|
|
97
|
+
namespaces: {
|
|
98
|
+
default: {
|
|
99
|
+
login_button: { it: "Accedi" },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
config: {
|
|
103
|
+
localeFallback: {
|
|
104
|
+
en: null,
|
|
105
|
+
it: "en",
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(report.missingEffectiveByLocale.default?.en).toEqual(["login_button"]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("reportHasGaps", () => {
|
|
115
|
+
const report = auditDictionaries({
|
|
116
|
+
namespaces: {
|
|
117
|
+
default: {
|
|
118
|
+
login_button: { en: "Login", it: "Accedi" },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
config: {
|
|
122
|
+
localeFallback: {
|
|
123
|
+
en: null,
|
|
124
|
+
"de-CH": "en",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("detects direct gaps", () => {
|
|
130
|
+
expect(reportHasGaps(report, "direct")).toBe(true);
|
|
131
|
+
expect(reportHasGaps(report, "effective")).toBe(false);
|
|
132
|
+
expect(reportHasGaps(report, "any")).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { CodegenConfig, DictionaryJson } from "../codegen/types.js";
|
|
3
|
+
import { buildRequiredLocales, enrichLocaleFallback } from "../codegen/locale-policy.js";
|
|
4
|
+
import { resolveLocaleTemplate } from "../resolve-locale.js";
|
|
5
|
+
|
|
6
|
+
export type FailOnCriterion = "effective" | "direct" | "any";
|
|
7
|
+
|
|
8
|
+
export interface I18nAuditReport {
|
|
9
|
+
requiredLocales: string[];
|
|
10
|
+
localeFallback?: Record<string, string | null>;
|
|
11
|
+
summary: Record<string, Record<string, { missingDirect: number; missingEffective: number }>>;
|
|
12
|
+
missingDirectByLocale: Record<string, Record<string, string[]>>;
|
|
13
|
+
missingEffectiveByLocale: Record<string, Record<string, string[]>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AuditDictionariesOptions {
|
|
17
|
+
namespaces: Record<string, DictionaryJson>;
|
|
18
|
+
config: Pick<CodegenConfig, "localeFallback" | "defaultNamespace">;
|
|
19
|
+
treatEmptyAsMissing?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isDirectlyMissing(
|
|
23
|
+
localesByKey: Record<string, string>,
|
|
24
|
+
locale: string,
|
|
25
|
+
treatEmptyAsMissing: boolean
|
|
26
|
+
): boolean {
|
|
27
|
+
const template = localesByKey[locale];
|
|
28
|
+
if (template === undefined) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return treatEmptyAsMissing && template === "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isEffectivelyMissing(
|
|
35
|
+
localesByKey: Record<string, string>,
|
|
36
|
+
locale: string,
|
|
37
|
+
localeFallback: Record<string, string | null> | undefined,
|
|
38
|
+
treatEmptyAsMissing: boolean
|
|
39
|
+
): boolean {
|
|
40
|
+
const resolved = resolveLocaleTemplate(localesByKey, locale, localeFallback);
|
|
41
|
+
if (resolved === undefined) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return treatEmptyAsMissing && resolved.template === "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function collectDictionaryLocales(namespaces: Record<string, DictionaryJson>): Set<string> {
|
|
48
|
+
const locales = new Set<string>();
|
|
49
|
+
|
|
50
|
+
for (const dictionary of Object.values(namespaces)) {
|
|
51
|
+
for (const localesByKey of Object.values(dictionary)) {
|
|
52
|
+
for (const locale of Object.keys(localesByKey)) {
|
|
53
|
+
locales.add(locale);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return locales;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function auditDictionaries(options: AuditDictionariesOptions): I18nAuditReport {
|
|
62
|
+
const { namespaces, config, treatEmptyAsMissing = true } = options;
|
|
63
|
+
const dictionaryLocales = collectDictionaryLocales(namespaces);
|
|
64
|
+
const requiredLocales = buildRequiredLocales(dictionaryLocales, config.localeFallback);
|
|
65
|
+
const localeFallbackForAudit = config.localeFallback
|
|
66
|
+
? enrichLocaleFallback(dictionaryLocales, config.localeFallback)
|
|
67
|
+
: undefined;
|
|
68
|
+
|
|
69
|
+
const summary: I18nAuditReport["summary"] = {};
|
|
70
|
+
const missingDirectByLocale: I18nAuditReport["missingDirectByLocale"] = {};
|
|
71
|
+
const missingEffectiveByLocale: I18nAuditReport["missingEffectiveByLocale"] = {};
|
|
72
|
+
|
|
73
|
+
for (const [namespace, dictionary] of Object.entries(namespaces)) {
|
|
74
|
+
summary[namespace] = {};
|
|
75
|
+
missingDirectByLocale[namespace] = {};
|
|
76
|
+
missingEffectiveByLocale[namespace] = {};
|
|
77
|
+
|
|
78
|
+
for (const locale of requiredLocales) {
|
|
79
|
+
summary[namespace]![locale] = { missingDirect: 0, missingEffective: 0 };
|
|
80
|
+
missingDirectByLocale[namespace]![locale] = [];
|
|
81
|
+
missingEffectiveByLocale[namespace]![locale] = [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const [key, localesByKey] of Object.entries(dictionary)) {
|
|
85
|
+
for (const locale of requiredLocales) {
|
|
86
|
+
if (isDirectlyMissing(localesByKey, locale, treatEmptyAsMissing)) {
|
|
87
|
+
summary[namespace]![locale]!.missingDirect += 1;
|
|
88
|
+
missingDirectByLocale[namespace]![locale]!.push(key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
isEffectivelyMissing(localesByKey, locale, localeFallbackForAudit, treatEmptyAsMissing)
|
|
93
|
+
) {
|
|
94
|
+
summary[namespace]![locale]!.missingEffective += 1;
|
|
95
|
+
missingEffectiveByLocale[namespace]![locale]!.push(key);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const locale of requiredLocales) {
|
|
101
|
+
missingDirectByLocale[namespace]![locale]!.sort();
|
|
102
|
+
missingEffectiveByLocale[namespace]![locale]!.sort();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
requiredLocales,
|
|
108
|
+
localeFallback: localeFallbackForAudit,
|
|
109
|
+
summary,
|
|
110
|
+
missingDirectByLocale,
|
|
111
|
+
missingEffectiveByLocale,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function reportHasGaps(report: I18nAuditReport, criterion: FailOnCriterion): boolean {
|
|
116
|
+
for (const namespace of Object.keys(report.missingDirectByLocale)) {
|
|
117
|
+
for (const locale of report.requiredLocales) {
|
|
118
|
+
const direct = report.missingDirectByLocale[namespace]?.[locale] ?? [];
|
|
119
|
+
const effective = report.missingEffectiveByLocale[namespace]?.[locale] ?? [];
|
|
120
|
+
|
|
121
|
+
if (criterion === "direct" && direct.length > 0) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
if (criterion === "effective" && effective.length > 0) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (criterion === "any" && (direct.length > 0 || effective.length > 0)) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface RunAuditOptions {
|
|
137
|
+
projectRoot: string;
|
|
138
|
+
config: CodegenConfig;
|
|
139
|
+
configPath: string;
|
|
140
|
+
treatEmptyAsMissing?: boolean;
|
|
141
|
+
failOn?: FailOnCriterion;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface RunAuditResult {
|
|
145
|
+
report: I18nAuditReport;
|
|
146
|
+
exitCode: 0 | 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function runAuditFromConfig(options: RunAuditOptions): Promise<RunAuditResult> {
|
|
150
|
+
const { resolveNamespaces } = await import("../codegen/config.js");
|
|
151
|
+
const { readDictionaryFile } = await import("../codegen/read-dictionary.js");
|
|
152
|
+
|
|
153
|
+
const config = options.config;
|
|
154
|
+
const entries = resolveNamespaces(config);
|
|
155
|
+
const namespaces: Record<string, DictionaryJson> = {};
|
|
156
|
+
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
const absolutePath = path.resolve(options.projectRoot, entry.filePath);
|
|
159
|
+
namespaces[entry.namespace] = readDictionaryFile(absolutePath);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const report = auditDictionaries({
|
|
163
|
+
namespaces,
|
|
164
|
+
config,
|
|
165
|
+
treatEmptyAsMissing: options.treatEmptyAsMissing,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const exitCode =
|
|
169
|
+
options.failOn && reportHasGaps(report, options.failOn) ? (1 as const) : (0 as const);
|
|
170
|
+
|
|
171
|
+
return { report, exitCode };
|
|
172
|
+
}
|