@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.
Files changed (51) hide show
  1. package/README.md +154 -134
  2. package/dist/codegen/index.js.map +1 -1
  3. package/dist/index.d.ts +276 -61
  4. package/dist/index.js +687 -126
  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 +115 -215
  11. package/src/IcuTranslationProviderMulti.ts +107 -96
  12. package/src/IcuTranslationProviderSingle.test.ts +36 -294
  13. package/src/IcuTranslationProviderSingle.ts +55 -72
  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 +35 -10
  28. package/src/codegen/emit/namespace-loaders-file.ts +11 -61
  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 +47 -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 +76 -0
  38. package/src/project-locales.ts +58 -1
  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,269 +68,170 @@ 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
99
 
143
- it("treats an empty string template as valid", () => {
144
- expect(provider.get("billing", "empty_label", "en")).toBe("");
100
+ local.patchKeyMulti(
101
+ "billing",
102
+ "invoice_summary",
103
+ "en",
104
+ "{count, plural, one {1 bill} other {{count} bills}} for {name}"
105
+ );
106
+
107
+ expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
108
+ "2 bills for Bob"
109
+ );
110
+ expect(view().t("default", "login_button", "en")).toBe("Login");
145
111
  });
146
112
 
147
- it("tracks loaded namespaces with hasNamespace", () => {
113
+ it("adds namespace to dictionary with applyLoadMergeNamespace", () => {
148
114
  const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
149
115
  default: dictionary.default,
150
116
  });
117
+ expect("billing" in partial.getAll()).toBe(false);
151
118
 
152
- expect(partial.hasNamespace("default")).toBe(true);
153
- expect(partial.hasNamespace("billing")).toBe(false);
119
+ partial.applyLoadMergeNamespace("billing", dictionary.billing);
120
+ expect("billing" in partial.getAll()).toBe(true);
154
121
  });
155
122
 
156
- it("throws when getting from an unloaded namespace", () => {
157
- const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
158
- default: dictionary.default,
159
- });
123
+ it("adds namespace to dictionary on first applyLoadMergeNamespace", () => {
124
+ const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({});
125
+ expect("billing" in partial.getAll()).toBe(false);
160
126
 
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().'
165
- );
127
+ partial.applyLoadMergeNamespace("billing", dictionary.billing);
128
+ expect("billing" in partial.getAll()).toBe(true);
166
129
  });
167
130
 
168
- it("supports partial initialization", () => {
169
- const partial = new IcuTranslationProviderMulti<TestSchema, TestParams>({
170
- default: dictionary.default,
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
+ },
171
138
  });
139
+ const view = () => local.toScope({ namespaces: ["billing"] });
172
140
 
173
- expect(partial.get("default", "login_button", "en")).toBe("Login");
174
- });
175
-
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)'
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"
184
144
  );
185
- });
186
145
 
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
- );
191
- });
146
+ local.applyLoadMergeNamespace("billing", {
147
+ invoice_summary: {
148
+ it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
149
+ },
150
+ });
192
151
 
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(
152
+ expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
196
153
  "You have 2 invoices for Bob"
197
154
  );
155
+ expect(view().t("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
156
+ "Hai 2 fatture per Bob"
157
+ );
158
+ });
159
+
160
+ it("merges locales per key with applyLoadMergeNamespace without dropping existing locales", () => {
161
+ const local = new IcuTranslationProviderMulti<TestSchema, TestParams>({
162
+ billing: {
163
+ invoice_summary: {
164
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
165
+ },
166
+ },
167
+ });
168
+ const view = () => local.toScope({ namespaces: ["billing"] });
198
169
 
199
- local.setNamespace("billing", {
200
- ...dictionary.billing,
170
+ local.applyLoadMergeNamespace("billing", {
201
171
  invoice_summary: {
202
- en: "{count, plural, one {1 bill} other {{count} bills}}",
203
- it: dictionary.billing.invoice_summary.it,
172
+ it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
204
173
  },
205
174
  });
206
175
 
207
- expect(local.get("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
208
- "2 bills"
176
+ expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
177
+ "You have 2 invoices for Bob"
178
+ );
179
+ expect(view().t("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
180
+ "Hai 2 fatture per Bob"
209
181
  );
210
- expect(local.get("default", "login_button", "en")).toBe("Login");
211
182
  });
212
183
 
213
- it("replaces the full dictionary on setAll", () => {
184
+ it("patches a preloaded default namespace key", () => {
214
185
  const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary);
215
186
 
216
- local.setAll({
217
- ...dictionary,
218
- default: {
219
- ...dictionary.default,
220
- login_button: { en: "Sign in", it: "Entra" },
187
+ local.patchKeyMulti("default", "login_button", "en", "Sign in");
188
+
189
+ expect(local.toScope({ namespaces: ["default"] }).t("default", "login_button", "en")).toBe(
190
+ "Sign in"
191
+ );
192
+ });
193
+
194
+ it("merges namespaces with applyLoadMergeAll without dropping existing locales", () => {
195
+ const local = new IcuTranslationProviderMulti<TestSchema, TestParams>({
196
+ billing: {
197
+ invoice_summary: {
198
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
199
+ },
200
+ },
201
+ });
202
+ const view = () => local.toScope({ namespaces: ["default", "billing"] });
203
+
204
+ local.applyLoadMergeAll({
205
+ billing: {
206
+ invoice_summary: {
207
+ it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
208
+ },
221
209
  },
210
+ default: dictionary.default,
222
211
  });
223
212
 
224
- expect(local.get("default", "login_button", "en")).toBe("Sign in");
213
+ expect(view().t("billing", "invoice_summary", "en", { count: 2, name: "Bob" })).toBe(
214
+ "You have 2 invoices for Bob"
215
+ );
216
+ expect(view().t("billing", "invoice_summary", "it", { count: 2, name: "Bob" })).toBe(
217
+ "Hai 2 fatture per Bob"
218
+ );
219
+ expect(view().t("default", "login_button", "en")).toBe("Login");
225
220
  });
226
221
 
227
222
  it("returns a deep-frozen snapshot from getAll", () => {
228
- expect(provider.getAll()).toEqual(dictionary);
229
- expect(provider.getAll()).not.toBe(dictionary);
230
- 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);
231
226
  });
232
227
 
233
- it("does not mutate the provider when getAll snapshot is modified", () => {
234
- const snapshot = provider.getAll();
228
+ it("does not mutate the engine when getAll snapshot is modified", () => {
229
+ const snapshot = engine.getAll();
235
230
  expect(() => {
236
231
  snapshot.default.login_button.en = "Hacked";
237
232
  }).toThrow(TypeError);
238
- expect(provider.get("default", "login_button", "en")).toBe("Login");
239
- });
240
-
241
- describe("forLocale", () => {
242
- it("binds a locale so get() no longer requires it", () => {
243
- const it = provider.forLocale("it");
244
-
245
- expect(it.locale).toBe("it");
246
- expect(it.get("default", "login_button")).toBe("Accedi");
247
- expect(it.get("billing", "invoice_summary", { count: 2, name: "Ada" })).toBe(
248
- "Hai 2 fatture per Ada"
249
- );
250
- });
251
- });
252
-
253
- describe("locale fallback", () => {
254
- const fallbackMap = {
255
- en: null,
256
- "de-DE": "en",
257
- "de-CH": "de-DE",
258
- it: "en",
259
- } as const;
260
-
261
- const fallbackProvider = new IcuTranslationProviderMulti<
262
- TestSchema,
263
- TestParams,
264
- keyof typeof fallbackMap | LocaleOfMulti<TestSchema>,
265
- typeof fallbackMap
266
- >(dictionary, { localeFallback: fallbackMap });
267
-
268
- it("resolves a locale through the fallback chain", () => {
269
- expect(fallbackProvider.get("default", "login_button", "de-CH")).toBe("Login");
270
- });
271
-
272
- it("throws when the fallback chain cannot resolve a template", () => {
273
- const unresolvedFallback = {
274
- en: null,
275
- "de-CH": "fr",
276
- fr: null,
277
- } as const;
278
- const unresolvedProvider = new IcuTranslationProviderMulti<
279
- TestSchema,
280
- TestParams,
281
- keyof typeof unresolvedFallback | LocaleOfMulti<TestSchema>,
282
- typeof unresolvedFallback
283
- >(dictionary, { localeFallback: unresolvedFallback });
284
-
285
- expect(() => unresolvedProvider.get("default", "login_button", "de-CH")).toThrow(
286
- '[i18n] Missing key or locale: namespace "default", key "login_button" [de-CH] (fallback chain: de-CH → fr)'
287
- );
288
- });
289
- });
290
-
291
- describe("onMissing", () => {
292
- it('returns "namespace.key" with onMissing: "key"', () => {
293
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary, {
294
- onMissing: "key",
295
- });
296
- expect(local.get("default", "missing_key" as "login_button", "en")).toBe(
297
- "default.missing_key"
298
- );
299
- });
300
-
301
- it("calls a custom handler with namespace, key, locale, and fallback chain", () => {
302
- const contexts: unknown[] = [];
303
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary, {
304
- onMissing: (context) => {
305
- contexts.push(context);
306
- return `<missing:${context.namespace}/${context.key}>`;
307
- },
308
- });
309
-
310
- expect(local.get("default", "missing_key" as "login_button", "en")).toBe(
311
- "<missing:default/missing_key>"
312
- );
313
- expect(contexts).toEqual([
314
- { namespace: "default", key: "missing_key", locale: "en", fallbackChain: "en" },
315
- ]);
316
- });
317
-
318
- it("still throws for unloaded namespaces with a lenient onMissing", () => {
319
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(
320
- { default: dictionary.default },
321
- { onMissing: "key" }
322
- );
323
- expect(() => local.get("billing", "account_balance", "en", { amount: 1 })).toThrow(
324
- '[i18n] Namespace not loaded: "billing"'
325
- );
326
- });
327
-
328
- it("applies onMissing through forLocale wrappers", () => {
329
- const local = new IcuTranslationProviderMulti<TestSchema, TestParams>(dictionary, {
330
- onMissing: "key",
331
- });
332
- expect(local.forLocale("en").get("default", "missing_key" as "login_button")).toBe(
333
- "default.missing_key"
334
- );
335
- });
233
+ expect(engine.toScope({ namespaces: ["default"] }).t("default", "login_button", "en")).toBe(
234
+ "Login"
235
+ );
336
236
  });
337
237
  });
@@ -1,5 +1,13 @@
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";
10
+ import { mergeNamespaceLocalesCore } from "./project-locales.js";
3
11
  import { validateLocaleFallback } from "./resolve-locale.js";
4
12
  import type {
5
13
  IcuTranslationProviderOptions,
@@ -8,83 +16,34 @@ import type {
8
16
  MultiCompiledCache,
9
17
  MultiDictionary,
10
18
  OnMissingTranslation,
19
+ PartialKeyDictionary,
20
+ PartialMultiDictionary,
11
21
  } from "./types.js";
12
-
13
- export interface TranslationProviderMultiForLocale<
14
- Schema extends MultiDictionary,
15
- Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
16
- Locale extends string,
17
- > {
18
- readonly locale: Locale;
19
- get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
20
- namespace: NS,
21
- key: K,
22
- ...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
23
- ): string;
24
- }
25
-
26
- export interface TranslationProviderMulti<
27
- Schema extends MultiDictionary,
28
- Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
29
- RequestLocales extends string = LocaleOfMulti<Schema>,
30
- > {
31
- get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
32
- namespace: NS,
33
- key: K,
34
- locale: RequestLocales,
35
- ...params: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
36
- ): string;
37
- forLocale<Locale extends RequestLocales>(
38
- locale: Locale
39
- ): TranslationProviderMultiForLocale<Schema, Params, Locale>;
40
- getAll(): Schema;
41
- hasNamespace<NS extends keyof Schema & string>(namespace: NS): boolean;
42
- setAll(values: Schema): void;
43
- setNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void;
44
- }
45
-
46
- export class IcuTranslationProviderMultiForLocale<
47
- Schema extends MultiDictionary,
48
- Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
49
- RequestLocales extends string,
50
- Locale extends RequestLocales,
51
- Fallback extends LocaleFallbackMap | undefined,
52
- > implements TranslationProviderMultiForLocale<Schema, Params, Locale> {
53
- constructor(
54
- private readonly provider: IcuTranslationProviderMulti<
55
- Schema,
56
- Params,
57
- RequestLocales,
58
- Fallback
59
- >,
60
- readonly locale: Locale
61
- ) {}
62
-
63
- get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
64
- namespace: NS,
65
- key: K,
66
- ...args: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
67
- ): string {
68
- const params = args[0] as Record<string, unknown> | undefined;
69
- return this.provider.getWithLocale(String(namespace), String(key), this.locale, params);
70
- }
71
- }
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";
72
25
 
73
26
  export class IcuTranslationProviderMulti<
74
27
  Schema extends MultiDictionary,
75
- Params extends { [NS in keyof Schema]: { [K in keyof Schema[NS]]: unknown } },
28
+ Params extends MultiParams<Schema>,
76
29
  RequestLocales extends string = LocaleOfMulti<Schema>,
77
30
  Fallback extends LocaleFallbackMap | undefined = undefined,
78
- > implements TranslationProviderMulti<Schema, Params, RequestLocales> {
31
+ > implements I18nEngineMulti<Schema, Params, RequestLocales> {
32
+ readonly __i18nEngineMode = "multi" as const;
33
+
79
34
  private dictionary: Schema;
80
35
  private compiledCache: MultiCompiledCache = {};
36
+ private readonly preloadedKeys = new Set<string>();
37
+ private readonly builderLoadRegistry = new BuilderLoadRegistry();
81
38
  private readonly localeFallback?: Fallback;
82
39
  private readonly onMissing: OnMissingTranslation;
83
- private loadedNamespaces: Set<string>;
84
40
 
85
- constructor(dictionary: Partial<Schema>, options?: IcuTranslationProviderOptions<Fallback>) {
41
+ constructor(
42
+ dictionary: PartialMultiDictionary<Schema, RequestLocales>,
43
+ options?: IcuTranslationProviderOptions<Fallback>
44
+ ) {
86
45
  this.dictionary = structuredClone(dictionary) as Schema;
87
- this.loadedNamespaces = new Set(Object.keys(dictionary));
46
+ seedPreloadedKeysMulti(this.dictionary, this.preloadedKeys);
88
47
  if (options?.localeFallback) {
89
48
  validateLocaleFallback(options.localeFallback);
90
49
  this.localeFallback = options.localeFallback;
@@ -92,28 +51,12 @@ export class IcuTranslationProviderMulti<
92
51
  this.onMissing = options?.onMissing ?? "throw";
93
52
  }
94
53
 
95
- get<NS extends keyof Schema, K extends keyof Schema[NS] & keyof Params[NS] & string>(
96
- namespace: NS,
97
- key: K,
98
- locale: RequestLocales,
99
- ...args: Params[NS][K] extends never ? [] : [params: Params[NS][K]]
100
- ): string {
101
- const params = args[0] as Record<string, unknown> | undefined;
102
- return this.getWithLocale(String(namespace), String(key), locale, params);
103
- }
104
-
105
54
  getWithLocale(
106
55
  namespace: string,
107
56
  key: string,
108
57
  locale: string,
109
58
  params?: Record<string, unknown>
110
59
  ): string {
111
- if (!this.loadedNamespaces.has(namespace)) {
112
- throw new Error(
113
- `[i18n] Namespace not loaded: "${namespace}". Register it with setNamespace() before calling .get().`
114
- );
115
- }
116
-
117
60
  const localeByKey = (
118
61
  this.dictionary[namespace] as Record<string, Record<string, string>> | undefined
119
62
  )?.[key];
@@ -141,35 +84,103 @@ export class IcuTranslationProviderMulti<
141
84
  });
142
85
  }
143
86
 
144
- forLocale<Locale extends RequestLocales>(
145
- locale: Locale
146
- ): IcuTranslationProviderMultiForLocale<Schema, Params, RequestLocales, Locale, Fallback> {
147
- 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
+ >;
148
123
  }
149
124
 
150
125
  getAll(): Schema {
151
126
  return cloneAndFreeze(this.dictionary);
152
127
  }
153
128
 
154
- hasNamespace<NS extends keyof Schema & string>(namespace: NS): boolean {
155
- return this.loadedNamespaces.has(namespace);
129
+ hasBuilderResourceLoaded(namespace: string, partition: string | undefined): boolean {
130
+ return this.builderLoadRegistry.has(namespace, partition);
156
131
  }
157
132
 
158
- setAll(values: Schema): void {
159
- this.dictionary = structuredClone(values);
160
- this.compiledCache = {};
161
- this.loadedNamespaces = new Set(Object.keys(values));
133
+ markBuilderResourceLoaded(namespace: string, partition: string | undefined): void {
134
+ this.builderLoadRegistry.mark(namespace, partition);
162
135
  }
163
136
 
164
- setNamespace<NS extends keyof Schema>(namespace: NS, values: Schema[NS]): void {
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);
143
+
165
144
  this.dictionary = {
166
145
  ...this.dictionary,
167
- [namespace]: structuredClone(values),
146
+ [namespace]: merged,
168
147
  };
169
- 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
+ }
154
+
155
+ for (const locale of Object.keys(incomingLocales)) {
156
+ delete this.compiledCache[locale]?.[namespace as string]?.[key];
157
+ }
158
+ }
159
+ }
170
160
 
171
- for (const locale of Object.keys(this.compiledCache)) {
172
- delete this.compiledCache[locale]?.[namespace as string];
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
+ }
173
167
  }
174
168
  }
169
+
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
+ );
185
+ }
175
186
  }