@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.
- package/README.md +58 -8
- package/bin/audit.mjs +14 -0
- package/dist/index.d.ts +1 -1
- package/dist/{types-CFWVT2PP.d.ts → types-C1CpXVOJ.d.ts} +7 -1
- package/dist/validation/index.d.ts +3 -2
- package/dist/validation/index.js +125 -28
- package/dist/validation/index.js.map +1 -1
- 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 +496 -0
- package/src/codegen/generate-i18n-types.ts +33 -5
- package/src/codegen/icu-analysis.ts +22 -7
- 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/ensure-namespace.test.ts +1 -1
- package/src/icu/extract-variables.test.ts +199 -0
- package/src/icu/extract-variables.ts +160 -20
- package/src/icu/parse-template.ts +9 -2
- package/src/setup/setup-i18n.ts +7 -4
- package/src/validation/normalize.ts +17 -10
- 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 '
|
|
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 '
|
|
74
|
+
`import type { ${dictionaryTypeName} } from '${typesImport}';\n\n` +
|
|
71
75
|
`export const dictionary: ${dictionaryTypeName} = {\n${objectEntries}\n};\n`
|
|
72
76
|
);
|
|
73
77
|
}
|