@xndrjs/i18n 0.2.0 → 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.
Files changed (33) hide show
  1. package/README.md +58 -8
  2. package/bin/audit.mjs +14 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/{types-CFWVT2PP.d.ts → types-C1CpXVOJ.d.ts} +7 -1
  5. package/dist/validation/index.d.ts +3 -2
  6. package/dist/validation/index.js +125 -28
  7. package/dist/validation/index.js.map +1 -1
  8. package/package.json +5 -3
  9. package/src/IcuTranslationProviderSingle.test.ts +54 -0
  10. package/src/audit/audit-dictionaries.test.ts +134 -0
  11. package/src/audit/audit-dictionaries.ts +172 -0
  12. package/src/audit/run-audit.test.ts +136 -0
  13. package/src/audit/run-audit.ts +81 -0
  14. package/src/codegen/emit/dictionary-file.ts +7 -3
  15. package/src/codegen/emit/dictionary-schema-file.ts +6 -4
  16. package/src/codegen/emit/instance-file.ts +10 -5
  17. package/src/codegen/emit/namespace-loaders-file.ts +10 -6
  18. package/src/codegen/generate-i18n-types.test.ts +496 -0
  19. package/src/codegen/generate-i18n-types.ts +33 -5
  20. package/src/codegen/icu-analysis.ts +22 -7
  21. package/src/codegen/locale-policy.test.ts +35 -0
  22. package/src/codegen/locale-policy.ts +26 -0
  23. package/src/codegen/paths.ts +27 -1
  24. package/src/codegen/read-dictionary.test.ts +104 -0
  25. package/src/codegen/read-dictionary.ts +133 -0
  26. package/src/codegen/types.ts +5 -0
  27. package/src/ensure-namespace.test.ts +1 -1
  28. package/src/icu/extract-variables.test.ts +199 -0
  29. package/src/icu/extract-variables.ts +160 -20
  30. package/src/icu/parse-template.ts +9 -2
  31. package/src/setup/setup-i18n.ts +7 -4
  32. package/src/validation/normalize.ts +17 -10
  33. package/src/validation/validation.test.ts +104 -0
@@ -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
+ }
@@ -0,0 +1,136 @@
1
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { dirname } from "node:path";
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+ import { parseAuditArgs, runAuditCli } from "./run-audit.js";
9
+
10
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
11
+ const auditBin = join(packageRoot, "bin/audit.mjs");
12
+
13
+ describe("parseAuditArgs", () => {
14
+ it("defaults to report-only mode without --fail-on", () => {
15
+ expect(parseAuditArgs([])).toEqual({
16
+ configPath: join(process.cwd(), "i18n/i18n.codegen.json"),
17
+ outPath: undefined,
18
+ failOn: undefined,
19
+ treatEmptyAsMissing: true,
20
+ });
21
+ });
22
+
23
+ it("parses fail-on and allow-empty", () => {
24
+ const parsed = parseAuditArgs([
25
+ "--config",
26
+ "cfg.json",
27
+ "--fail-on",
28
+ "effective",
29
+ "--allow-empty",
30
+ ]);
31
+ expect(parsed.failOn).toBe("effective");
32
+ expect(parsed.treatEmptyAsMissing).toBe(false);
33
+ expect(parsed.configPath).toBe(join(process.cwd(), "cfg.json"));
34
+ });
35
+ });
36
+
37
+ describe("runAuditCli", () => {
38
+ let tempDir = "";
39
+
40
+ afterEach(() => {
41
+ if (tempDir) {
42
+ rmSync(tempDir, { recursive: true, force: true });
43
+ tempDir = "";
44
+ }
45
+ });
46
+
47
+ function writeFixture(dictionary: Record<string, Record<string, string>>) {
48
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-audit-"));
49
+ mkdirSync(join(tempDir, "i18n/translations"), { recursive: true });
50
+ writeFileSync(join(tempDir, "i18n/translations/translations.json"), JSON.stringify(dictionary));
51
+ writeFileSync(
52
+ join(tempDir, "i18n/i18n.codegen.json"),
53
+ JSON.stringify({
54
+ dictionary: "translations/translations.json",
55
+ typesOutput: "generated/i18n-types.generated.ts",
56
+ dictionaryOutput: "generated/dictionary.generated.ts",
57
+ instanceOutput: "generated/instance.generated.ts",
58
+ paramsTypeName: "AppParams",
59
+ schemaTypeName: "AppSchema",
60
+ localeFallback: {
61
+ en: null,
62
+ it: "en",
63
+ },
64
+ })
65
+ );
66
+ }
67
+
68
+ it("exits 0 without --fail-on even when effective gaps exist", async () => {
69
+ writeFixture({
70
+ welcome: { en: "Welcome" },
71
+ });
72
+
73
+ const previousCwd = process.cwd();
74
+ process.chdir(tempDir);
75
+ try {
76
+ const exitCode = await runAuditCli(["--config", "i18n/i18n.codegen.json"]);
77
+ expect(exitCode).toBe(0);
78
+ } finally {
79
+ process.chdir(previousCwd);
80
+ }
81
+ });
82
+
83
+ it("exits 1 with --fail-on effective when effective gaps exist", async () => {
84
+ writeFixture({
85
+ login_button: { it: "Accedi" },
86
+ });
87
+
88
+ const previousCwd = process.cwd();
89
+ process.chdir(tempDir);
90
+ try {
91
+ const exitCode = await runAuditCli([
92
+ "--config",
93
+ "i18n/i18n.codegen.json",
94
+ "--fail-on",
95
+ "effective",
96
+ ]);
97
+ expect(exitCode).toBe(1);
98
+ } finally {
99
+ process.chdir(previousCwd);
100
+ }
101
+ });
102
+
103
+ it("writes JSON report to --out", async () => {
104
+ writeFixture({
105
+ login_button: { en: "Login", it: "Accedi" },
106
+ });
107
+
108
+ const outPath = join(tempDir, "audit.json");
109
+ const previousCwd = process.cwd();
110
+ process.chdir(tempDir);
111
+ try {
112
+ const exitCode = await runAuditCli(["--config", "i18n/i18n.codegen.json", "--out", outPath]);
113
+ expect(exitCode).toBe(0);
114
+ const report = JSON.parse(readFileSync(outPath, "utf8"));
115
+ expect(report.requiredLocales).toEqual(["en", "it"]);
116
+ expect(report.missingEffectiveByLocale.default.en).toEqual([]);
117
+ } finally {
118
+ process.chdir(previousCwd);
119
+ }
120
+ });
121
+
122
+ it("runs through the bin wrapper", () => {
123
+ writeFixture({
124
+ login_button: { en: "Login" },
125
+ });
126
+
127
+ const result = spawnSync("node", [auditBin, "--config", "i18n/i18n.codegen.json"], {
128
+ cwd: tempDir,
129
+ encoding: "utf8",
130
+ });
131
+
132
+ expect(result.status).toBe(0);
133
+ const report = JSON.parse(result.stdout);
134
+ expect(report.requiredLocales).toEqual(["en", "it"]);
135
+ });
136
+ });
@@ -0,0 +1,81 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { loadConfig } from "../codegen/config.js";
4
+ import { fail } from "../codegen/paths.js";
5
+ import { type FailOnCriterion, runAuditFromConfig } from "./audit-dictionaries.js";
6
+
7
+ const FAIL_ON_VALUES = new Set<FailOnCriterion>(["effective", "direct", "any"]);
8
+
9
+ export interface AuditCliOptions {
10
+ configPath: string;
11
+ outPath?: string;
12
+ failOn?: FailOnCriterion;
13
+ treatEmptyAsMissing: boolean;
14
+ }
15
+
16
+ export function parseAuditArgs(argv: string[]): AuditCliOptions {
17
+ const configArgIndex = argv.indexOf("--config");
18
+ const outArgIndex = argv.indexOf("--out");
19
+ const failOnArgIndex = argv.indexOf("--fail-on");
20
+
21
+ const configPath = path.resolve(
22
+ process.cwd(),
23
+ configArgIndex >= 0 ? argv[configArgIndex + 1]! : "i18n/i18n.codegen.json"
24
+ );
25
+
26
+ const outPath =
27
+ outArgIndex >= 0 ? path.resolve(process.cwd(), argv[outArgIndex + 1]!) : undefined;
28
+
29
+ let failOn: FailOnCriterion | undefined;
30
+ if (failOnArgIndex >= 0) {
31
+ const value = argv[failOnArgIndex + 1];
32
+ if (!value || !FAIL_ON_VALUES.has(value as FailOnCriterion)) {
33
+ fail(`[Audit Error] --fail-on must be one of: effective, direct, any`);
34
+ }
35
+ failOn = value as FailOnCriterion;
36
+ }
37
+
38
+ const treatEmptyAsMissing = !argv.includes("--allow-empty");
39
+
40
+ return { configPath, outPath, failOn, treatEmptyAsMissing };
41
+ }
42
+
43
+ export async function runAuditCli(argv = process.argv.slice(2)): Promise<number> {
44
+ const options = parseAuditArgs(argv);
45
+
46
+ if (!fs.existsSync(options.configPath)) {
47
+ console.error(`[Audit Error] Config file not found: ${options.configPath}`);
48
+ return 2;
49
+ }
50
+
51
+ const projectRoot = path.dirname(options.configPath);
52
+ const config = loadConfig(options.configPath);
53
+
54
+ const { report, exitCode } = await runAuditFromConfig({
55
+ projectRoot,
56
+ config,
57
+ configPath: options.configPath,
58
+ treatEmptyAsMissing: options.treatEmptyAsMissing,
59
+ failOn: options.failOn,
60
+ });
61
+
62
+ const json = `${JSON.stringify(report, null, 2)}\n`;
63
+
64
+ if (options.outPath) {
65
+ fs.mkdirSync(path.dirname(options.outPath), { recursive: true });
66
+ fs.writeFileSync(options.outPath, json);
67
+ } else {
68
+ process.stdout.write(json);
69
+ }
70
+
71
+ return exitCode;
72
+ }
73
+
74
+ async function main(): Promise<void> {
75
+ const exitCode = await runAuditCli();
76
+ process.exit(exitCode);
77
+ }
78
+
79
+ if (process.argv[1]?.endsWith("run-audit.ts")) {
80
+ void main();
81
+ }
@@ -4,8 +4,9 @@ import {
4
4
  toImportIdentifier,
5
5
  toImportPath,
6
6
  toModuleBasename,
7
+ toRelativeModuleImport,
7
8
  } from "../paths.js";
8
- import type { NamespaceEntry } from "../types.js";
9
+ import type { ImportExtension, NamespaceEntry } from "../types.js";
9
10
 
10
11
  export interface DictionaryFileOptions {
11
12
  isSingle: boolean;
@@ -16,6 +17,7 @@ export interface DictionaryFileOptions {
16
17
  dictionaryOutputPath: string;
17
18
  typesOutputPath: string;
18
19
  schemaTypeName: string;
20
+ importExtension: ImportExtension;
19
21
  }
20
22
 
21
23
  export function formatDictionaryFile(options: DictionaryFileOptions): string {
@@ -28,9 +30,11 @@ export function formatDictionaryFile(options: DictionaryFileOptions): string {
28
30
  dictionaryOutputPath,
29
31
  typesOutputPath,
30
32
  schemaTypeName,
33
+ importExtension,
31
34
  } = options;
32
35
 
33
36
  const typesModule = toModuleBasename(typesOutputPath);
37
+ const typesImport = toRelativeModuleImport(typesModule, importExtension);
34
38
 
35
39
  if (isSingle) {
36
40
  const entry = entries[0]!;
@@ -43,7 +47,7 @@ export function formatDictionaryFile(options: DictionaryFileOptions): string {
43
47
  return (
44
48
  `${GENERATED_FILE_BANNER}` +
45
49
  `import ${importId} from '${importPath}.json';\n` +
46
- `import type { ${schemaTypeName} } from './${typesModule}.js';\n\n` +
50
+ `import type { ${schemaTypeName} } from '${typesImport}';\n\n` +
47
51
  `export const dictionary: ${schemaTypeName} = ${importId};\n`
48
52
  );
49
53
  }
@@ -67,7 +71,7 @@ export function formatDictionaryFile(options: DictionaryFileOptions): string {
67
71
  return (
68
72
  `${GENERATED_FILE_BANNER}` +
69
73
  `${imports}\n` +
70
- `import type { ${dictionaryTypeName} } from './${typesModule}.js';\n\n` +
74
+ `import type { ${dictionaryTypeName} } from '${typesImport}';\n\n` +
71
75
  `export const dictionary: ${dictionaryTypeName} = {\n${objectEntries}\n};\n`
72
76
  );
73
77
  }