@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
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
3
- import type { LocaleOfMulti } from "./types.js";
4
3
 
5
4
  type TestSchema = {
6
5
  default: {
@@ -69,199 +68,141 @@ const dictionary: TestSchema = {
69
68
  };
70
69
 
71
70
  describe("IcuTranslationProviderMulti", () => {
72
- const provider = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
71
+ const engine = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
73
72
 
74
- it("resolves translations by namespace and key", () => {
75
- expect(provider.get("default", "login_button", "it")).toBe("Accedi");
76
- expect(provider.get("default", "welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
77
- });
73
+ it("tracks namespaces via dictionary keys", () => {
74
+ const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
75
+ default: dictionary.default,
76
+ });
78
77
 
79
- it("formats ICU plurals with multiple parameters", () => {
80
- expect(provider.get("billing", "invoice_summary", "en", { count: 1, name: "Ada" })).toBe(
81
- "You have 1 invoice for Ada"
82
- );
83
- expect(provider.get("billing", "invoice_summary", "en", { count: 3, name: "Ada" })).toBe(
84
- "You have 3 invoices for Ada"
85
- );
86
- expect(provider.get("billing", "invoice_summary", "it", { count: 1, name: "Ada" })).toBe(
87
- "Hai 1 fattura per Ada"
88
- );
89
- expect(provider.get("billing", "invoice_summary", "it", { count: 3, name: "Ada" })).toBe(
90
- "Hai 3 fatture per Ada"
91
- );
78
+ expect("default" in partial.getAll()).toBe(true);
79
+ expect("billing" in partial.getAll()).toBe(false);
92
80
  });
93
81
 
94
- it("formats nested numeric plurals with double-brace references", () => {
95
- expect(provider.get("default", "dashboard_status", "en", { msgCount: 1, chatCount: 1 })).toBe(
96
- "You have 1 message in one chat"
97
- );
98
- expect(provider.get("default", "dashboard_status", "en", { msgCount: 3, chatCount: 2 })).toBe(
99
- "You have 3 messages in 2 chats"
100
- );
101
- expect(provider.get("default", "dashboard_status", "it", { msgCount: 1, chatCount: 1 })).toBe(
102
- "Hai 1 messaggio in una chat"
103
- );
104
- expect(provider.get("default", "dashboard_status", "it", { msgCount: 3, chatCount: 2 })).toBe(
105
- "Hai 3 messaggi in 2 chat"
106
- );
107
- });
82
+ it("supports partial initialization", () => {
83
+ const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
84
+ default: dictionary.default,
85
+ });
108
86
 
109
- it("formats select and selectordinal ICU variants", () => {
110
- expect(provider.get("default", "inbox_owner", "en", { gender: "male", name: "Linus" })).toBe(
111
- "Linus owns his inbox"
112
- );
113
- expect(provider.get("default", "ranking_position", "en", { position: 3 })).toBe(
114
- "You finished 3rd"
87
+ expect(partial.toScope({ namespaces: ["default"] }).t("default", "login_button", "en")).toBe(
88
+ "Login"
115
89
  );
116
90
  });
117
91
 
118
- it("formats number, date, and time ICU variants", () => {
119
- const amount = 1234.5;
120
- const when = new Date("2026-07-01T13:30:00Z");
121
- const expectedAmount = new Intl.NumberFormat("en", {
122
- style: "currency",
123
- currency: "EUR",
124
- }).format(amount);
125
- const expectedDate = new Intl.DateTimeFormat("en", {
126
- dateStyle: "short",
127
- }).format(when);
128
- const expectedTime = new Intl.DateTimeFormat("en", {
129
- timeStyle: "short",
130
- }).format(when);
92
+ it("patches a preloaded key and invalidates its cache", () => {
93
+ const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
94
+ const view = () => local.toScope({ namespaces: ["default", "billing"] });
131
95
 
132
- expect(provider.get("billing", "account_balance", "en", { amount })).toBe(
133
- `Balance: ${expectedAmount}`
96
+ expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
97
+ "You have 2 invoices for Bob"
134
98
  );
135
- expect(
136
- provider.get("billing", "appointment_summary", "en", {
137
- dueDate: when,
138
- startTime: when,
139
- })
140
- ).toBe(`Due ${expectedDate} at ${expectedTime}`);
141
- });
142
-
143
- it("treats an empty string template as valid", () => {
144
- expect(provider.get("billing", "empty_label", "en")).toBe("");
145
- });
146
-
147
- it("tracks loaded namespaces with hasNamespace", () => {
148
- const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
149
- default: dictionary.default,
150
- });
151
99
 
152
- expect(partial.hasNamespace("default")).toBe(true);
153
- expect(partial.hasNamespace("billing")).toBe(false);
154
- });
155
-
156
- it("throws when getting from an unloaded namespace", () => {
157
- const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
158
- default: dictionary.default,
159
- });
100
+ local.patchKeyMulti(
101
+ "billing",
102
+ "invoice_summary",
103
+ "en",
104
+ "{count, plural, one {1 bill} other {{count} bills}} for {name}"
105
+ );
160
106
 
161
- expect(() =>
162
- partial.get("billing", "invoice_summary", "en", { count: 1, name: "Ada" })
163
- ).toThrow(
164
- '[i18n] Namespace not loaded: "billing". Register it with setNamespace() before calling .get().'
107
+ expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
108
+ "2 bills for Bob"
165
109
  );
110
+ expect(view().t("default", "login_button", "en")).toBe("Login");
166
111
  });
167
112
 
168
- it("supports partial initialization", () => {
113
+ it("adds namespace to dictionary with applyLoadMergeNamespace", () => {
169
114
  const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
170
115
  default: dictionary.default,
171
116
  });
117
+ expect("billing" in partial.getAll()).toBe(false);
172
118
 
173
- expect(partial.get("default", "login_button", "en")).toBe("Login");
119
+ partial.applyLoadMergeNamespace("billing", dictionary.billing);
120
+ expect("billing" in partial.getAll()).toBe(true);
174
121
  });
175
122
 
176
- it("throws when namespace, key, or locale is missing", () => {
177
- expect(() =>
178
- provider.get("default", "login_button", "fr" as unknown as LocaleOfMulti<TestSchema>)
179
- ).toThrow(
180
- '[i18n] Missing key or locale: namespace "default", key "login_button" [fr] (fallback chain: fr)'
181
- );
182
- expect(() => provider.get("billing", "login_button" as "empty_label", "en")).toThrow(
183
- '[i18n] Missing key or locale: namespace "billing", key "login_button" [en] (fallback chain: en)'
184
- );
185
- });
123
+ it("adds namespace to dictionary on first applyLoadMergeNamespace", () => {
124
+ const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({});
125
+ expect("billing" in partial.getAll()).toBe(false);
186
126
 
187
- it("throws when required parameters are missing", () => {
188
- expect(() => provider.get("billing", "invoice_summary", "en", { count: 1 } as never)).toThrow(
189
- "[i18n Formatting Error]"
190
- );
127
+ partial.applyLoadMergeNamespace("billing", dictionary.billing);
128
+ expect("billing" in partial.getAll()).toBe(true);
191
129
  });
192
130
 
193
- it("patches a single namespace with setNamespace and invalidates its cache", () => {
194
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
195
- expect(local.get("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
196
- "You have 2 invoices for Bob"
131
+ it("accumulates a locale via applyLoadMergeNamespace when the namespace already exists", () => {
132
+ const local = new IcuTranslationProviderMulti<TestSchema, TestParams>({
133
+ billing: {
134
+ invoice_summary: {
135
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
136
+ },
137
+ },
138
+ });
139
+ const view = () => local.toScope({ namespaces: ["billing"] });
140
+
141
+ expect("billing" in local.getAll()).toBe(true);
142
+ expect(view().t("billing", "invoice_summary", "en", { count: 1, name: "Ada" })).toBe(
143
+ "You have 1 invoice for Ada"
197
144
  );
198
145
 
199
- local.setNamespace("billing", {
200
- ...dictionary.billing,
146
+ local.applyLoadMergeNamespace("billing", {
201
147
  invoice_summary: {
202
- en: "{count, plural, one {1 bill} other {{count} bills}}",
203
- it: dictionary.billing.invoice_summary.it,
148
+ it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
204
149
  },
205
150
  });
206
151
 
207
- expect(local.get("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
208
- "2 bills"
152
+ expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
153
+ "You have 2 invoices for Bob"
154
+ );
155
+ expect(view().t("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
156
+ "Hai 2 fatture per Bob"
209
157
  );
210
- expect(local.get("default", "login_button", "en")).toBe("Login");
211
158
  });
212
159
 
213
- it("merges locales per key with mergeNamespace without dropping existing locales", () => {
160
+ it("merges locales per key with applyLoadMergeNamespace without dropping existing locales", () => {
214
161
  const local = new IcuTranslationProviderMulti<TestSchema, TestParams>({
215
162
  billing: {
216
- // @ts-expect-error missing locale
217
163
  invoice_summary: {
218
164
  en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
219
165
  },
220
166
  },
221
167
  });
168
+ const view = () => local.toScope({ namespaces: ["billing"] });
222
169
 
223
- local.mergeNamespace("billing", {
224
- // @ts-expect-error missing locale
170
+ local.applyLoadMergeNamespace("billing", {
225
171
  invoice_summary: {
226
172
  it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
227
173
  },
228
174
  });
229
175
 
230
- expect(local.get("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
176
+ expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
231
177
  "You have 2 invoices for Bob"
232
178
  );
233
- expect(local.get("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
179
+ expect(view().t("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
234
180
  "Hai 2 fatture per Bob"
235
181
  );
236
182
  });
237
183
 
238
- it("replaces the full dictionary on setAll", () => {
184
+ it("patches a preloaded default namespace key", () => {
239
185
  const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
240
186
 
241
- local.setAll({
242
- ...dictionary,
243
- default: {
244
- ...dictionary.default,
245
- login_button: { en: "Sign in", it: "Entra" },
246
- },
247
- });
187
+ local.patchKeyMulti("default", "login_button", "en", "Sign in");
248
188
 
249
- expect(local.get("default", "login_button", "en")).toBe("Sign in");
189
+ expect(local.toScope({ namespaces: ["default"] }).t("default", "login_button", "en")).toBe(
190
+ "Sign in"
191
+ );
250
192
  });
251
193
 
252
- it("merges namespaces with mergeAll without dropping existing locales", () => {
194
+ it("merges namespaces with applyLoadMergeAll without dropping existing locales", () => {
253
195
  const local = new IcuTranslationProviderMulti<TestSchema, TestParams>({
254
196
  billing: {
255
- // @ts-expect-error missing locale
256
197
  invoice_summary: {
257
198
  en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
258
199
  },
259
200
  },
260
201
  });
202
+ const view = () => local.toScope({ namespaces: ["default", "billing"] });
261
203
 
262
- local.mergeAll({
204
+ local.applyLoadMergeAll({
263
205
  billing: {
264
- // @ts-expect-error missing locale
265
206
  invoice_summary: {
266
207
  it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
267
208
  },
@@ -269,123 +210,28 @@ describe("IcuTranslationProviderMulti", () => {
269
210
  default: dictionary.default,
270
211
  });
271
212
 
272
- expect(local.get("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
213
+ expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
273
214
  "You have 2 invoices for Bob"
274
215
  );
275
- expect(local.get("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
216
+ expect(view().t("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
276
217
  "Hai 2 fatture per Bob"
277
218
  );
278
- expect(local.get("default", "login_button", "en")).toBe("Login");
219
+ expect(view().t("default", "login_button", "en")).toBe("Login");
279
220
  });
280
221
 
281
222
  it("returns a deep-frozen snapshot from getAll", () => {
282
- expect(provider.getAll()).toEqual(dictionary);
283
- expect(provider.getAll()).not.toBe(dictionary);
284
- expect(Object.isFrozen(provider.getAll().default)).toBe(true);
223
+ expect(engine.getAll()).toEqual(dictionary);
224
+ expect(engine.getAll()).not.toBe(dictionary);
225
+ expect(Object.isFrozen(engine.getAll().default)).toBe(true);
285
226
  });
286
227
 
287
- it("does not mutate the provider when getAll snapshot is modified", () => {
288
- const snapshot = provider.getAll();
228
+ it("does not mutate the engine when getAll snapshot is modified", () => {
229
+ const snapshot = engine.getAll();
289
230
  expect(() => {
290
231
  snapshot.default.login_button.en = "Hacked";
291
232
  }).toThrow(TypeError);
292
- expect(provider.get("default", "login_button", "en")).toBe("Login");
293
- });
294
-
295
- describe("forLocale", () => {
296
- it("binds a locale so get() no longer requires it", () => {
297
- const it = provider.forLocale("it");
298
-
299
- expect(it.locale).toBe("it");
300
- expect(it.get("default", "login_button")).toBe("Accedi");
301
- expect(it.get("billing", "invoice_summary", { count: 2, name: "Ada" })).toBe(
302
- "Hai 2 fatture per Ada"
303
- );
304
- });
305
- });
306
-
307
- describe("locale fallback", () => {
308
- const fallbackMap = {
309
- en: null,
310
- "de-DE": "en",
311
- "de-CH": "de-DE",
312
- it: "en",
313
- } as const;
314
-
315
- const fallbackProvider = new IcuTranslationProviderMulti<
316
- TestSchema,
317
- TestParams,
318
- keyof typeof fallbackMap | LocaleOfMulti<TestSchema>,
319
- typeof fallbackMap
320
- >(dictionary, { localeFallback: fallbackMap });
321
-
322
- it("resolves a locale through the fallback chain", () => {
323
- expect(fallbackProvider.get("default", "login_button", "de-CH")).toBe("Login");
324
- });
325
-
326
- it("throws when the fallback chain cannot resolve a template", () => {
327
- const unresolvedFallback = {
328
- en: null,
329
- "de-CH": "fr",
330
- fr: null,
331
- } as const;
332
- const unresolvedProvider = new IcuTranslationProviderMulti<
333
- TestSchema,
334
- TestParams,
335
- keyof typeof unresolvedFallback | LocaleOfMulti<TestSchema>,
336
- typeof unresolvedFallback
337
- >(dictionary, { localeFallback: unresolvedFallback });
338
-
339
- expect(() => unresolvedProvider.get("default", "login_button", "de-CH")).toThrow(
340
- '[i18n] Missing key or locale: namespace "default", key "login_button" [de-CH] (fallback chain: de-CH → fr)'
341
- );
342
- });
343
- });
344
-
345
- describe("onMissing", () => {
346
- it('returns "namespace.key" with onMissing: "key"', () => {
347
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary, {
348
- onMissing: "key",
349
- });
350
- expect(local.get("default", "missing_key" as "login_button", "en")).toBe(
351
- "default.missing_key"
352
- );
353
- });
354
-
355
- it("calls a custom handler with namespace, key, locale, and fallback chain", () => {
356
- const contexts: unknown[] = [];
357
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary, {
358
- onMissing: (context) => {
359
- contexts.push(context);
360
- return `<missing:${context.namespace}/${context.key}>`;
361
- },
362
- });
363
-
364
- expect(local.get("default", "missing_key" as "login_button", "en")).toBe(
365
- "<missing:default/missing_key>"
366
- );
367
- expect(contexts).toEqual([
368
- { namespace: "default", key: "missing_key", locale: "en", fallbackChain: "en" },
369
- ]);
370
- });
371
-
372
- it("still throws for unloaded namespaces with a lenient onMissing", () => {
373
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(
374
- { default: dictionary.default },
375
- { onMissing: "key" }
376
- );
377
- expect(() => local.get("billing", "account_balance", "en", { amount: 1 })).toThrow(
378
- '[i18n] Namespace not loaded: "billing"'
379
- );
380
- });
381
-
382
- it("applies onMissing through forLocale wrappers", () => {
383
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary, {
384
- onMissing: "key",
385
- });
386
- expect(local.forLocale("en").get("default", "missing_key" as "login_button")).toBe(
387
- "default.missing_key"
388
- );
389
- });
233
+ expect(engine.toScope({ namespaces: ["default"] }).t("default", "login_button", "en")).toBe(
234
+ "Login"
235
+ );
390
236
  });
391
237
  });