@xndrjs/i18n 0.6.1 → 0.7.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 (51) hide show
  1. package/README.md +154 -141
  2. package/dist/codegen/index.js.map +1 -1
  3. package/dist/index.d.ts +268 -69
  4. package/dist/index.js +572 -65
  5. package/dist/index.js.map +1 -1
  6. package/dist/validation/index.d.ts +23 -7
  7. package/dist/validation/index.js +196 -89
  8. package/dist/validation/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/IcuTranslationProviderMulti.test.ts +80 -234
  11. package/src/IcuTranslationProviderMulti.ts +101 -112
  12. package/src/IcuTranslationProviderSingle.test.ts +30 -309
  13. package/src/IcuTranslationProviderSingle.ts +42 -75
  14. package/src/builder-load-registry.ts +18 -0
  15. package/src/builder-loaders.ts +18 -0
  16. package/src/builder-multi.ts +481 -0
  17. package/src/builder-types.test.ts +80 -0
  18. package/src/builder-types.ts +24 -0
  19. package/src/builder.test.ts +421 -0
  20. package/src/builder.ts +112 -0
  21. package/src/codegen/delivery-artifacts.test.ts +2 -1
  22. package/src/codegen/delivery-artifacts.ts +2 -1
  23. package/src/codegen/emit/dictionary-file.test.ts +0 -7
  24. package/src/codegen/emit/dictionary-schema-file.ts +55 -42
  25. package/src/codegen/emit/instance-file.test.ts +88 -0
  26. package/src/codegen/emit/instance-file.ts +164 -17
  27. package/src/codegen/emit/namespace-loaders-file.test.ts +4 -18
  28. package/src/codegen/emit/namespace-loaders-file.ts +4 -54
  29. package/src/codegen/emit/types-file.test.ts +0 -2
  30. package/src/codegen/generate-i18n-types.test.ts +8 -303
  31. package/src/codegen/generate-i18n-types.ts +16 -1
  32. package/src/codegen/project-locales-set-namespace.test.ts +25 -32
  33. package/src/engine.ts +71 -0
  34. package/src/index.ts +45 -7
  35. package/src/patch-key.test.ts +186 -0
  36. package/src/patch-key.ts +140 -0
  37. package/src/project-locales.test.ts +16 -6
  38. package/src/project-locales.ts +17 -6
  39. package/src/scope-multi.ts +124 -0
  40. package/src/scope-single.ts +85 -0
  41. package/src/scope-types.ts +27 -0
  42. package/src/scope.test.ts +481 -0
  43. package/src/single-builder.ts +153 -0
  44. package/src/types.ts +16 -0
  45. package/src/validation/create-normalized-schema.ts +1 -1
  46. package/src/validation/errors.ts +2 -0
  47. package/src/validation/index.ts +135 -27
  48. package/src/validation/normalize.ts +176 -46
  49. package/src/validation/types.ts +4 -0
  50. package/src/validation/validate-normalized.ts +43 -1
  51. package/src/validation/validation.test.ts +200 -7
@@ -0,0 +1,85 @@
1
+ import type { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
2
+ import type { KeyDictionary, LocaleFallbackMap, LocaleOfSingle } from "./types.js";
3
+ import type { ParamArgs } from "./scope-types.js";
4
+
5
+ /** Type-safe translation scope for a single-namespace schema. */
6
+ export interface I18nScopeSingle<
7
+ Schema extends KeyDictionary,
8
+ Params extends { [K in keyof Schema]: unknown },
9
+ RequestLocales extends string = LocaleOfSingle<Schema>,
10
+ > {
11
+ t<const K extends keyof Schema & string>(
12
+ key: K,
13
+ locale: RequestLocales,
14
+ ...params: ParamArgs<Params[K]>
15
+ ): string;
16
+ forLocale<Locale extends RequestLocales>(
17
+ locale: Locale
18
+ ): I18nScopeSingleForLocale<Schema, Params, Locale>;
19
+ }
20
+
21
+ /** Single-namespace scope with a bound locale — `t(key)` and `set(key)` without a locale argument. */
22
+ export interface I18nScopeSingleForLocale<
23
+ Schema extends KeyDictionary,
24
+ Params extends { [K in keyof Schema]: unknown },
25
+ Locale extends string,
26
+ > {
27
+ readonly locale: Locale;
28
+ t<const K extends keyof Schema & string>(key: K, ...params: ParamArgs<Params[K]>): string;
29
+ set<const K extends keyof Schema & string>(key: K, template: string): void;
30
+ forLocale<Next extends Locale>(locale: Next): I18nScopeSingleForLocale<Schema, Params, Next>;
31
+ }
32
+
33
+ export class I18nScopeSingleImpl<
34
+ Schema extends KeyDictionary,
35
+ Params extends { [K in keyof Schema]: unknown },
36
+ RequestLocales extends string,
37
+ Fallback extends LocaleFallbackMap | undefined,
38
+ > implements I18nScopeSingle<Schema, Params, RequestLocales> {
39
+ constructor(
40
+ private readonly engine: IcuTranslationProviderSingle<Schema, Params, RequestLocales, Fallback>
41
+ ) {}
42
+
43
+ t = <const K extends keyof Schema & string>(
44
+ key: K,
45
+ locale: RequestLocales,
46
+ ...args: ParamArgs<Params[K]>
47
+ ): string => {
48
+ const params = args[0] as Record<string, unknown> | undefined;
49
+ return this.engine.getWithLocale(String(key), locale, params);
50
+ };
51
+
52
+ forLocale = <Locale extends RequestLocales>(
53
+ locale: Locale
54
+ ): I18nScopeSingleForLocaleImpl<Schema, Params, RequestLocales, Locale, Fallback> => {
55
+ return new I18nScopeSingleForLocaleImpl(this.engine, locale);
56
+ };
57
+ }
58
+
59
+ export class I18nScopeSingleForLocaleImpl<
60
+ Schema extends KeyDictionary,
61
+ Params extends { [K in keyof Schema]: unknown },
62
+ RequestLocales extends string,
63
+ Locale extends RequestLocales,
64
+ Fallback extends LocaleFallbackMap | undefined,
65
+ > implements I18nScopeSingleForLocale<Schema, Params, Locale> {
66
+ constructor(
67
+ private readonly engine: IcuTranslationProviderSingle<Schema, Params, RequestLocales, Fallback>,
68
+ readonly locale: Locale
69
+ ) {}
70
+
71
+ t = <const K extends keyof Schema & string>(key: K, ...args: ParamArgs<Params[K]>): string => {
72
+ const params = args[0] as Record<string, unknown> | undefined;
73
+ return this.engine.getWithLocale(String(key), this.locale, params);
74
+ };
75
+
76
+ set = <const K extends keyof Schema & string>(key: K, template: string): void => {
77
+ this.engine.patchKey(String(key), this.locale, template);
78
+ };
79
+
80
+ forLocale = <Next extends Locale>(
81
+ locale: Next
82
+ ): I18nScopeSingleForLocaleImpl<Schema, Params, RequestLocales, Next, Fallback> => {
83
+ return new I18nScopeSingleForLocaleImpl(this.engine, locale);
84
+ };
85
+ }
@@ -0,0 +1,27 @@
1
+ import type { KeyDictionary, MultiDictionary } from "./types.js";
2
+
3
+ /** ICU argument map for a multi-namespace schema. */
4
+ export type MultiParams<Schema extends MultiDictionary> = {
5
+ [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown };
6
+ };
7
+
8
+ /** Restrict a multi schema to a subset of namespaces. */
9
+ export type SchemaForNamespaces<
10
+ Schema extends MultiDictionary,
11
+ NsList extends readonly (keyof Schema & string)[],
12
+ > = Pick<Schema, NsList[number]>;
13
+
14
+ /** Restrict multi ICU params to a subset of namespaces. */
15
+ export type ParamsForNamespaces<
16
+ Schema extends MultiDictionary,
17
+ Params extends MultiParams<Schema>,
18
+ NsList extends readonly (keyof Schema & string)[],
19
+ > = Pick<Params, NsList[number]>;
20
+
21
+ /** ICU argument map for a single-namespace schema. */
22
+ export type SingleParams<Schema extends KeyDictionary> = {
23
+ [K in keyof Schema]: unknown;
24
+ };
25
+
26
+ /** Rest args for `t()` — `never` params omit the third argument. */
27
+ export type ParamArgs<P> = [P] extends [never] ? [] : [params: P];
@@ -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
+ });