@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.
- package/README.md +1 -2
- package/dist/cli/audit.js +739 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/codegen.js +1471 -0
- package/dist/cli/codegen.js.map +1 -0
- package/dist/cli/setup.js +148 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/codegen/index.js +43 -40
- package/dist/codegen/index.js.map +1 -1
- package/dist/index.d.ts +20 -1
- package/dist/index.js +56 -10
- package/dist/index.js.map +1 -1
- package/package.json +6 -9
- package/bin/audit.mjs +0 -14
- package/bin/codegen.mjs +0 -17
- package/bin/setup.mjs +0 -14
- package/src/IcuTranslationProviderMulti.test.ts +0 -215
- package/src/IcuTranslationProviderMulti.ts +0 -176
- package/src/audit/audit-dictionaries.test.ts +0 -134
- package/src/audit/audit-dictionaries.ts +0 -178
- package/src/audit/run-audit.test.ts +0 -169
- package/src/audit/run-audit.ts +0 -94
- package/src/builder-load-registry.ts +0 -39
- package/src/builder-loaders.ts +0 -15
- package/src/builder-types.test.ts +0 -49
- package/src/builder-types.ts +0 -24
- package/src/builder.test.ts +0 -260
- package/src/builder.ts +0 -10
- package/src/codegen/codegen-config-schema.ts +0 -156
- package/src/codegen/config.test.ts +0 -231
- package/src/codegen/config.ts +0 -42
- package/src/codegen/constants.ts +0 -9
- package/src/codegen/delivery-artifacts.test.ts +0 -167
- package/src/codegen/delivery-artifacts.ts +0 -154
- package/src/codegen/dictionary-spec-contract.test.ts +0 -28
- package/src/codegen/dictionary-spec-contract.ts +0 -138
- package/src/codegen/emit/dictionary-schema-file.ts +0 -113
- package/src/codegen/emit/instance-file.test.ts +0 -71
- package/src/codegen/emit/instance-file.ts +0 -257
- package/src/codegen/emit/namespace-loaders-file.test.ts +0 -251
- package/src/codegen/emit/namespace-loaders-file.ts +0 -229
- package/src/codegen/emit/types-file.test.ts +0 -96
- package/src/codegen/emit/types-file.ts +0 -142
- package/src/codegen/fixtures/billing-namespace.json +0 -6
- package/src/codegen/generate-i18n-types.test.ts +0 -1137
- package/src/codegen/generate-i18n-types.ts +0 -20
- package/src/codegen/icu-analysis.ts +0 -110
- package/src/codegen/locale-fallback.ts +0 -73
- package/src/codegen/locale-policy.test.ts +0 -35
- package/src/codegen/locale-policy.ts +0 -30
- package/src/codegen/paths.ts +0 -44
- package/src/codegen/project-locales-set-namespace.test.ts +0 -134
- package/src/codegen/read-dictionary.test.ts +0 -594
- package/src/codegen/read-dictionary.ts +0 -260
- package/src/codegen/regenerate-namespaces.ts +0 -180
- package/src/codegen/run-codegen.test.ts +0 -230
- package/src/codegen/run-codegen.ts +0 -252
- package/src/codegen/types.ts +0 -10
- package/src/codegen/write-file-if-changed.test.ts +0 -42
- package/src/codegen/write-file-if-changed.ts +0 -20
- package/src/codegen-config/build-config.ts +0 -15
- package/src/codegen-config/codegen-config.test.ts +0 -36
- package/src/codegen-config/index.ts +0 -37
- package/src/codegen-config/type-names.ts +0 -20
- package/src/codegen-config/write-config.ts +0 -8
- package/src/engine.ts +0 -47
- package/src/fetch-artifact.ts +0 -16
- package/src/format-core.ts +0 -91
- package/src/i18n-handle.ts +0 -202
- package/src/icu/extract-variables.test.ts +0 -199
- package/src/icu/extract-variables.ts +0 -227
- package/src/icu/parse-template.ts +0 -31
- package/src/index.ts +0 -58
- package/src/project-locales.test.ts +0 -280
- package/src/project-locales.ts +0 -211
- package/src/resolve-locale.test.ts +0 -91
- package/src/resolve-locale.ts +0 -88
- package/src/scope-multi.ts +0 -105
- package/src/scope-types.ts +0 -35
- package/src/scope.test.ts +0 -172
- package/src/serialized-state.test.ts +0 -114
- package/src/serialized-state.ts +0 -28
- package/src/setup/setup-i18n.test.ts +0 -85
- package/src/setup/setup-i18n.ts +0 -156
- package/src/types.ts +0 -65
- package/src/validation/create-args-schema.ts +0 -76
- package/src/validation/create-normalized-schema.ts +0 -52
- package/src/validation/errors.ts +0 -43
- package/src/validation/index.ts +0 -202
- package/src/validation/normalize.ts +0 -299
- package/src/validation/to-dictionary.ts +0 -67
- package/src/validation/types.ts +0 -82
- package/src/validation/validate-normalized.ts +0 -133
- 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
|
-
}
|