@xndrjs/i18n 0.8.0 → 0.8.2-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +1 -2
  2. package/dist/cli/audit.js +739 -0
  3. package/dist/cli/audit.js.map +1 -0
  4. package/dist/cli/codegen.js +1471 -0
  5. package/dist/cli/codegen.js.map +1 -0
  6. package/dist/cli/setup.js +148 -0
  7. package/dist/cli/setup.js.map +1 -0
  8. package/dist/codegen/index.js +43 -40
  9. package/dist/codegen/index.js.map +1 -1
  10. package/dist/index.d.ts +20 -1
  11. package/dist/index.js +56 -10
  12. package/dist/index.js.map +1 -1
  13. package/package.json +6 -9
  14. package/bin/audit.mjs +0 -14
  15. package/bin/codegen.mjs +0 -17
  16. package/bin/setup.mjs +0 -14
  17. package/src/IcuTranslationProviderMulti.test.ts +0 -215
  18. package/src/IcuTranslationProviderMulti.ts +0 -176
  19. package/src/audit/audit-dictionaries.test.ts +0 -134
  20. package/src/audit/audit-dictionaries.ts +0 -178
  21. package/src/audit/run-audit.test.ts +0 -169
  22. package/src/audit/run-audit.ts +0 -94
  23. package/src/builder-load-registry.ts +0 -39
  24. package/src/builder-loaders.ts +0 -15
  25. package/src/builder-types.test.ts +0 -49
  26. package/src/builder-types.ts +0 -24
  27. package/src/builder.test.ts +0 -260
  28. package/src/builder.ts +0 -10
  29. package/src/codegen/codegen-config-schema.ts +0 -156
  30. package/src/codegen/config.test.ts +0 -231
  31. package/src/codegen/config.ts +0 -42
  32. package/src/codegen/constants.ts +0 -9
  33. package/src/codegen/delivery-artifacts.test.ts +0 -167
  34. package/src/codegen/delivery-artifacts.ts +0 -154
  35. package/src/codegen/dictionary-spec-contract.test.ts +0 -28
  36. package/src/codegen/dictionary-spec-contract.ts +0 -138
  37. package/src/codegen/emit/dictionary-schema-file.ts +0 -113
  38. package/src/codegen/emit/instance-file.test.ts +0 -71
  39. package/src/codegen/emit/instance-file.ts +0 -257
  40. package/src/codegen/emit/namespace-loaders-file.test.ts +0 -251
  41. package/src/codegen/emit/namespace-loaders-file.ts +0 -229
  42. package/src/codegen/emit/types-file.test.ts +0 -96
  43. package/src/codegen/emit/types-file.ts +0 -142
  44. package/src/codegen/fixtures/billing-namespace.json +0 -6
  45. package/src/codegen/generate-i18n-types.test.ts +0 -1137
  46. package/src/codegen/generate-i18n-types.ts +0 -20
  47. package/src/codegen/icu-analysis.ts +0 -110
  48. package/src/codegen/locale-fallback.ts +0 -73
  49. package/src/codegen/locale-policy.test.ts +0 -35
  50. package/src/codegen/locale-policy.ts +0 -30
  51. package/src/codegen/paths.ts +0 -44
  52. package/src/codegen/project-locales-set-namespace.test.ts +0 -134
  53. package/src/codegen/read-dictionary.test.ts +0 -594
  54. package/src/codegen/read-dictionary.ts +0 -260
  55. package/src/codegen/regenerate-namespaces.ts +0 -180
  56. package/src/codegen/run-codegen.test.ts +0 -230
  57. package/src/codegen/run-codegen.ts +0 -252
  58. package/src/codegen/types.ts +0 -10
  59. package/src/codegen/write-file-if-changed.test.ts +0 -42
  60. package/src/codegen/write-file-if-changed.ts +0 -20
  61. package/src/codegen-config/build-config.ts +0 -15
  62. package/src/codegen-config/codegen-config.test.ts +0 -36
  63. package/src/codegen-config/index.ts +0 -37
  64. package/src/codegen-config/type-names.ts +0 -20
  65. package/src/codegen-config/write-config.ts +0 -8
  66. package/src/engine.ts +0 -47
  67. package/src/fetch-artifact.ts +0 -16
  68. package/src/format-core.ts +0 -91
  69. package/src/i18n-handle.ts +0 -202
  70. package/src/icu/extract-variables.test.ts +0 -199
  71. package/src/icu/extract-variables.ts +0 -227
  72. package/src/icu/parse-template.ts +0 -31
  73. package/src/index.ts +0 -58
  74. package/src/project-locales.test.ts +0 -280
  75. package/src/project-locales.ts +0 -211
  76. package/src/resolve-locale.test.ts +0 -91
  77. package/src/resolve-locale.ts +0 -88
  78. package/src/scope-multi.ts +0 -105
  79. package/src/scope-types.ts +0 -35
  80. package/src/scope.test.ts +0 -172
  81. package/src/serialized-state.test.ts +0 -114
  82. package/src/serialized-state.ts +0 -28
  83. package/src/setup/setup-i18n.test.ts +0 -85
  84. package/src/setup/setup-i18n.ts +0 -156
  85. package/src/types.ts +0 -65
  86. package/src/validation/create-args-schema.ts +0 -76
  87. package/src/validation/create-normalized-schema.ts +0 -52
  88. package/src/validation/errors.ts +0 -43
  89. package/src/validation/index.ts +0 -202
  90. package/src/validation/normalize.ts +0 -299
  91. package/src/validation/to-dictionary.ts +0 -67
  92. package/src/validation/types.ts +0 -82
  93. package/src/validation/validate-normalized.ts +0 -133
  94. package/src/validation/validation.test.ts +0 -562
@@ -1,215 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
3
-
4
- type TestSchema = {
5
- default: {
6
- login_button: { en: string; it: string };
7
- welcome: { en: string };
8
- dashboard_status: { en: string; it: string };
9
- inbox_owner: { en: string; it: string };
10
- ranking_position: { en: string; it: string };
11
- };
12
- billing: {
13
- invoice_summary: { en: string; it: string };
14
- account_balance: { en: string; it: string };
15
- appointment_summary: { en: string; it: string };
16
- empty_label: { en: string };
17
- };
18
- };
19
-
20
- type TestParams = {
21
- default: {
22
- login_button: never;
23
- welcome: { name: string };
24
- dashboard_status: { msgCount: number; chatCount: number };
25
- inbox_owner: { gender: string; name: string };
26
- ranking_position: { position: number };
27
- };
28
- billing: {
29
- invoice_summary: { count: number; name: string };
30
- account_balance: { amount: number };
31
- appointment_summary: { dueDate: Date | number; startTime: Date | number };
32
- empty_label: never;
33
- };
34
- };
35
-
36
- const dictionary: TestSchema = {
37
- default: {
38
- login_button: { en: "Login", it: "Accedi" },
39
- welcome: { en: "Welcome {name}!" },
40
- dashboard_status: {
41
- en: "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}",
42
- it: "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
43
- },
44
- inbox_owner: {
45
- en: "{gender, select, female {{name} owns her inbox} male {{name} owns his inbox} other {{name} owns their inbox}}",
46
- it: "{gender, select, female {{name} gestisce la sua casella} male {{name} gestisce la sua casella} other {{name} gestisce la propria casella}}",
47
- },
48
- ranking_position: {
49
- en: "You finished {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
50
- it: "Hai concluso al {position, selectordinal, one {#°} other {#°}} posto",
51
- },
52
- },
53
- billing: {
54
- invoice_summary: {
55
- en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
56
- it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
57
- },
58
- account_balance: {
59
- en: "Balance: {amount, number, ::currency/EUR}",
60
- it: "Saldo: {amount, number, ::currency/EUR}",
61
- },
62
- appointment_summary: {
63
- en: "Due {dueDate, date, short} at {startTime, time, short}",
64
- it: "Scade il {dueDate, date, short} alle {startTime, time, short}",
65
- },
66
- empty_label: { en: "" },
67
- },
68
- };
69
-
70
- describe("IcuTranslationProviderMulti", () => {
71
- const engine = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
72
-
73
- it("tracks namespaces via dictionary keys", () => {
74
- const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
75
- default: dictionary.default,
76
- });
77
-
78
- expect("default" in partial.getAll()).toBe(true);
79
- expect("billing" in partial.getAll()).toBe(false);
80
- });
81
-
82
- it("supports partial initialization", () => {
83
- const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
84
- default: dictionary.default,
85
- });
86
-
87
- expect(partial.toScope({ namespaces: ["default"] }).t("default", "login_button", "en")).toBe(
88
- "Login"
89
- );
90
- });
91
-
92
- it("adds namespace to dictionary with applyLoadMergeNamespace", () => {
93
- const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
94
- default: dictionary.default,
95
- });
96
- expect("billing" in partial.getAll()).toBe(false);
97
-
98
- partial.applyLoadMergeNamespace("billing", dictionary.billing);
99
- expect("billing" in partial.getAll()).toBe(true);
100
- });
101
-
102
- it("adds namespace to dictionary on first applyLoadMergeNamespace", () => {
103
- const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({});
104
- expect("billing" in partial.getAll()).toBe(false);
105
-
106
- partial.applyLoadMergeNamespace("billing", dictionary.billing);
107
- expect("billing" in partial.getAll()).toBe(true);
108
- });
109
-
110
- it("accumulates a locale via applyLoadMergeNamespace when the namespace already exists", () => {
111
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>({
112
- billing: {
113
- invoice_summary: {
114
- en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
115
- },
116
- },
117
- });
118
- const view = () => local.toScope({ namespaces: ["billing"] });
119
-
120
- expect("billing" in local.getAll()).toBe(true);
121
- expect(view().t("billing", "invoice_summary", "en", { count: 1, name: "Ada" })).toBe(
122
- "You have 1 invoice for Ada"
123
- );
124
-
125
- local.applyLoadMergeNamespace("billing", {
126
- invoice_summary: {
127
- it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
128
- },
129
- });
130
-
131
- expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
132
- "You have 2 invoices for Bob"
133
- );
134
- expect(view().t("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
135
- "Hai 2 fatture per Bob"
136
- );
137
- });
138
-
139
- it("merges locales per key with applyLoadMergeNamespace without dropping existing locales", () => {
140
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>({
141
- billing: {
142
- invoice_summary: {
143
- en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
144
- },
145
- },
146
- });
147
- const view = () => local.toScope({ namespaces: ["billing"] });
148
-
149
- local.applyLoadMergeNamespace("billing", {
150
- invoice_summary: {
151
- it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
152
- },
153
- });
154
-
155
- expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
156
- "You have 2 invoices for Bob"
157
- );
158
- expect(view().t("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
159
- "Hai 2 fatture per Bob"
160
- );
161
- });
162
-
163
- it("merges namespaces with applyLoadMergeAll without dropping existing locales", () => {
164
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>({
165
- billing: {
166
- invoice_summary: {
167
- en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
168
- },
169
- },
170
- });
171
- const view = () => local.toScope({ namespaces: ["default", "billing"] });
172
-
173
- local.applyLoadMergeAll({
174
- billing: {
175
- invoice_summary: {
176
- it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
177
- },
178
- },
179
- default: dictionary.default,
180
- });
181
-
182
- expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
183
- "You have 2 invoices for Bob"
184
- );
185
- expect(view().t("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
186
- "Hai 2 fatture per Bob"
187
- );
188
- expect(view().t("default", "login_button", "en")).toBe("Login");
189
- });
190
-
191
- it("returns the live dictionary from getAll (constructor clones the input)", () => {
192
- expect(engine.getAll()).toEqual(dictionary);
193
- expect(engine.getAll()).not.toBe(dictionary);
194
- expect(engine.getAll()).toBe(engine.getAll());
195
- });
196
-
197
- it("invalidates compiled cache when applyLoadMergeNamespace overwrites a key", () => {
198
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
199
- const view = () => local.toScope({ namespaces: ["billing"] });
200
-
201
- expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
202
- "You have 2 invoices for Bob"
203
- );
204
-
205
- local.applyLoadMergeNamespace("billing", {
206
- invoice_summary: {
207
- en: "{count, plural, one {1 bill} other {{count} bills}} for {name}",
208
- },
209
- });
210
-
211
- expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
212
- "2 bills for Bob"
213
- );
214
- });
215
- });
@@ -1,176 +0,0 @@
1
- import { BuilderLoadRegistry, type BuilderResourceEntry } from "./builder-load-registry.js";
2
- import type { I18nEngineMulti } from "./engine.js";
3
- import type { I18nSerializedState } from "./serialized-state.js";
4
- import { resolveAndFormat } from "./format-core.js";
5
- import { mergeNamespaceLocalesCore } from "./project-locales.js";
6
- import { validateLocaleFallback } from "./resolve-locale.js";
7
- import type {
8
- IcuTranslationProviderOptions,
9
- LocaleFallbackMap,
10
- LocaleOfMulti,
11
- MultiCompiledCache,
12
- MultiDictionary,
13
- OnMissingTranslation,
14
- PartialKeyDictionary,
15
- PartialMultiDictionary,
16
- } from "./types.js";
17
- import { I18nScopeMultiForLocaleImpl, I18nScopeMultiImpl } from "./scope-multi.js";
18
- import type { I18nScopeMulti, I18nScopeMultiForLocale } from "./scope-multi.js";
19
- import type { MultiParams, ParamsForNamespaces, SchemaForNamespaces } from "./scope-types.js";
20
-
21
- export class IcuTranslationProviderMulti<
22
- Schema extends MultiDictionary,
23
- Params extends MultiParams<Schema>,
24
- RequestLocales extends string = LocaleOfMulti<Schema>,
25
- Fallback extends LocaleFallbackMap | undefined = undefined,
26
- > implements I18nEngineMulti<Schema, Params, RequestLocales> {
27
- readonly __i18nEngineMode = "multi" as const;
28
-
29
- /** Loaded namespaces only — cold start is `{}`; never claim full Schema until loaded. */
30
- private dictionary: PartialMultiDictionary<Schema, RequestLocales>;
31
- private compiledCache: MultiCompiledCache = {};
32
- private readonly builderLoadRegistry = new BuilderLoadRegistry();
33
- private readonly localeFallback?: Fallback;
34
- private readonly onMissing: OnMissingTranslation;
35
-
36
- constructor(
37
- dictionary: PartialMultiDictionary<Schema, RequestLocales> = {},
38
- options?: IcuTranslationProviderOptions<Fallback>
39
- ) {
40
- this.dictionary = structuredClone(dictionary);
41
- if (options?.localeFallback) {
42
- validateLocaleFallback(options.localeFallback);
43
- this.localeFallback = options.localeFallback;
44
- }
45
- this.onMissing = options?.onMissing ?? "throw";
46
- }
47
-
48
- getWithLocale(
49
- namespace: string,
50
- key: string,
51
- locale: string,
52
- params?: Record<string, unknown>
53
- ): string {
54
- const localeByKey = (
55
- this.dictionary[namespace as keyof Schema] as
56
- | Record<string, Record<string, string>>
57
- | undefined
58
- )?.[key];
59
-
60
- return resolveAndFormat({
61
- localeByKey,
62
- locale,
63
- params,
64
- getCache: (resolvedLocale) => {
65
- if (!this.compiledCache[resolvedLocale]) {
66
- this.compiledCache[resolvedLocale] = {};
67
- }
68
- if (!this.compiledCache[resolvedLocale]![namespace]) {
69
- this.compiledCache[resolvedLocale]![namespace] = {};
70
- }
71
- return this.compiledCache[resolvedLocale]![namespace]!;
72
- },
73
- context: {
74
- namespace,
75
- key,
76
- locale,
77
- localeFallback: this.localeFallback,
78
- onMissing: this.onMissing,
79
- },
80
- });
81
- }
82
-
83
- toScope<
84
- NsList extends readonly (keyof Schema & string)[],
85
- Locale extends RequestLocales,
86
- >(options: {
87
- namespaces: NsList;
88
- locale: Locale;
89
- }): I18nScopeMultiForLocale<
90
- SchemaForNamespaces<Schema, NsList>,
91
- ParamsForNamespaces<Schema, Params, NsList>,
92
- RequestLocales,
93
- Locale
94
- >;
95
- toScope<NsList extends readonly (keyof Schema & string)[]>(options: {
96
- namespaces: NsList;
97
- }): I18nScopeMulti<
98
- SchemaForNamespaces<Schema, NsList>,
99
- ParamsForNamespaces<Schema, Params, NsList>,
100
- RequestLocales
101
- >;
102
- toScope<
103
- NsList extends readonly (keyof Schema & string)[],
104
- Locale extends RequestLocales,
105
- >(options: { namespaces: NsList; locale?: Locale }) {
106
- if (options.locale !== undefined) {
107
- return new I18nScopeMultiForLocaleImpl(this, options.locale) as I18nScopeMultiForLocale<
108
- SchemaForNamespaces<Schema, NsList>,
109
- ParamsForNamespaces<Schema, Params, NsList>,
110
- RequestLocales,
111
- Locale
112
- >;
113
- }
114
- return new I18nScopeMultiImpl(this) as I18nScopeMulti<
115
- SchemaForNamespaces<Schema, NsList>,
116
- ParamsForNamespaces<Schema, Params, NsList>,
117
- RequestLocales
118
- >;
119
- }
120
-
121
- /** Live dictionary reference (loaded namespaces only). Do not mutate. */
122
- getAll(): PartialMultiDictionary<Schema, RequestLocales> {
123
- return this.dictionary;
124
- }
125
-
126
- hasBuilderResourceLoaded(namespace: string, partition: string): boolean {
127
- return this.builderLoadRegistry.has(namespace, partition);
128
- }
129
-
130
- markBuilderResourceLoaded(namespace: string, partition: string): void {
131
- this.builderLoadRegistry.mark(namespace, partition);
132
- }
133
-
134
- seedBuilderResources(resources: readonly BuilderResourceEntry[]): void {
135
- this.builderLoadRegistry.seed(resources);
136
- }
137
-
138
- serialize(): I18nSerializedState<PartialMultiDictionary<Schema, RequestLocales>> {
139
- return {
140
- dictionary: this.dictionary,
141
- resources: this.builderLoadRegistry.entries(),
142
- };
143
- }
144
-
145
- applyLoadMergeNamespace<NS extends keyof Schema>(
146
- namespace: NS,
147
- values: PartialKeyDictionary<Schema[NS], RequestLocales>
148
- ): void {
149
- const existing = (this.dictionary[namespace] ?? {}) as Schema[NS];
150
- const merged = mergeNamespaceLocalesCore(existing, values);
151
-
152
- this.dictionary = {
153
- ...this.dictionary,
154
- [namespace]: merged,
155
- };
156
-
157
- for (const [key, incomingLocales] of Object.entries(values)) {
158
- if (incomingLocales === undefined || typeof incomingLocales !== "object") {
159
- continue;
160
- }
161
-
162
- for (const locale of Object.keys(incomingLocales)) {
163
- delete this.compiledCache[locale]?.[namespace as string]?.[key];
164
- }
165
- }
166
- }
167
-
168
- applyLoadMergeAll(values: PartialMultiDictionary<Schema, RequestLocales>): void {
169
- for (const namespace of Object.keys(values) as (keyof Schema & string)[]) {
170
- const incoming = values[namespace];
171
- if (incoming !== undefined) {
172
- this.applyLoadMergeNamespace(namespace, incoming);
173
- }
174
- }
175
- }
176
- }
@@ -1,134 +0,0 @@
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
- });
@@ -1,178 +0,0 @@
1
- import path from "node:path";
2
- import type { DictionaryJson } from "../codegen/types.js";
3
- import { buildRequiredLocales, enrichLocaleFallback } from "../codegen/locale-policy.js";
4
- import { resolveLocaleTemplate } from "../resolve-locale.js";
5
- import { CodegenConfig } from "../codegen/codegen-config-schema.js";
6
-
7
- export type FailOnCriterion = "effective" | "direct" | "any";
8
-
9
- export interface I18nAuditReport {
10
- requiredLocales: string[];
11
- localeFallback?: Record<string, string | null>;
12
- summary: Record<string, Record<string, { missingDirect: number; missingEffective: number }>>;
13
- missingDirectByLocale: Record<string, Record<string, string[]>>;
14
- missingEffectiveByLocale: Record<string, Record<string, string[]>>;
15
- }
16
-
17
- export interface AuditDictionariesOptions {
18
- namespaces: Record<string, DictionaryJson>;
19
- config: Pick<CodegenConfig, "localeFallback">;
20
- treatEmptyAsMissing?: boolean;
21
- }
22
-
23
- function isDirectlyMissing(
24
- localesByKey: Record<string, string>,
25
- locale: string,
26
- treatEmptyAsMissing: boolean
27
- ): boolean {
28
- const template = localesByKey[locale];
29
- if (template === undefined) {
30
- return true;
31
- }
32
- return treatEmptyAsMissing && template === "";
33
- }
34
-
35
- function isEffectivelyMissing(
36
- localesByKey: Record<string, string>,
37
- locale: string,
38
- localeFallback: Record<string, string | null> | undefined,
39
- treatEmptyAsMissing: boolean
40
- ): boolean {
41
- const resolved = resolveLocaleTemplate(localesByKey, locale, localeFallback);
42
- if (resolved === undefined) {
43
- return true;
44
- }
45
- return treatEmptyAsMissing && resolved.template === "";
46
- }
47
-
48
- function collectDictionaryLocales(namespaces: Record<string, DictionaryJson>): Set<string> {
49
- const locales = new Set<string>();
50
-
51
- for (const dictionary of Object.values(namespaces)) {
52
- for (const localesByKey of Object.values(dictionary)) {
53
- for (const locale of Object.keys(localesByKey)) {
54
- locales.add(locale);
55
- }
56
- }
57
- }
58
-
59
- return locales;
60
- }
61
-
62
- /**
63
- * Audit pipeline: reports missing translations per namespace/locale (direct and
64
- * effective via fallback), using the same locale policy as codegen.
65
- */
66
- export function auditDictionaries(options: AuditDictionariesOptions): I18nAuditReport {
67
- const { namespaces, config, treatEmptyAsMissing = true } = options;
68
- const dictionaryLocales = collectDictionaryLocales(namespaces);
69
- const requiredLocales = buildRequiredLocales(dictionaryLocales, config.localeFallback);
70
- const localeFallbackForAudit = config.localeFallback
71
- ? enrichLocaleFallback(dictionaryLocales, config.localeFallback)
72
- : undefined;
73
-
74
- const summary: I18nAuditReport["summary"] = {};
75
- const missingDirectByLocale: I18nAuditReport["missingDirectByLocale"] = {};
76
- const missingEffectiveByLocale: I18nAuditReport["missingEffectiveByLocale"] = {};
77
-
78
- for (const [namespace, dictionary] of Object.entries(namespaces)) {
79
- summary[namespace] = {};
80
- missingDirectByLocale[namespace] = {};
81
- missingEffectiveByLocale[namespace] = {};
82
-
83
- for (const locale of requiredLocales) {
84
- summary[namespace]![locale] = { missingDirect: 0, missingEffective: 0 };
85
- missingDirectByLocale[namespace]![locale] = [];
86
- missingEffectiveByLocale[namespace]![locale] = [];
87
- }
88
-
89
- for (const [key, localesByKey] of Object.entries(dictionary)) {
90
- for (const locale of requiredLocales) {
91
- if (isDirectlyMissing(localesByKey, locale, treatEmptyAsMissing)) {
92
- summary[namespace]![locale]!.missingDirect += 1;
93
- missingDirectByLocale[namespace]![locale]!.push(key);
94
- }
95
-
96
- if (
97
- isEffectivelyMissing(localesByKey, locale, localeFallbackForAudit, treatEmptyAsMissing)
98
- ) {
99
- summary[namespace]![locale]!.missingEffective += 1;
100
- missingEffectiveByLocale[namespace]![locale]!.push(key);
101
- }
102
- }
103
- }
104
-
105
- for (const locale of requiredLocales) {
106
- missingDirectByLocale[namespace]![locale]!.sort();
107
- missingEffectiveByLocale[namespace]![locale]!.sort();
108
- }
109
- }
110
-
111
- return {
112
- requiredLocales,
113
- ...(localeFallbackForAudit !== undefined ? { localeFallback: localeFallbackForAudit } : {}),
114
- summary,
115
- missingDirectByLocale,
116
- missingEffectiveByLocale,
117
- };
118
- }
119
-
120
- export function reportHasGaps(report: I18nAuditReport, criterion: FailOnCriterion): boolean {
121
- for (const namespace of Object.keys(report.missingDirectByLocale)) {
122
- for (const locale of report.requiredLocales) {
123
- const direct = report.missingDirectByLocale[namespace]?.[locale] ?? [];
124
- const effective = report.missingEffectiveByLocale[namespace]?.[locale] ?? [];
125
-
126
- if (criterion === "direct" && direct.length > 0) {
127
- return true;
128
- }
129
- if (criterion === "effective" && effective.length > 0) {
130
- return true;
131
- }
132
- if (criterion === "any" && (direct.length > 0 || effective.length > 0)) {
133
- return true;
134
- }
135
- }
136
- }
137
-
138
- return false;
139
- }
140
-
141
- export interface RunAuditOptions {
142
- projectRoot: string;
143
- config: CodegenConfig;
144
- treatEmptyAsMissing?: boolean;
145
- failOn?: FailOnCriterion;
146
- }
147
-
148
- export interface RunAuditResult {
149
- report: I18nAuditReport;
150
- exitCode: 0 | 1;
151
- }
152
-
153
- export async function runAuditFromConfig(options: RunAuditOptions): Promise<RunAuditResult> {
154
- const { resolveNamespaces } = await import("../codegen/config.js");
155
- const { readDictionaryFile } = await import("../codegen/read-dictionary.js");
156
-
157
- const config = options.config;
158
- const entries = resolveNamespaces(config);
159
- const namespaces: Record<string, DictionaryJson> = {};
160
-
161
- for (const entry of entries) {
162
- const absolutePath = path.resolve(options.projectRoot, entry.filePath);
163
- namespaces[entry.namespace] = readDictionaryFile(absolutePath);
164
- }
165
-
166
- const report = auditDictionaries({
167
- namespaces,
168
- config,
169
- ...(options.treatEmptyAsMissing !== undefined
170
- ? { treatEmptyAsMissing: options.treatEmptyAsMissing }
171
- : {}),
172
- });
173
-
174
- const exitCode =
175
- options.failOn && reportHasGaps(report, options.failOn) ? (1 as const) : (0 as const);
176
-
177
- return { report, exitCode };
178
- }