@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,5 +1,12 @@
1
1
  import { cloneAndFreeze } from "./deep-freeze.js";
2
+ import { BuilderLoadRegistry } from "./builder-load-registry.js";
3
+ import type { I18nEngineMulti } from "./engine.js";
2
4
  import { resolveAndFormat } from "./format-core.js";
5
+ import {
6
+ assertPatchKeyMulti,
7
+ recordPreloadedKeysMulti,
8
+ seedPreloadedKeysMulti,
9
+ } from "./patch-key.js";
3
10
  import { mergeNamespaceLocalesCore } from "./project-locales.js";
4
11
  import { validateLocaleFallback } from "./resolve-locale.js";
5
12
  import type {
@@ -9,85 +16,34 @@ import type {
9
16
  MultiCompiledCache,
10
17
  MultiDictionary,
11
18
  OnMissingTranslation,
19
+ PartialKeyDictionary,
20
+ PartialMultiDictionary,
12
21
  } from "./types.js";
13
-
14
- export interface TranslationProviderMultiForLocale<
15
- Schema extends MultiDictionary,
16
- Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
17
- Locale extends string,
18
- > {
19
- readonly locale: Locale;
20
- get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
21
- namespace: NS,
22
- key: K,
23
- ...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
24
- ): string;
25
- }
26
-
27
- export interface TranslationProviderMulti<
28
- Schema extends MultiDictionary,
29
- Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
30
- RequestLocales extends string = LocaleOfMulti<Schema>,
31
- > {
32
- get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
33
- namespace: NS,
34
- key: K,
35
- locale: RequestLocales,
36
- ...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
37
- ): string;
38
- forLocale<Locale extends RequestLocales>(
39
- locale: Locale
40
- ): TranslationProviderMultiForLocale<Schema, Params, Locale>;
41
- getAll(): Schema;
42
- hasNamespace<NS extends keyof Schema & string>(namespace: NS): boolean;
43
- setAll(values: Schema): void;
44
- mergeAll(values: Partial<Schema>): void;
45
- setNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void;
46
- mergeNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void;
47
- }
48
-
49
- export class IcuTranslationProviderMultiForLocale<
50
- Schema extends MultiDictionary,
51
- Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
52
- RequestLocales extends string,
53
- Locale extends RequestLocales,
54
- Fallback extends LocaleFallbackMap | undefined,
55
- > implements TranslationProviderMultiForLocale<Schema, Params, Locale> {
56
- constructor(
57
- private readonly provider: IcuTranslationProviderMulti<
58
- Schema,
59
- Params,
60
- RequestLocales,
61
- Fallback
62
- >,
63
- readonly locale: Locale
64
- ) {}
65
-
66
- get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
67
- namespace: NS,
68
- key: K,
69
- ...args: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
70
- ): string {
71
- const params = args[0] as Record<string, unknown> | undefined;
72
- return this.provider.getWithLocale(String(namespace), String(key), this.locale, params);
73
- }
74
- }
22
+ import { I18nScopeMultiForLocaleImpl, I18nScopeMultiImpl } from "./scope-multi.js";
23
+ import type { I18nScopeMulti, I18nScopeMultiForLocale } from "./scope-multi.js";
24
+ import type { MultiParams, ParamsForNamespaces, SchemaForNamespaces } from "./scope-types.js";
75
25
 
76
26
  export class IcuTranslationProviderMulti<
77
27
  Schema extends MultiDictionary,
78
- Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
28
+ Params extends MultiParams<Schema>,
79
29
  RequestLocales extends string = LocaleOfMulti<Schema>,
80
30
  Fallback extends LocaleFallbackMap | undefined = undefined,
81
- > implements TranslationProviderMulti<Schema, Params, RequestLocales> {
31
+ > implements I18nEngineMulti<Schema, Params, RequestLocales> {
32
+ readonly __i18nEngineMode = "multi" as const;
33
+
82
34
  private dictionary: Schema;
83
35
  private compiledCache: MultiCompiledCache = {};
36
+ private readonly preloadedKeys = new Set<string>();
37
+ private readonly builderLoadRegistry = new BuilderLoadRegistry();
84
38
  private readonly localeFallback?: Fallback;
85
39
  private readonly onMissing: OnMissingTranslation;
86
- private loadedNamespaces: Set<string>;
87
40
 
88
- constructor(dictionary: Partial<Schema>, options?: IcuTranslationProviderOptions<Fallback>) {
41
+ constructor(
42
+ dictionary: PartialMultiDictionary<Schema, RequestLocales>,
43
+ options?: IcuTranslationProviderOptions<Fallback>
44
+ ) {
89
45
  this.dictionary = structuredClone(dictionary) as Schema;
90
- this.loadedNamespaces = new Set(Object.keys(dictionary));
46
+ seedPreloadedKeysMulti(this.dictionary, this.preloadedKeys);
91
47
  if (options?.localeFallback) {
92
48
  validateLocaleFallback(options.localeFallback);
93
49
  this.localeFallback = options.localeFallback;
@@ -95,28 +51,12 @@ export class IcuTranslationProviderMulti<
95
51
  this.onMissing = options?.onMissing ?? "throw";
96
52
  }
97
53
 
98
- get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
99
- namespace: NS,
100
- key: K,
101
- locale: RequestLocales,
102
- ...args: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
103
- ): string {
104
- const params = args[0] as Record<string, unknown> | undefined;
105
- return this.getWithLocale(String(namespace), String(key), locale, params);
106
- }
107
-
108
54
  getWithLocale(
109
55
  namespace: string,
110
56
  key: string,
111
57
  locale: string,
112
58
  params?: Record<string, unknown>
113
59
  ): string {
114
- if (!this.loadedNamespaces.has(namespace)) {
115
- throw new Error(
116
- `[i18n] Namespace not loaded: "${namespace}". Register it with setNamespace() before calling .get().`
117
- );
118
- }
119
-
120
60
  const localeByKey = (
121
61
  this.dictionary[namespace] as Record<string, Record<string, string>> | undefined
122
62
  )?.[key];
@@ -144,54 +84,103 @@ export class IcuTranslationProviderMulti<
144
84
  });
145
85
  }
146
86
 
147
- forLocale<Locale extends RequestLocales>(
148
- locale: Locale
149
- ): IcuTranslationProviderMultiForLocale<Schema, Params, RequestLocales, Locale, Fallback> {
150
- return new IcuTranslationProviderMultiForLocale(this, locale);
87
+ toScope<
88
+ NsList extends readonly (keyof Schema & string)[],
89
+ Locale extends RequestLocales,
90
+ >(options: {
91
+ namespaces: NsList;
92
+ locale: Locale;
93
+ }): I18nScopeMultiForLocale<
94
+ SchemaForNamespaces<Schema, NsList>,
95
+ ParamsForNamespaces<Schema, Params, NsList>,
96
+ RequestLocales,
97
+ Locale
98
+ >;
99
+ toScope<NsList extends readonly (keyof Schema & string)[]>(options: {
100
+ namespaces: NsList;
101
+ }): I18nScopeMulti<
102
+ SchemaForNamespaces<Schema, NsList>,
103
+ ParamsForNamespaces<Schema, Params, NsList>,
104
+ RequestLocales
105
+ >;
106
+ toScope<
107
+ NsList extends readonly (keyof Schema & string)[],
108
+ Locale extends RequestLocales,
109
+ >(options: { namespaces: NsList; locale?: Locale }) {
110
+ if (options.locale !== undefined) {
111
+ return new I18nScopeMultiForLocaleImpl(this, options.locale) as I18nScopeMultiForLocale<
112
+ SchemaForNamespaces<Schema, NsList>,
113
+ ParamsForNamespaces<Schema, Params, NsList>,
114
+ RequestLocales,
115
+ Locale
116
+ >;
117
+ }
118
+ return new I18nScopeMultiImpl(this) as I18nScopeMulti<
119
+ SchemaForNamespaces<Schema, NsList>,
120
+ ParamsForNamespaces<Schema, Params, NsList>,
121
+ RequestLocales
122
+ >;
151
123
  }
152
124
 
153
125
  getAll(): Schema {
154
126
  return cloneAndFreeze(this.dictionary);
155
127
  }
156
128
 
157
- hasNamespace<NS extends keyof Schema & string>(namespace: NS): boolean {
158
- return this.loadedNamespaces.has(namespace);
129
+ hasBuilderResourceLoaded(namespace: string, partition: string | undefined): boolean {
130
+ return this.builderLoadRegistry.has(namespace, partition);
159
131
  }
160
132
 
161
- setAll(values: Schema): void {
162
- this.dictionary = structuredClone(values);
163
- this.compiledCache = {};
164
- this.loadedNamespaces = new Set(Object.keys(values));
133
+ markBuilderResourceLoaded(namespace: string, partition: string | undefined): void {
134
+ this.builderLoadRegistry.mark(namespace, partition);
165
135
  }
166
136
 
167
- mergeAll(values: Partial<Schema>): void {
168
- for (const namespace of Object.keys(values) as (keyof Schema & string)[]) {
169
- const incoming = values[namespace];
170
- if (incoming !== undefined) {
171
- this.mergeNamespace(namespace, incoming as Schema[typeof namespace]);
172
- }
173
- }
174
- }
137
+ applyLoadMergeNamespace<NS extends keyof Schema>(
138
+ namespace: NS,
139
+ values: PartialKeyDictionary<Schema[NS], RequestLocales>
140
+ ): void {
141
+ const existing = this.dictionary[namespace];
142
+ const merged = mergeNamespaceLocalesCore((existing ?? {}) as Schema[NS], values);
175
143
 
176
- setNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void {
177
144
  this.dictionary = {
178
145
  ...this.dictionary,
179
- [namespace]: structuredClone(values),
146
+ [namespace]: merged,
180
147
  };
181
- this.loadedNamespaces.add(namespace as string);
148
+ recordPreloadedKeysMulti(namespace as string, values, this.preloadedKeys);
149
+
150
+ for (const [key, incomingLocales] of Object.entries(values)) {
151
+ if (incomingLocales === undefined || typeof incomingLocales !== "object") {
152
+ continue;
153
+ }
182
154
 
183
- for (const locale of Object.keys(this.compiledCache)) {
184
- delete this.compiledCache[locale]?.[namespace as string];
155
+ for (const locale of Object.keys(incomingLocales)) {
156
+ delete this.compiledCache[locale]?.[namespace as string]?.[key];
157
+ }
185
158
  }
186
159
  }
187
160
 
188
- mergeNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void {
189
- if (!this.loadedNamespaces.has(namespace as string)) {
190
- this.setNamespace(namespace, values);
191
- return;
161
+ applyLoadMergeAll(values: PartialMultiDictionary<Schema, RequestLocales>): void {
162
+ for (const namespace of Object.keys(values) as (keyof Schema & string)[]) {
163
+ const incoming = values[namespace];
164
+ if (incoming !== undefined) {
165
+ this.applyLoadMergeNamespace(namespace, incoming);
166
+ }
192
167
  }
168
+ }
193
169
 
194
- const existing = this.dictionary[namespace];
195
- this.setNamespace(namespace, mergeNamespaceLocalesCore(existing as Schema[NS], values));
170
+ patchKeyMulti(namespace: string, key: string, locale: string, template: string): void {
171
+ const namespaceDictionary = this.dictionary[namespace] as
172
+ | Record<string, Record<string, string>>
173
+ | undefined;
174
+ const localeByKey = namespaceDictionary?.[key];
175
+
176
+ assertPatchKeyMulti(namespace, key, locale, template, this.preloadedKeys, localeByKey);
177
+
178
+ this.applyLoadMergeNamespace(
179
+ namespace as keyof Schema,
180
+ { [key]: { [locale]: template } } as PartialKeyDictionary<
181
+ Schema[keyof Schema],
182
+ RequestLocales
183
+ >
184
+ );
196
185
  }
197
186
  }
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
3
- import type { LocaleOfSingle } from "./types.js";
4
3
 
5
4
  type TestSchema = {
6
5
  login_button: { en: string; it: string };
@@ -82,160 +81,19 @@ const dictionary: TestSchema = {
82
81
  };
83
82
 
84
83
  describe("IcuTranslationProviderSingle", () => {
85
- const provider = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
84
+ const engine = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
86
85
 
87
- it("returns a static translation without params", () => {
88
- expect(provider.get("login_button", "it")).toBe("Accedi");
89
- });
90
-
91
- it("interpolates ICU arguments", () => {
92
- expect(provider.get("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
93
- });
94
-
95
- describe("ICU plural interpolation", () => {
96
- it("formats a single numeric plural in English and Italian", () => {
97
- expect(provider.get("invoice_count", "en", { count: 1 })).toBe("You have 1 invoice");
98
- expect(provider.get("invoice_count", "en", { count: 5 })).toBe("You have 5 invoices");
99
- expect(provider.get("invoice_count", "it", { count: 1 })).toBe("Hai 1 fattura");
100
- expect(provider.get("invoice_count", "it", { count: 5 })).toBe("Hai 5 fatture");
101
- });
102
-
103
- it("uses =5 for an exact match; zero is a locale plural category, not exact match in English", () => {
104
- // ICU "zero" is a plural rule category (e.g. Arabic); in English, 0 maps to "other".
105
- expect(provider.get("item_count_zero", "en", { count: 0 })).toBe("0 items");
106
- // "=5" is an exact-value selector and matches count === 5 in any locale.
107
- expect(provider.get("item_count_exact", "en", { count: 0 })).toBe("0 items");
108
- expect(provider.get("item_count_exact", "en", { count: 1 })).toBe("1 item");
109
- expect(provider.get("item_count_exact", "en", { count: 5 })).toBe("five items");
110
- });
111
-
112
- it("formats nested numeric plurals with double-brace references", () => {
113
- expect(provider.get("dashboard_status", "en", { msgCount: 1, chatCount: 1 })).toBe(
114
- "You have 1 message in one chat"
115
- );
116
- expect(provider.get("dashboard_status", "en", { msgCount: 3, chatCount: 2 })).toBe(
117
- "You have 3 messages in 2 chats"
118
- );
119
- expect(provider.get("dashboard_status", "it", { msgCount: 1, chatCount: 1 })).toBe(
120
- "Hai 1 messaggio in una chat"
121
- );
122
- expect(provider.get("dashboard_status", "it", { msgCount: 3, chatCount: 2 })).toBe(
123
- "Hai 3 messaggi in 2 chat"
124
- );
125
- });
126
-
127
- it("throws when a required numeric plural parameter is missing", () => {
128
- expect(() => provider.get("dashboard_status", "en", { msgCount: 1 } as never)).toThrow(
129
- "[i18n Formatting Error]"
130
- );
131
- });
132
- });
133
-
134
- describe("additional ICU variants", () => {
135
- it("formats select arguments as strings", () => {
136
- expect(provider.get("inbox_owner", "en", { gender: "female", name: "Ada" })).toBe(
137
- "Ada owns her inbox"
138
- );
139
- expect(provider.get("inbox_owner", "en", { gender: "unknown", name: "Sam" })).toBe(
140
- "Sam owns their inbox"
141
- );
142
- });
143
-
144
- it("formats Italian select with a single other branch and the same params", () => {
145
- expect(provider.get("inbox_owner", "it", { gender: "female", name: "Ada" })).toBe(
146
- "Ada gestisce la propria casella"
147
- );
148
- expect(provider.get("inbox_owner", "it", { gender: "male", name: "Luca" })).toBe(
149
- "Luca gestisce la propria casella"
150
- );
151
- });
152
-
153
- it("formats selectordinal arguments as numbers", () => {
154
- expect(provider.get("ranking_position", "en", { position: 1 })).toBe("You finished 1st");
155
- expect(provider.get("ranking_position", "en", { position: 2 })).toBe("You finished 2nd");
156
- expect(provider.get("ranking_position", "en", { position: 3 })).toBe("You finished 3rd");
157
- expect(provider.get("ranking_position", "en", { position: 4 })).toBe("You finished 4th");
158
- });
159
-
160
- it("formats number, date, and time arguments", () => {
161
- const amount = 1234.5;
162
- const when = new Date("2026-07-01T13:30:00Z");
163
-
164
- const expectedAmount = new Intl.NumberFormat("en", {
165
- style: "currency",
166
- currency: "EUR",
167
- }).format(amount);
168
- const expectedDate = new Intl.DateTimeFormat("en", {
169
- dateStyle: "short",
170
- }).format(when);
171
- const expectedTime = new Intl.DateTimeFormat("en", {
172
- timeStyle: "short",
173
- }).format(when);
174
-
175
- expect(provider.get("account_balance", "en", { amount })).toBe(`Balance: ${expectedAmount}`);
176
- expect(
177
- provider.get("appointment_summary", "en", {
178
- dueDate: when,
179
- startTime: when,
180
- })
181
- ).toBe(`Due ${expectedDate} at ${expectedTime}`);
182
- });
183
-
184
- it("formats ICU date and number skeletons (::yMMMMd, ::percent)", () => {
185
- const when = new Date("2026-07-01T13:30:00Z");
186
- const expectedLongDate = new Intl.DateTimeFormat("en", {
187
- year: "numeric",
188
- month: "long",
189
- day: "numeric",
190
- }).format(when);
191
- const expectedPercent = new Intl.NumberFormat("en", {
192
- style: "percent",
193
- }).format(0.25);
194
-
195
- expect(provider.get("invoice_due_long", "en", { dueDate: when })).toBe(
196
- `Payment due on ${expectedLongDate}`
197
- );
198
- expect(provider.get("discount_rate", "en", { rate: 0.25 })).toBe(
199
- `Save ${expectedPercent} today`
200
- );
201
- });
202
- });
203
-
204
- it("treats an empty string template as valid", () => {
205
- expect(provider.get("empty_label", "en")).toBe("");
206
- });
207
-
208
- it("throws when the key or locale is missing", () => {
209
- expect(() =>
210
- provider.get("login_button", "fr" as unknown as LocaleOfSingle<TestSchema>)
211
- ).toThrow('[i18n] Missing key or locale: "login_button" [fr] (fallback chain: fr)');
212
- expect(() => provider.get("missing_key" as "login_button", "en")).toThrow(
213
- '[i18n] Missing key or locale: "missing_key" [en] (fallback chain: en)'
214
- );
215
- });
216
-
217
- it("throws on malformed ICU syntax", () => {
218
- expect(() => provider.get("broken", "en", { name: "Ada" })).toThrow("[i18n ICU Syntax Error]");
219
- });
220
-
221
- it("throws when required parameters are missing", () => {
222
- // @ts-expect-error
223
- expect(() => provider.get("welcome", "en")).toThrow("[i18n Formatting Error]");
224
- });
225
-
226
- it("replaces the dictionary and invalidates cache on setAll", () => {
86
+ it("patches a preloaded key and invalidates cache", () => {
227
87
  const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
228
- expect(local.get("login_button", "en")).toBe("Login");
88
+ expect(local.toScope().t("login_button", "en")).toBe("Login");
229
89
 
230
- local.setAll({
231
- ...dictionary,
232
- login_button: { en: "Sign in", it: "Entra" },
233
- });
90
+ local.patchKey("login_button", "en", "Sign in");
234
91
 
235
- expect(local.get("login_button", "en")).toBe("Sign in");
92
+ expect(local.toScope().t("login_button", "en")).toBe("Sign in");
93
+ expect(local.toScope().t("login_button", "it")).toBe("Accedi");
236
94
  });
237
95
 
238
- it("merges locales per key with mergeAll without dropping existing locales", () => {
96
+ it("merges locales per key with applyLoadMergeSingle without dropping existing locales", () => {
239
97
  const local = new IcuTranslationProviderSingle<TestSchema, TestParams>({
240
98
  ...dictionary,
241
99
  // @ts-expect-error missing locale
@@ -244,30 +102,41 @@ describe("IcuTranslationProviderSingle", () => {
244
102
  },
245
103
  });
246
104
 
247
- local.mergeAll({
248
- // @ts-expect-error missing locale
105
+ local.applyLoadMergeSingle({
249
106
  welcome: {
107
+ // @ts-expect-error adding locale via merge
250
108
  it: "Benvenuto {name}!",
251
109
  },
252
110
  });
253
111
 
254
- expect(local.get("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
255
- expect(local.get("welcome", "it", { name: "Ada" })).toBe("Benvenuto Ada!");
256
- expect(local.get("login_button", "en")).toBe("Login");
112
+ const view = local.toScope();
113
+ expect(view.t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
114
+ expect(view.t("welcome", "it", { name: "Ada" })).toBe("Benvenuto Ada!");
115
+ expect(view.t("login_button", "en")).toBe("Login");
116
+ });
117
+
118
+ it("throws when patching a key that was not preloaded", () => {
119
+ const local = new IcuTranslationProviderSingle<TestSchema, TestParams>({
120
+ login_button: { en: "Login" },
121
+ } as TestSchema);
122
+
123
+ expect(() => local.patchKey("login_button", "it", "Accedi")).toThrow(
124
+ "[i18n] Key not preloaded: login_button (it)"
125
+ );
257
126
  });
258
127
 
259
128
  it("returns a deep-frozen snapshot from getAll", () => {
260
- expect(provider.getAll()).toEqual(dictionary);
261
- expect(provider.getAll()).not.toBe(dictionary);
262
- expect(Object.isFrozen(provider.getAll())).toBe(true);
129
+ expect(engine.getAll()).toEqual(dictionary);
130
+ expect(engine.getAll()).not.toBe(dictionary);
131
+ expect(Object.isFrozen(engine.getAll())).toBe(true);
263
132
  });
264
133
 
265
- it("does not mutate the provider when getAll snapshot is modified", () => {
266
- const snapshot = provider.getAll();
134
+ it("does not mutate the engine when getAll snapshot is modified", () => {
135
+ const snapshot = engine.getAll();
267
136
  expect(() => {
268
137
  snapshot.welcome.en = "Hacked";
269
138
  }).toThrow(TypeError);
270
- expect(provider.get("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
139
+ expect(engine.toScope().t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
271
140
  });
272
141
 
273
142
  it("does not reflect external dictionary mutations after construction", () => {
@@ -292,91 +161,10 @@ describe("IcuTranslationProviderSingle", () => {
292
161
  };
293
162
  const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(external);
294
163
  external.login_button.en = "Sign in";
295
- expect(local.get("login_button", "en")).toBe("Login");
296
- });
297
-
298
- describe("forLocale", () => {
299
- it("binds a locale so get() no longer requires it", () => {
300
- const en = provider.forLocale("en");
301
-
302
- expect(en.locale).toBe("en");
303
- expect(en.get("login_button")).toBe("Login");
304
- expect(en.get("welcome", { name: "Ada" })).toBe("Welcome Ada!");
305
- });
306
-
307
- it("uses the parent provider cache and fallback rules", () => {
308
- const fallbackMap = {
309
- en: null,
310
- "de-CH": "en",
311
- } as const;
312
- const fallbackProvider = new IcuTranslationProviderSingle<
313
- TestSchema,
314
- TestParams,
315
- keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
316
- typeof fallbackMap
317
- >(dictionary, { localeFallback: fallbackMap });
318
- const deCh = fallbackProvider.forLocale("de-CH");
319
-
320
- expect(deCh.get("login_button")).toBe("Login");
321
- });
164
+ expect(local.toScope().t("login_button", "en")).toBe("Login");
322
165
  });
323
166
 
324
167
  describe("locale fallback", () => {
325
- const fallbackMap = {
326
- en: null,
327
- "de-DE": "en",
328
- "de-CH": "de-DE",
329
- it: "en",
330
- } as const;
331
-
332
- const fallbackProvider = new IcuTranslationProviderSingle<
333
- TestSchema,
334
- TestParams,
335
- keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
336
- typeof fallbackMap
337
- >(dictionary, { localeFallback: fallbackMap });
338
-
339
- it("resolves a locale through the fallback chain", () => {
340
- expect(fallbackProvider.get("login_button", "de-CH")).toBe("Login");
341
- });
342
-
343
- it("uses an intermediate locale template when available", () => {
344
- const withGerman = new IcuTranslationProviderSingle<
345
- TestSchema & {
346
- login_button: { en: string; it: string; "de-DE": string };
347
- },
348
- TestParams,
349
- keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
350
- typeof fallbackMap
351
- >(
352
- {
353
- ...dictionary,
354
- login_button: { en: "Login", it: "Accedi", "de-DE": "Anmelden" },
355
- },
356
- { localeFallback: fallbackMap }
357
- );
358
-
359
- expect(withGerman.get("login_button", "de-CH")).toBe("Anmelden");
360
- });
361
-
362
- it("throws when the fallback chain cannot resolve a template", () => {
363
- const unresolvedFallback = {
364
- en: null,
365
- "de-CH": "fr",
366
- fr: null,
367
- } as const;
368
- const unresolvedProvider = new IcuTranslationProviderSingle<
369
- TestSchema,
370
- TestParams,
371
- keyof typeof unresolvedFallback | LocaleOfSingle<TestSchema>,
372
- typeof unresolvedFallback
373
- >(dictionary, { localeFallback: unresolvedFallback });
374
-
375
- expect(() => unresolvedProvider.get("login_button", "de-CH")).toThrow(
376
- '[i18n] Missing key or locale: "login_button" [de-CH] (fallback chain: de-CH → fr)'
377
- );
378
- });
379
-
380
168
  it("rejects circular fallback maps at construction", () => {
381
169
  expect(
382
170
  () =>
@@ -386,71 +174,4 @@ describe("IcuTranslationProviderSingle", () => {
386
174
  ).toThrow("[i18n] Circular locale fallback detected");
387
175
  });
388
176
  });
389
-
390
- describe("onMissing", () => {
391
- it('throws on missing translations with onMissing: "throw" (explicit default)', () => {
392
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
393
- onMissing: "throw",
394
- });
395
- expect(() => local.get("missing_key" as "login_button", "en")).toThrow(
396
- '[i18n] Missing key or locale: "missing_key" [en] (fallback chain: en)'
397
- );
398
- });
399
-
400
- it('returns the key itself with onMissing: "key"', () => {
401
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
402
- onMissing: "key",
403
- });
404
- expect(local.get("missing_key" as "login_button", "en")).toBe("missing_key");
405
- });
406
-
407
- it("calls a custom handler with the missing-translation context", () => {
408
- const contexts: unknown[] = [];
409
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
410
- onMissing: (context) => {
411
- contexts.push(context);
412
- return `<missing:${context.key}>`;
413
- },
414
- });
415
-
416
- expect(local.get("missing_key" as "login_button", "en")).toBe("<missing:missing_key>");
417
- expect(contexts).toEqual([{ key: "missing_key", locale: "en", fallbackChain: "en" }]);
418
- });
419
-
420
- it("includes the walked fallback chain in the handler context", () => {
421
- const fallbackMap = { en: null, "de-CH": "fr", fr: null } as const;
422
- const contexts: { fallbackChain: string }[] = [];
423
- const local = new IcuTranslationProviderSingle<
424
- TestSchema,
425
- TestParams,
426
- keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
427
- typeof fallbackMap
428
- >(dictionary, {
429
- localeFallback: fallbackMap,
430
- onMissing: (context) => {
431
- contexts.push(context);
432
- return context.key;
433
- },
434
- });
435
-
436
- expect(local.get("login_button", "de-CH")).toBe("login_button");
437
- expect(contexts[0]?.fallbackChain).toBe("de-CH → fr");
438
- });
439
-
440
- it("still throws ICU syntax and formatting errors with a lenient onMissing", () => {
441
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
442
- onMissing: "key",
443
- });
444
- expect(() => local.get("broken", "en", { name: "Ada" })).toThrow("[i18n ICU Syntax Error]");
445
- // @ts-expect-error
446
- expect(() => local.get("welcome", "en")).toThrow("[i18n Formatting Error]");
447
- });
448
-
449
- it("applies onMissing through forLocale wrappers", () => {
450
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
451
- onMissing: "key",
452
- });
453
- expect(local.forLocale("en").get("missing_key" as "login_button")).toBe("missing_key");
454
- });
455
- });
456
177
  });