@xndrjs/i18n 0.6.0 → 0.7.0-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 +154 -134
- package/dist/codegen/index.js.map +1 -1
- package/dist/index.d.ts +276 -61
- package/dist/index.js +687 -126
- package/dist/index.js.map +1 -1
- package/dist/validation/index.d.ts +23 -7
- package/dist/validation/index.js +196 -89
- package/dist/validation/index.js.map +1 -1
- package/package.json +1 -1
- package/src/IcuTranslationProviderMulti.test.ts +115 -215
- package/src/IcuTranslationProviderMulti.ts +107 -96
- package/src/IcuTranslationProviderSingle.test.ts +36 -294
- package/src/IcuTranslationProviderSingle.ts +55 -72
- package/src/builder-load-registry.ts +18 -0
- package/src/builder-loaders.ts +18 -0
- package/src/builder-multi.ts +481 -0
- package/src/builder-types.test.ts +80 -0
- package/src/builder-types.ts +24 -0
- package/src/builder.test.ts +421 -0
- package/src/builder.ts +112 -0
- package/src/codegen/delivery-artifacts.test.ts +2 -1
- package/src/codegen/delivery-artifacts.ts +2 -1
- package/src/codegen/emit/dictionary-file.test.ts +0 -7
- package/src/codegen/emit/dictionary-schema-file.ts +55 -42
- package/src/codegen/emit/instance-file.test.ts +88 -0
- package/src/codegen/emit/instance-file.ts +164 -17
- package/src/codegen/emit/namespace-loaders-file.test.ts +35 -10
- package/src/codegen/emit/namespace-loaders-file.ts +11 -61
- package/src/codegen/emit/types-file.test.ts +0 -2
- package/src/codegen/generate-i18n-types.test.ts +8 -303
- package/src/codegen/generate-i18n-types.ts +16 -1
- package/src/codegen/project-locales-set-namespace.test.ts +25 -32
- package/src/engine.ts +71 -0
- package/src/index.ts +47 -7
- package/src/patch-key.test.ts +186 -0
- package/src/patch-key.ts +140 -0
- package/src/project-locales.test.ts +76 -0
- package/src/project-locales.ts +58 -1
- package/src/scope-multi.ts +124 -0
- package/src/scope-single.ts +85 -0
- package/src/scope-types.ts +27 -0
- package/src/scope.test.ts +481 -0
- package/src/single-builder.ts +153 -0
- package/src/types.ts +16 -0
- package/src/validation/create-normalized-schema.ts +1 -1
- package/src/validation/errors.ts +2 -0
- package/src/validation/index.ts +135 -27
- package/src/validation/normalize.ts +176 -46
- package/src/validation/types.ts +4 -0
- package/src/validation/validate-normalized.ts +43 -1
- package/src/validation/validation.test.ts +200 -7
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
|
|
3
|
+
import { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
|
|
4
|
+
import type { LocaleOfMulti, LocaleOfSingle } from "./types.js";
|
|
5
|
+
|
|
6
|
+
type SingleSchema = {
|
|
7
|
+
login_button: { en: string; it: string };
|
|
8
|
+
welcome: { en: string; it: string };
|
|
9
|
+
empty_label: { en: string };
|
|
10
|
+
broken: { en: string };
|
|
11
|
+
invoice_count: { en: string; it: string };
|
|
12
|
+
dashboard_status: { en: string; it: string };
|
|
13
|
+
inbox_owner: { en: string; it: string };
|
|
14
|
+
ranking_position: { en: string; it: string };
|
|
15
|
+
account_balance: { en: string; it: string };
|
|
16
|
+
appointment_summary: { en: string; it: string };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type SingleParams = {
|
|
20
|
+
login_button: never;
|
|
21
|
+
welcome: { name: string };
|
|
22
|
+
empty_label: never;
|
|
23
|
+
broken: { name: string };
|
|
24
|
+
invoice_count: { count: number };
|
|
25
|
+
dashboard_status: { msgCount: number; chatCount: number };
|
|
26
|
+
inbox_owner: { gender: string; name: string };
|
|
27
|
+
ranking_position: { position: number };
|
|
28
|
+
account_balance: { amount: number };
|
|
29
|
+
appointment_summary: { dueDate: Date | number; startTime: Date | number };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const singleDictionary: SingleSchema = {
|
|
33
|
+
login_button: { en: "Login", it: "Accedi" },
|
|
34
|
+
welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
|
|
35
|
+
empty_label: { en: "" },
|
|
36
|
+
broken: { en: "Hi {name" },
|
|
37
|
+
invoice_count: {
|
|
38
|
+
en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
|
|
39
|
+
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
|
|
40
|
+
},
|
|
41
|
+
dashboard_status: {
|
|
42
|
+
en: "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}",
|
|
43
|
+
it: "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
|
|
44
|
+
},
|
|
45
|
+
inbox_owner: {
|
|
46
|
+
en: "{gender, select, female {{name} owns her inbox} male {{name} owns his inbox} other {{name} owns their inbox}}",
|
|
47
|
+
it: "{gender, select, other {{name} gestisce la propria casella}}",
|
|
48
|
+
},
|
|
49
|
+
ranking_position: {
|
|
50
|
+
en: "You finished {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
|
|
51
|
+
it: "Hai concluso al {position, selectordinal, one {#°} other {#°}} posto",
|
|
52
|
+
},
|
|
53
|
+
account_balance: {
|
|
54
|
+
en: "Balance: {amount, number, ::currency/EUR}",
|
|
55
|
+
it: "Saldo: {amount, number, ::currency/EUR}",
|
|
56
|
+
},
|
|
57
|
+
appointment_summary: {
|
|
58
|
+
en: "Due {dueDate, date, short} at {startTime, time, short}",
|
|
59
|
+
it: "Scade il {dueDate, date, short} alle {startTime, time, short}",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type MultiSchema = {
|
|
64
|
+
default: {
|
|
65
|
+
login_button: { en: string; it: string };
|
|
66
|
+
welcome: { en: string };
|
|
67
|
+
dashboard_status: { en: string; it: string };
|
|
68
|
+
inbox_owner: { en: string; it: string };
|
|
69
|
+
ranking_position: { en: string; it: string };
|
|
70
|
+
};
|
|
71
|
+
billing: {
|
|
72
|
+
invoice_summary: { en: string; it: string };
|
|
73
|
+
account_balance: { en: string; it: string };
|
|
74
|
+
appointment_summary: { en: string; it: string };
|
|
75
|
+
empty_label: { en: string };
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type MultiParams = {
|
|
80
|
+
default: {
|
|
81
|
+
login_button: never;
|
|
82
|
+
welcome: { name: string };
|
|
83
|
+
dashboard_status: { msgCount: number; chatCount: number };
|
|
84
|
+
inbox_owner: { gender: string; name: string };
|
|
85
|
+
ranking_position: { position: number };
|
|
86
|
+
};
|
|
87
|
+
billing: {
|
|
88
|
+
invoice_summary: { count: number; name: string };
|
|
89
|
+
account_balance: { amount: number };
|
|
90
|
+
appointment_summary: { dueDate: Date | number; startTime: Date | number };
|
|
91
|
+
empty_label: never;
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const multiDictionary: MultiSchema = {
|
|
96
|
+
default: {
|
|
97
|
+
login_button: { en: "Login", it: "Accedi" },
|
|
98
|
+
welcome: { en: "Welcome {name}!" },
|
|
99
|
+
dashboard_status: {
|
|
100
|
+
en: "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}",
|
|
101
|
+
it: "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
|
|
102
|
+
},
|
|
103
|
+
inbox_owner: {
|
|
104
|
+
en: "{gender, select, female {{name} owns her inbox} male {{name} owns his inbox} other {{name} owns their inbox}}",
|
|
105
|
+
it: "{gender, select, female {{name} gestisce la sua casella} male {{name} gestisce la sua casella} other {{name} gestisce la propria casella}}",
|
|
106
|
+
},
|
|
107
|
+
ranking_position: {
|
|
108
|
+
en: "You finished {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
|
|
109
|
+
it: "Hai concluso al {position, selectordinal, one {#°} other {#°}} posto",
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
billing: {
|
|
113
|
+
invoice_summary: {
|
|
114
|
+
en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
|
|
115
|
+
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
|
|
116
|
+
},
|
|
117
|
+
account_balance: {
|
|
118
|
+
en: "Balance: {amount, number, ::currency/EUR}",
|
|
119
|
+
it: "Saldo: {amount, number, ::currency/EUR}",
|
|
120
|
+
},
|
|
121
|
+
appointment_summary: {
|
|
122
|
+
en: "Due {dueDate, date, short} at {startTime, time, short}",
|
|
123
|
+
it: "Scade il {dueDate, date, short} alle {startTime, time, short}",
|
|
124
|
+
},
|
|
125
|
+
empty_label: { en: "" },
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
describe("I18nScope single", () => {
|
|
130
|
+
const engine = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary);
|
|
131
|
+
const view = engine.toScope();
|
|
132
|
+
|
|
133
|
+
it("translates with t(key, locale)", () => {
|
|
134
|
+
expect(view.t("login_button", "it")).toBe("Accedi");
|
|
135
|
+
expect(view.t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("formats ICU plurals and nested plurals", () => {
|
|
139
|
+
expect(view.t("invoice_count", "en", { count: 1 })).toBe("You have 1 invoice");
|
|
140
|
+
expect(view.t("invoice_count", "en", { count: 5 })).toBe("You have 5 invoices");
|
|
141
|
+
expect(view.t("dashboard_status", "en", { msgCount: 3, chatCount: 2 })).toBe(
|
|
142
|
+
"You have 3 messages in 2 chats"
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("binds locale via forLocale", () => {
|
|
147
|
+
const en = view.forLocale("en");
|
|
148
|
+
|
|
149
|
+
expect(en.locale).toBe("en");
|
|
150
|
+
expect(en.t("login_button")).toBe("Login");
|
|
151
|
+
expect(en.t("welcome", { name: "Ada" })).toBe("Welcome Ada!");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("binds locale via toScope({ locale })", () => {
|
|
155
|
+
const it = engine.toScope({ locale: "it" });
|
|
156
|
+
|
|
157
|
+
expect(it.locale).toBe("it");
|
|
158
|
+
expect(it.t("login_button")).toBe("Accedi");
|
|
159
|
+
expect(it.t("welcome", { name: "Ada" })).toBe("Benvenuto Ada!");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("supports destructuring t and set from locale-bound scopes", () => {
|
|
163
|
+
const { t, set } = engine.toScope().forLocale("en");
|
|
164
|
+
|
|
165
|
+
expect(t("login_button")).toBe("Login");
|
|
166
|
+
set("login_button", "Sign in");
|
|
167
|
+
expect(t("login_button")).toBe("Sign in");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("throws when key or locale is missing", () => {
|
|
171
|
+
expect(() => view.t("login_button", "fr" as unknown as LocaleOfSingle<SingleSchema>)).toThrow(
|
|
172
|
+
'[i18n] Missing key or locale: "login_button" [fr] (fallback chain: fr)'
|
|
173
|
+
);
|
|
174
|
+
expect(() => view.t("missing_key" as "login_button", "en")).toThrow(
|
|
175
|
+
'[i18n] Missing key or locale: "missing_key" [en] (fallback chain: en)'
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("locale fallback", () => {
|
|
180
|
+
const fallbackMap = {
|
|
181
|
+
en: null,
|
|
182
|
+
"de-DE": "en",
|
|
183
|
+
"de-CH": "de-DE",
|
|
184
|
+
it: "en",
|
|
185
|
+
} as const;
|
|
186
|
+
|
|
187
|
+
const fallbackEngine = new IcuTranslationProviderSingle<
|
|
188
|
+
SingleSchema,
|
|
189
|
+
SingleParams,
|
|
190
|
+
keyof typeof fallbackMap | LocaleOfSingle<SingleSchema>,
|
|
191
|
+
typeof fallbackMap
|
|
192
|
+
>(singleDictionary, { localeFallback: fallbackMap });
|
|
193
|
+
const fallbackView = fallbackEngine.toScope();
|
|
194
|
+
|
|
195
|
+
it("resolves a locale through the fallback chain", () => {
|
|
196
|
+
expect(fallbackView.t("login_button", "de-CH")).toBe("Login");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("onMissing", () => {
|
|
201
|
+
it('returns the key itself with onMissing: "key"', () => {
|
|
202
|
+
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary, {
|
|
203
|
+
onMissing: "key",
|
|
204
|
+
});
|
|
205
|
+
expect(local.toScope().t("missing_key" as "login_button", "en")).toBe("missing_key");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("applies onMissing through forLocale wrappers", () => {
|
|
209
|
+
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary, {
|
|
210
|
+
onMissing: "key",
|
|
211
|
+
});
|
|
212
|
+
expect(
|
|
213
|
+
local
|
|
214
|
+
.toScope()
|
|
215
|
+
.forLocale("en")
|
|
216
|
+
.t("missing_key" as "login_button")
|
|
217
|
+
).toBe("missing_key");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("set()", () => {
|
|
222
|
+
it("patches a preloaded key after load merge", () => {
|
|
223
|
+
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
|
|
224
|
+
login_button: { en: "Login", it: "Accedi" },
|
|
225
|
+
welcome: { en: "Welcome {name}!", it: "benvenuto {name}..." },
|
|
226
|
+
empty_label: { en: "" },
|
|
227
|
+
broken: { en: "Hi {name" },
|
|
228
|
+
invoice_count: singleDictionary.invoice_count,
|
|
229
|
+
dashboard_status: singleDictionary.dashboard_status,
|
|
230
|
+
inbox_owner: singleDictionary.inbox_owner,
|
|
231
|
+
ranking_position: singleDictionary.ranking_position,
|
|
232
|
+
account_balance: singleDictionary.account_balance,
|
|
233
|
+
appointment_summary: singleDictionary.appointment_summary,
|
|
234
|
+
});
|
|
235
|
+
local.applyLoadMergeSingle({
|
|
236
|
+
welcome: { it: "Benvenuto {name}!" },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const enScope = local.toScope().forLocale("en");
|
|
240
|
+
enScope.set("login_button", "Sign in");
|
|
241
|
+
|
|
242
|
+
const itScope = local.toScope().forLocale("it");
|
|
243
|
+
|
|
244
|
+
expect(enScope.t("login_button")).toBe("Sign in");
|
|
245
|
+
expect(itScope.t("login_button")).toBe("Accedi");
|
|
246
|
+
|
|
247
|
+
expect(itScope.t("welcome", { name: "Mario" })).toBe("Benvenuto Mario!");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("throws when patching a key that was not preloaded", () => {
|
|
251
|
+
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
|
|
252
|
+
// @ts-expect-error missing it
|
|
253
|
+
login_button: { en: "Login" },
|
|
254
|
+
welcome: { en: "Welcome {name}!", it: "" },
|
|
255
|
+
empty_label: { en: "" },
|
|
256
|
+
broken: { en: "Hi {name" },
|
|
257
|
+
invoice_count: singleDictionary.invoice_count,
|
|
258
|
+
dashboard_status: singleDictionary.dashboard_status,
|
|
259
|
+
inbox_owner: singleDictionary.inbox_owner,
|
|
260
|
+
ranking_position: singleDictionary.ranking_position,
|
|
261
|
+
account_balance: singleDictionary.account_balance,
|
|
262
|
+
appointment_summary: singleDictionary.appointment_summary,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(() => local.toScope().forLocale("it").set("login_button", "Accedi")).toThrow(
|
|
266
|
+
"[i18n] Key not preloaded: login_button (it)"
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("invalidates compiled cache after set()", () => {
|
|
271
|
+
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary);
|
|
272
|
+
const en = local.toScope().forLocale("en");
|
|
273
|
+
|
|
274
|
+
expect(en.t("welcome", { name: "Ada" })).toBe("Welcome Ada!");
|
|
275
|
+
|
|
276
|
+
en.set("welcome", "Hi {name}!");
|
|
277
|
+
|
|
278
|
+
expect(en.t("welcome", { name: "Ada" })).toBe("Hi Ada!");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("throws on ICU args mismatch against other locales", () => {
|
|
282
|
+
const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary);
|
|
283
|
+
const en = local.toScope().forLocale("en");
|
|
284
|
+
|
|
285
|
+
expect(() => en.set("invoice_count", "{count, select, other {{count}}}")).toThrow(
|
|
286
|
+
"[i18n] ICU args mismatch on patch:"
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("I18nScope multi", () => {
|
|
293
|
+
const engine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(multiDictionary);
|
|
294
|
+
|
|
295
|
+
it("translates with t(namespace, key, locale)", () => {
|
|
296
|
+
const view = engine.toScope({ namespaces: ["default", "billing"] });
|
|
297
|
+
|
|
298
|
+
expect(view.t("default", "login_button", "it")).toBe("Accedi");
|
|
299
|
+
expect(view.t("default", "welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
|
|
300
|
+
expect(view.t("billing", "invoice_summary", "en", { count: 3, name: "Ada" })).toBe(
|
|
301
|
+
"You have 3 invoices for Ada"
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("restricts namespaces at the type level", () => {
|
|
306
|
+
const billingOnly = engine.toScope({ namespaces: ["billing"] });
|
|
307
|
+
|
|
308
|
+
expect(billingOnly.t("billing", "invoice_summary", "en", { count: 1, name: "Ada" })).toBe(
|
|
309
|
+
"You have 1 invoice for Ada"
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("binds locale via forLocale", () => {
|
|
314
|
+
const view = engine.toScope({ namespaces: ["default", "billing"] });
|
|
315
|
+
const it = view.forLocale("it");
|
|
316
|
+
|
|
317
|
+
expect(it.locale).toBe("it");
|
|
318
|
+
expect(it.t("default", "login_button")).toBe("Accedi");
|
|
319
|
+
expect(it.t("billing", "invoice_summary", { count: 2, name: "Ada" })).toBe(
|
|
320
|
+
"Hai 2 fatture per Ada"
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("binds locale via toScope({ namespaces, locale })", () => {
|
|
325
|
+
const it = engine.toScope({ namespaces: ["billing"], locale: "it" });
|
|
326
|
+
expect(it.locale).toBe("it");
|
|
327
|
+
expect(it.t("billing", "invoice_summary", { count: 2, name: "Ada" })).toBe(
|
|
328
|
+
"Hai 2 fatture per Ada"
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("supports destructuring t and set from locale-bound scopes", () => {
|
|
333
|
+
const { t, set } = engine.toScope({ namespaces: ["billing"] }).forLocale("en");
|
|
334
|
+
|
|
335
|
+
expect(t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
|
|
336
|
+
"You have 1 invoice for Ada"
|
|
337
|
+
);
|
|
338
|
+
set(
|
|
339
|
+
"billing",
|
|
340
|
+
"invoice_summary",
|
|
341
|
+
"You have {count, plural, one {1 invoice only} other {{count} invoices only}} for {name}"
|
|
342
|
+
);
|
|
343
|
+
expect(t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
|
|
344
|
+
"You have 1 invoice only for Ada"
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("reflects partial applyLoadMergeNamespace via a fresh toScope", () => {
|
|
349
|
+
const partial = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({
|
|
350
|
+
billing: {
|
|
351
|
+
invoice_summary: {
|
|
352
|
+
en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
partial.applyLoadMergeNamespace("billing", {
|
|
358
|
+
invoice_summary: {
|
|
359
|
+
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const view = partial.toScope({ namespaces: ["billing"], locale: "it" });
|
|
364
|
+
expect(view.t("billing", "invoice_summary", { count: 2, name: "Bob" })).toBe(
|
|
365
|
+
"Hai 2 fatture per Bob"
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("degrades to onMissing for namespaces not in dictionary", () => {
|
|
370
|
+
const partial = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(
|
|
371
|
+
{ default: multiDictionary.default },
|
|
372
|
+
{ onMissing: "key" }
|
|
373
|
+
);
|
|
374
|
+
const view = partial.toScope({ namespaces: ["billing"], locale: "en" });
|
|
375
|
+
|
|
376
|
+
expect(view.t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
|
|
377
|
+
"billing.invoice_summary"
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("locale fallback", () => {
|
|
382
|
+
const fallbackMap = {
|
|
383
|
+
en: null,
|
|
384
|
+
"de-DE": "en",
|
|
385
|
+
"de-CH": "de-DE",
|
|
386
|
+
it: "en",
|
|
387
|
+
} as const;
|
|
388
|
+
|
|
389
|
+
const fallbackEngine = new IcuTranslationProviderMulti<
|
|
390
|
+
MultiSchema,
|
|
391
|
+
MultiParams,
|
|
392
|
+
keyof typeof fallbackMap | LocaleOfMulti<MultiSchema>,
|
|
393
|
+
typeof fallbackMap
|
|
394
|
+
>(multiDictionary, { localeFallback: fallbackMap });
|
|
395
|
+
|
|
396
|
+
it("resolves a locale through the fallback chain", () => {
|
|
397
|
+
const view = fallbackEngine.toScope({ namespaces: ["default"] });
|
|
398
|
+
expect(view.t("default", "login_button", "de-CH")).toBe("Login");
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("set()", () => {
|
|
403
|
+
it("patches a preloaded key after load merge", () => {
|
|
404
|
+
const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({
|
|
405
|
+
billing: {
|
|
406
|
+
invoice_summary: {
|
|
407
|
+
en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
local.applyLoadMergeNamespace("billing", {
|
|
412
|
+
invoice_summary: {
|
|
413
|
+
it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const en = local.toScope({ namespaces: ["billing"] }).forLocale("en");
|
|
418
|
+
en.set(
|
|
419
|
+
"billing",
|
|
420
|
+
"invoice_summary",
|
|
421
|
+
"You have {count, plural, one {1 invoice only} other {{count} invoices only}} for {name}"
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
expect(en.t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
|
|
425
|
+
"You have 1 invoice only for Ada"
|
|
426
|
+
);
|
|
427
|
+
expect(
|
|
428
|
+
local
|
|
429
|
+
.toScope({ namespaces: ["billing"], locale: "it" })
|
|
430
|
+
.t("billing", "invoice_summary", { count: 2, name: "Bob" })
|
|
431
|
+
).toBe("Hai 2 fatture per Bob");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("throws when patching a key that was not preloaded", () => {
|
|
435
|
+
const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({
|
|
436
|
+
billing: {
|
|
437
|
+
invoice_summary: {
|
|
438
|
+
en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
expect(() =>
|
|
444
|
+
local
|
|
445
|
+
.toScope({ namespaces: ["billing"] })
|
|
446
|
+
.forLocale("it")
|
|
447
|
+
.set(
|
|
448
|
+
"billing",
|
|
449
|
+
"invoice_summary",
|
|
450
|
+
"Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}"
|
|
451
|
+
)
|
|
452
|
+
).toThrow("[i18n] Key not preloaded: billing.invoice_summary (it)");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("invalidates compiled cache after set()", () => {
|
|
456
|
+
const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(multiDictionary);
|
|
457
|
+
const en = local.toScope({ namespaces: ["billing"] }).forLocale("en");
|
|
458
|
+
|
|
459
|
+
expect(en.t("billing", "invoice_summary", { count: 2, name: "Bob" })).toBe(
|
|
460
|
+
"You have 2 invoices for Bob"
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
en.set(
|
|
464
|
+
"billing",
|
|
465
|
+
"invoice_summary",
|
|
466
|
+
"{count, plural, one {1 bill} other {{count} bills}} for {name}"
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
expect(en.t("billing", "invoice_summary", { count: 2, name: "Bob" })).toBe("2 bills for Bob");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("throws on ICU args mismatch against other locales", () => {
|
|
473
|
+
const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(multiDictionary);
|
|
474
|
+
const en = local.toScope({ namespaces: ["billing"] }).forLocale("en");
|
|
475
|
+
|
|
476
|
+
expect(() =>
|
|
477
|
+
en.set("billing", "invoice_summary", "{count, select, other {{count}}}")
|
|
478
|
+
).toThrow("[i18n] ICU args mismatch on patch:");
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { invokeNamespaceLoader, type NamespaceLoader } from "./builder-loaders.js";
|
|
2
|
+
import type { I18nEngineSingleImpl } from "./engine.js";
|
|
3
|
+
import type {
|
|
4
|
+
KeyDictionary,
|
|
5
|
+
LocaleFallbackMap,
|
|
6
|
+
LocaleOfSingle,
|
|
7
|
+
PartialKeyDictionary,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
import type { I18nScopeSingleForLocale, I18nScopeSingleImpl } from "./scope-single.js";
|
|
10
|
+
|
|
11
|
+
type SingleScopeBound<
|
|
12
|
+
Schema extends KeyDictionary,
|
|
13
|
+
Params,
|
|
14
|
+
Locale extends string,
|
|
15
|
+
> = I18nScopeSingleForLocale<Schema, Params & { [K in keyof Schema]: unknown }, Locale>;
|
|
16
|
+
|
|
17
|
+
export type I18nBuilderSingleOptions<
|
|
18
|
+
Schema extends KeyDictionary,
|
|
19
|
+
RequestLocales extends string = LocaleOfSingle<Schema>,
|
|
20
|
+
> = {
|
|
21
|
+
dictionaryLoader?: NamespaceLoader<PartialKeyDictionary<Schema, RequestLocales> | Schema>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface I18nBuilderSingle<
|
|
25
|
+
Schema extends KeyDictionary,
|
|
26
|
+
Params extends { [K in keyof Schema]: unknown },
|
|
27
|
+
RequestLocales extends string = LocaleOfSingle<Schema>,
|
|
28
|
+
> {
|
|
29
|
+
withLocale<Locale extends RequestLocales>(
|
|
30
|
+
locale: Locale
|
|
31
|
+
): I18nBuilderSingleForLocaleImpl<Schema, Params, RequestLocales, Locale>;
|
|
32
|
+
|
|
33
|
+
withDeliveryArea<Area extends string>(
|
|
34
|
+
area: Area
|
|
35
|
+
): I18nBuilderSingle<Schema, Params, RequestLocales>;
|
|
36
|
+
|
|
37
|
+
load(): Promise<
|
|
38
|
+
I18nScopeSingleImpl<Schema, Params, RequestLocales, LocaleFallbackMap | undefined>
|
|
39
|
+
>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface I18nBuilderSingleForLocale<
|
|
43
|
+
Schema extends KeyDictionary,
|
|
44
|
+
Params extends { [K in keyof Schema]: unknown },
|
|
45
|
+
Locale extends string,
|
|
46
|
+
> {
|
|
47
|
+
load(): Promise<SingleScopeBound<Schema, Params, Locale>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type SingleBuilderState<
|
|
51
|
+
Schema extends KeyDictionary,
|
|
52
|
+
RequestLocales extends string = LocaleOfSingle<Schema>,
|
|
53
|
+
> = {
|
|
54
|
+
locale?: RequestLocales;
|
|
55
|
+
deliveryArea?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export class I18nBuilderSingleImpl<
|
|
59
|
+
Schema extends KeyDictionary,
|
|
60
|
+
Params extends { [K in keyof Schema]: unknown },
|
|
61
|
+
RequestLocales extends string = LocaleOfSingle<Schema>,
|
|
62
|
+
> implements I18nBuilderSingle<Schema, Params, RequestLocales> {
|
|
63
|
+
private state: SingleBuilderState<Schema, RequestLocales> = {};
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
private readonly engine: I18nEngineSingleImpl<Schema, Params, RequestLocales>,
|
|
67
|
+
private readonly options?: I18nBuilderSingleOptions<Schema, RequestLocales>,
|
|
68
|
+
state?: SingleBuilderState<Schema, RequestLocales>
|
|
69
|
+
) {
|
|
70
|
+
if (state !== undefined) {
|
|
71
|
+
this.state = {
|
|
72
|
+
...(state.locale !== undefined ? { locale: state.locale } : {}),
|
|
73
|
+
...(state.deliveryArea !== undefined ? { deliveryArea: state.deliveryArea } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private clone(): SingleBuilderState<Schema, RequestLocales> {
|
|
79
|
+
return {
|
|
80
|
+
...(this.state.locale !== undefined ? { locale: this.state.locale } : {}),
|
|
81
|
+
...(this.state.deliveryArea !== undefined ? { deliveryArea: this.state.deliveryArea } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
withLocale<Locale extends RequestLocales>(
|
|
86
|
+
locale: Locale
|
|
87
|
+
): I18nBuilderSingleForLocaleImpl<Schema, Params, RequestLocales, Locale> {
|
|
88
|
+
const { deliveryArea: _deliveryArea, ...rest } = this.clone();
|
|
89
|
+
return new I18nBuilderSingleForLocaleImpl<Schema, Params, RequestLocales, Locale>(
|
|
90
|
+
this.engine,
|
|
91
|
+
this.options,
|
|
92
|
+
{ ...rest, locale }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
withDeliveryArea<Area extends string>(
|
|
97
|
+
_area: Area
|
|
98
|
+
): I18nBuilderSingle<Schema, Params, RequestLocales> {
|
|
99
|
+
const { locale: _locale, ...rest } = this.clone();
|
|
100
|
+
return new I18nBuilderSingleImpl(this.engine, this.options, {
|
|
101
|
+
...rest,
|
|
102
|
+
deliveryArea: _area,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async load(): Promise<
|
|
107
|
+
I18nScopeSingleImpl<Schema, Params, RequestLocales, LocaleFallbackMap | undefined>
|
|
108
|
+
> {
|
|
109
|
+
await applySingleBuilderLoad(this.engine, this.options, this.state);
|
|
110
|
+
return this.engine.toScope();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class I18nBuilderSingleForLocaleImpl<
|
|
115
|
+
Schema extends KeyDictionary,
|
|
116
|
+
Params extends { [K in keyof Schema]: unknown },
|
|
117
|
+
RequestLocales extends string = LocaleOfSingle<Schema>,
|
|
118
|
+
Locale extends RequestLocales = RequestLocales,
|
|
119
|
+
> implements I18nBuilderSingleForLocale<Schema, Params, Locale> {
|
|
120
|
+
constructor(
|
|
121
|
+
private readonly engine: I18nEngineSingleImpl<Schema, Params, RequestLocales>,
|
|
122
|
+
private readonly options: I18nBuilderSingleOptions<Schema, RequestLocales> | undefined,
|
|
123
|
+
private readonly state: SingleBuilderState<Schema, RequestLocales> & { locale: Locale }
|
|
124
|
+
) {}
|
|
125
|
+
|
|
126
|
+
async load(): Promise<SingleScopeBound<Schema, Params, Locale>> {
|
|
127
|
+
await applySingleBuilderLoad(this.engine, this.options, this.state);
|
|
128
|
+
return this.engine.toScope({ locale: this.state.locale });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function applySingleBuilderLoad<
|
|
133
|
+
Schema extends KeyDictionary,
|
|
134
|
+
Params extends { [K in keyof Schema]: unknown },
|
|
135
|
+
RequestLocales extends string,
|
|
136
|
+
>(
|
|
137
|
+
engine: I18nEngineSingleImpl<Schema, Params, RequestLocales>,
|
|
138
|
+
options: I18nBuilderSingleOptions<Schema, RequestLocales> | undefined,
|
|
139
|
+
state: SingleBuilderState<Schema, RequestLocales>
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const partition = state.locale ?? state.deliveryArea;
|
|
142
|
+
const loader = options?.dictionaryLoader;
|
|
143
|
+
|
|
144
|
+
if (loader !== undefined) {
|
|
145
|
+
if (engine.hasBuilderResourceLoaded(partition)) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const data = await invokeNamespaceLoader(loader, partition);
|
|
150
|
+
engine.applyLoadMergeSingle(data);
|
|
151
|
+
engine.markBuilderResourceLoaded(partition);
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -16,6 +16,22 @@ export type LocaleOfMulti<Schema extends MultiDictionary> = {
|
|
|
16
16
|
}[keyof Schema] &
|
|
17
17
|
string;
|
|
18
18
|
|
|
19
|
+
/** Subset of schema keys with any subset of project locales per key — valid input for merge operations. */
|
|
20
|
+
export type PartialKeyDictionary<
|
|
21
|
+
T extends KeyDictionary,
|
|
22
|
+
Locales extends string = LocaleOfSingle<T>,
|
|
23
|
+
> = {
|
|
24
|
+
[K in keyof T]?: Partial<Record<Locales, string>>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Subset of schema namespaces with partial keys/locales — valid input for multi merge operations. */
|
|
28
|
+
export type PartialMultiDictionary<
|
|
29
|
+
T extends MultiDictionary,
|
|
30
|
+
Locales extends string = LocaleOfMulti<T>,
|
|
31
|
+
> = {
|
|
32
|
+
[NS in keyof T]?: PartialKeyDictionary<T[NS], Locales>;
|
|
33
|
+
};
|
|
34
|
+
|
|
19
35
|
export type LocaleFallbackMap = Record<string, string | null>;
|
|
20
36
|
|
|
21
37
|
export type MissingTranslationContext = {
|
|
@@ -13,7 +13,7 @@ const parsedKeyEntrySchema = z.object({
|
|
|
13
13
|
mergedArgs: z.record(z.string(), variableTypeSchema),
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
function createKeyDictionarySchema(
|
|
16
|
+
export function createKeyDictionarySchema(
|
|
17
17
|
argsByKey: Readonly<Record<string, VariableSpec>>
|
|
18
18
|
): z.ZodType<Record<string, ParsedKeyEntry>> {
|
|
19
19
|
const keySchemas = Object.fromEntries(
|
package/src/validation/errors.ts
CHANGED
|
@@ -4,6 +4,8 @@ function issueMessage(issue: ValidationIssue): string {
|
|
|
4
4
|
switch (issue.kind) {
|
|
5
5
|
case "missing_key":
|
|
6
6
|
return `Missing required key at ${issue.path.join(".")}`;
|
|
7
|
+
case "unknown_key":
|
|
8
|
+
return `Unknown key at ${issue.path.join(".")}`;
|
|
7
9
|
case "invalid_input":
|
|
8
10
|
case "invalid_locale_value":
|
|
9
11
|
case "icu_syntax_error":
|