@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 CHANGED
@@ -112,7 +112,8 @@ xndrjs-toolkit/
112
112
  ├── packages/
113
113
  │ └── i18n/ # @xndrjs/i18n — the publishable library
114
114
  │ ├── bin/
115
- │ │ └── codegen.mjs # CLI entry: xndrjs-i18n-codegen
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.json"
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 JSON file (flat API). Mutually exclusive with `namespaces`. |
353
- | `namespaces` | Map of `namespace -> JSON path` (namespaced API). Mutually exclusive with `dictionary`. |
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.json"
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 JSON file (any name/path).
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.2.1",
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
+ }