@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 { 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,171 +81,62 @@ 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
- });
86
+ it("patches a preloaded key and invalidates cache", () => {
87
+ const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
88
+ expect(local.toScope().t("login_button", "en")).toBe("Login");
111
89
 
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
- });
90
+ local.patchKey("login_button", "en", "Sign in");
126
91
 
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
- });
92
+ expect(local.toScope().t("login_button", "en")).toBe("Sign in");
93
+ expect(local.toScope().t("login_button", "it")).toBe("Accedi");
132
94
  });
133
95
 
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");
96
+ it("merges locales per key with applyLoadMergeSingle without dropping existing locales", () => {
97
+ const local = new IcuTranslationProviderSingle<TestSchema, TestParams>({
98
+ ...dictionary,
99
+ // @ts-expect-error missing locale
100
+ welcome: {
101
+ en: "Welcome {name}!",
102
+ },
158
103
  });
159
104
 
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}`);
105
+ local.applyLoadMergeSingle({
106
+ welcome: {
107
+ // @ts-expect-error adding locale via merge
108
+ it: "Benvenuto {name}!",
109
+ },
182
110
  });
183
111
 
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
- });
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");
202
116
  });
203
117
 
204
- it("treats an empty string template as valid", () => {
205
- expect(provider.get("empty_label", "en")).toBe("");
206
- });
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);
207
122
 
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)'
123
+ expect(() => local.patchKey("login_button", "it", "Accedi")).toThrow(
124
+ "[i18n] Key not preloaded: login_button (it)"
214
125
  );
215
126
  });
216
127
 
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", () => {
227
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary);
228
- expect(local.get("login_button", "en")).toBe("Login");
229
-
230
- local.setAll({
231
- ...dictionary,
232
- login_button: { en: "Sign in", it: "Entra" },
233
- });
234
-
235
- expect(local.get("login_button", "en")).toBe("Sign in");
236
- });
237
-
238
128
  it("returns a deep-frozen snapshot from getAll", () => {
239
- expect(provider.getAll()).toEqual(dictionary);
240
- expect(provider.getAll()).not.toBe(dictionary);
241
- 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);
242
132
  });
243
133
 
244
- it("does not mutate the provider when getAll snapshot is modified", () => {
245
- const snapshot = provider.getAll();
134
+ it("does not mutate the engine when getAll snapshot is modified", () => {
135
+ const snapshot = engine.getAll();
246
136
  expect(() => {
247
137
  snapshot.welcome.en = "Hacked";
248
138
  }).toThrow(TypeError);
249
- expect(provider.get("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
139
+ expect(engine.toScope().t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
250
140
  });
251
141
 
252
142
  it("does not reflect external dictionary mutations after construction", () => {
@@ -271,91 +161,10 @@ describe("IcuTranslationProviderSingle", () => {
271
161
  };
272
162
  const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(external);
273
163
  external.login_button.en = "Sign in";
274
- expect(local.get("login_button", "en")).toBe("Login");
275
- });
276
-
277
- describe("forLocale", () => {
278
- it("binds a locale so get() no longer requires it", () => {
279
- const en = provider.forLocale("en");
280
-
281
- expect(en.locale).toBe("en");
282
- expect(en.get("login_button")).toBe("Login");
283
- expect(en.get("welcome", { name: "Ada" })).toBe("Welcome Ada!");
284
- });
285
-
286
- it("uses the parent provider cache and fallback rules", () => {
287
- const fallbackMap = {
288
- en: null,
289
- "de-CH": "en",
290
- } as const;
291
- const fallbackProvider = new IcuTranslationProviderSingle<
292
- TestSchema,
293
- TestParams,
294
- keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
295
- typeof fallbackMap
296
- >(dictionary, { localeFallback: fallbackMap });
297
- const deCh = fallbackProvider.forLocale("de-CH");
298
-
299
- expect(deCh.get("login_button")).toBe("Login");
300
- });
164
+ expect(local.toScope().t("login_button", "en")).toBe("Login");
301
165
  });
302
166
 
303
167
  describe("locale fallback", () => {
304
- const fallbackMap = {
305
- en: null,
306
- "de-DE": "en",
307
- "de-CH": "de-DE",
308
- it: "en",
309
- } as const;
310
-
311
- const fallbackProvider = new IcuTranslationProviderSingle<
312
- TestSchema,
313
- TestParams,
314
- keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
315
- typeof fallbackMap
316
- >(dictionary, { localeFallback: fallbackMap });
317
-
318
- it("resolves a locale through the fallback chain", () => {
319
- expect(fallbackProvider.get("login_button", "de-CH")).toBe("Login");
320
- });
321
-
322
- it("uses an intermediate locale template when available", () => {
323
- const withGerman = new IcuTranslationProviderSingle<
324
- TestSchema & {
325
- login_button: { en: string; it: string; "de-DE": string };
326
- },
327
- TestParams,
328
- keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
329
- typeof fallbackMap
330
- >(
331
- {
332
- ...dictionary,
333
- login_button: { en: "Login", it: "Accedi", "de-DE": "Anmelden" },
334
- },
335
- { localeFallback: fallbackMap }
336
- );
337
-
338
- expect(withGerman.get("login_button", "de-CH")).toBe("Anmelden");
339
- });
340
-
341
- it("throws when the fallback chain cannot resolve a template", () => {
342
- const unresolvedFallback = {
343
- en: null,
344
- "de-CH": "fr",
345
- fr: null,
346
- } as const;
347
- const unresolvedProvider = new IcuTranslationProviderSingle<
348
- TestSchema,
349
- TestParams,
350
- keyof typeof unresolvedFallback | LocaleOfSingle<TestSchema>,
351
- typeof unresolvedFallback
352
- >(dictionary, { localeFallback: unresolvedFallback });
353
-
354
- expect(() => unresolvedProvider.get("login_button", "de-CH")).toThrow(
355
- '[i18n] Missing key or locale: "login_button" [de-CH] (fallback chain: de-CH → fr)'
356
- );
357
- });
358
-
359
168
  it("rejects circular fallback maps at construction", () => {
360
169
  expect(
361
170
  () =>
@@ -365,71 +174,4 @@ describe("IcuTranslationProviderSingle", () => {
365
174
  ).toThrow("[i18n] Circular locale fallback detected");
366
175
  });
367
176
  });
368
-
369
- describe("onMissing", () => {
370
- it('throws on missing translations with onMissing: "throw" (explicit default)', () => {
371
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
372
- onMissing: "throw",
373
- });
374
- expect(() => local.get("missing_key" as "login_button", "en")).toThrow(
375
- '[i18n] Missing key or locale: "missing_key" [en] (fallback chain: en)'
376
- );
377
- });
378
-
379
- it('returns the key itself with onMissing: "key"', () => {
380
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
381
- onMissing: "key",
382
- });
383
- expect(local.get("missing_key" as "login_button", "en")).toBe("missing_key");
384
- });
385
-
386
- it("calls a custom handler with the missing-translation context", () => {
387
- const contexts: unknown[] = [];
388
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
389
- onMissing: (context) => {
390
- contexts.push(context);
391
- return `<missing:${context.key}>`;
392
- },
393
- });
394
-
395
- expect(local.get("missing_key" as "login_button", "en")).toBe("<missing:missing_key>");
396
- expect(contexts).toEqual([{ key: "missing_key", locale: "en", fallbackChain: "en" }]);
397
- });
398
-
399
- it("includes the walked fallback chain in the handler context", () => {
400
- const fallbackMap = { en: null, "de-CH": "fr", fr: null } as const;
401
- const contexts: { fallbackChain: string }[] = [];
402
- const local = new IcuTranslationProviderSingle<
403
- TestSchema,
404
- TestParams,
405
- keyof typeof fallbackMap | LocaleOfSingle<TestSchema>,
406
- typeof fallbackMap
407
- >(dictionary, {
408
- localeFallback: fallbackMap,
409
- onMissing: (context) => {
410
- contexts.push(context);
411
- return context.key;
412
- },
413
- });
414
-
415
- expect(local.get("login_button", "de-CH")).toBe("login_button");
416
- expect(contexts[0]?.fallbackChain).toBe("de-CH → fr");
417
- });
418
-
419
- it("still throws ICU syntax and formatting errors with a lenient onMissing", () => {
420
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
421
- onMissing: "key",
422
- });
423
- expect(() => local.get("broken", "en", { name: "Ada" })).toThrow("[i18n ICU Syntax Error]");
424
- // @ts-expect-error
425
- expect(() => local.get("welcome", "en")).toThrow("[i18n Formatting Error]");
426
- });
427
-
428
- it("applies onMissing through forLocale wrappers", () => {
429
- const local = new IcuTranslationProviderSingle<TestSchema, TestParams>(dictionary, {
430
- onMissing: "key",
431
- });
432
- expect(local.forLocale("en").get("missing_key" as "login_button")).toBe("missing_key");
433
- });
434
- });
435
177
  });
@@ -1,5 +1,13 @@
1
1
  import { cloneAndFreeze } from "./deep-freeze.js";
2
+ import { BuilderLoadRegistry, SINGLE_BUILDER_RESOURCE } from "./builder-load-registry.js";
3
+ import type { I18nEngineSingle } from "./engine.js";
2
4
  import { resolveAndFormat } from "./format-core.js";
5
+ import {
6
+ assertPatchKeySingle,
7
+ recordPreloadedKeysSingle,
8
+ seedPreloadedKeysSingle,
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,76 +16,28 @@ import type {
8
16
  LocaleOfSingle,
9
17
  OnMissingTranslation,
10
18
  SingleCompiledCache,
19
+ PartialKeyDictionary,
11
20
  } from "./types.js";
12
-
13
- export interface TranslationProviderSingleForLocale<
14
- Schema extends KeyDictionary,
15
- Params extends { [K in keyof Schema]: unknown },
16
- Locale extends string,
17
- > {
18
- readonly locale: Locale;
19
- get<K extends keyof Schema & string>(
20
- key: K,
21
- ...params: Params[K] extends never ? [] : [params: Params[K]]
22
- ): string;
23
- }
24
-
25
- export interface TranslationProviderSingle<
26
- Schema extends KeyDictionary,
27
- Params extends { [K in keyof Schema]: unknown },
28
- RequestLocales extends string = LocaleOfSingle<Schema>,
29
- > {
30
- get<K extends keyof Schema & string>(
31
- key: K,
32
- locale: RequestLocales,
33
- ...params: Params[K] extends never ? [] : [params: Params[K]]
34
- ): string;
35
- forLocale<Locale extends RequestLocales>(
36
- locale: Locale
37
- ): TranslationProviderSingleForLocale<Schema, Params, Locale>;
38
- getAll(): Schema;
39
- setAll(values: Schema): void;
40
- }
41
-
42
- export class IcuTranslationProviderSingleForLocale<
43
- Schema extends KeyDictionary,
44
- Params extends { [K in keyof Schema]: unknown },
45
- RequestLocales extends string,
46
- Locale extends RequestLocales,
47
- Fallback extends LocaleFallbackMap | undefined,
48
- > implements TranslationProviderSingleForLocale<Schema, Params, Locale> {
49
- constructor(
50
- private readonly provider: IcuTranslationProviderSingle<
51
- Schema,
52
- Params,
53
- RequestLocales,
54
- Fallback
55
- >,
56
- readonly locale: Locale
57
- ) {}
58
-
59
- get<K extends keyof Schema & string>(
60
- key: K,
61
- ...args: Params[K] extends never ? [] : [params: Params[K]]
62
- ): string {
63
- const params = args[0] as Record<string, unknown> | undefined;
64
- return this.provider.getWithLocale(String(key), this.locale, params);
65
- }
66
- }
21
+ import { I18nScopeSingleForLocaleImpl, I18nScopeSingleImpl } from "./scope-single.js";
67
22
 
68
23
  export class IcuTranslationProviderSingle<
69
24
  Schema extends KeyDictionary,
70
25
  Params extends { [K in keyof Schema]: unknown },
71
26
  RequestLocales extends string = LocaleOfSingle<Schema>,
72
27
  Fallback extends LocaleFallbackMap | undefined = undefined,
73
- > implements TranslationProviderSingle<Schema, Params, RequestLocales> {
28
+ > implements I18nEngineSingle<Schema, Params, RequestLocales> {
29
+ readonly __i18nEngineMode = "single" as const;
30
+
74
31
  private dictionary: Schema;
75
32
  private compiledCache: SingleCompiledCache = {};
33
+ private readonly preloadedKeys = new Set<string>();
34
+ private readonly builderLoadRegistry = new BuilderLoadRegistry();
76
35
  private readonly localeFallback?: Fallback;
77
36
  private readonly onMissing: OnMissingTranslation;
78
37
 
79
38
  constructor(dictionary: Schema, options?: IcuTranslationProviderOptions<Fallback>) {
80
39
  this.dictionary = structuredClone(dictionary);
40
+ seedPreloadedKeysSingle(this.dictionary, this.preloadedKeys);
81
41
  if (options?.localeFallback) {
82
42
  validateLocaleFallback(options.localeFallback);
83
43
  this.localeFallback = options.localeFallback;
@@ -85,15 +45,6 @@ export class IcuTranslationProviderSingle<
85
45
  this.onMissing = options?.onMissing ?? "throw";
86
46
  }
87
47
 
88
- get<K extends keyof Schema & string>(
89
- key: K,
90
- locale: RequestLocales,
91
- ...args: Params[K] extends never ? [] : [params: Params[K]]
92
- ): string {
93
- const params = args[0] as Record<string, unknown> | undefined;
94
- return this.getWithLocale(String(key), locale, params);
95
- }
96
-
97
48
  getWithLocale(key: string, locale: string, params?: Record<string, unknown>): string {
98
49
  return resolveAndFormat({
99
50
  localeByKey: this.dictionary[key],
@@ -114,18 +65,50 @@ export class IcuTranslationProviderSingle<
114
65
  });
115
66
  }
116
67
 
117
- forLocale<Locale extends RequestLocales>(
118
- locale: Locale
119
- ): IcuTranslationProviderSingleForLocale<Schema, Params, RequestLocales, Locale, Fallback> {
120
- return new IcuTranslationProviderSingleForLocale(this, locale);
68
+ toScope(): I18nScopeSingleImpl<Schema, Params, RequestLocales, Fallback>;
69
+ toScope<Locale extends RequestLocales>(options: {
70
+ locale: Locale;
71
+ }): I18nScopeSingleForLocaleImpl<Schema, Params, RequestLocales, Locale, Fallback>;
72
+ toScope<Locale extends RequestLocales>(options?: { locale?: Locale }) {
73
+ if (options?.locale !== undefined) {
74
+ return new I18nScopeSingleForLocaleImpl(this, options.locale);
75
+ }
76
+ return new I18nScopeSingleImpl(this);
121
77
  }
122
78
 
123
79
  getAll(): Schema {
124
80
  return cloneAndFreeze(this.dictionary);
125
81
  }
126
82
 
127
- setAll(values: Schema): void {
128
- this.dictionary = structuredClone(values);
129
- this.compiledCache = {};
83
+ hasBuilderResourceLoaded(partition: string | undefined): boolean {
84
+ return this.builderLoadRegistry.has(SINGLE_BUILDER_RESOURCE, partition);
85
+ }
86
+
87
+ markBuilderResourceLoaded(partition: string | undefined): void {
88
+ this.builderLoadRegistry.mark(SINGLE_BUILDER_RESOURCE, partition);
89
+ }
90
+
91
+ applyLoadMergeSingle(values: PartialKeyDictionary<Schema, RequestLocales>): void {
92
+ this.dictionary = mergeNamespaceLocalesCore(this.dictionary, values);
93
+ recordPreloadedKeysSingle(values, this.preloadedKeys);
94
+
95
+ for (const [key, incomingLocales] of Object.entries(values)) {
96
+ if (incomingLocales === undefined || typeof incomingLocales !== "object") {
97
+ continue;
98
+ }
99
+
100
+ for (const locale of Object.keys(incomingLocales)) {
101
+ delete this.compiledCache[locale]?.[key];
102
+ }
103
+ }
104
+ }
105
+
106
+ patchKey(key: string, locale: string, template: string): void {
107
+ assertPatchKeySingle(key, locale, template, this.preloadedKeys, this.dictionary[key]);
108
+
109
+ this.dictionary = mergeNamespaceLocalesCore(this.dictionary, {
110
+ [key]: { [locale]: template },
111
+ } as PartialKeyDictionary<Schema, RequestLocales>);
112
+ delete this.compiledCache[locale]?.[key];
130
113
  }
131
114
  }
@@ -0,0 +1,18 @@
1
+ /** Stable key for a lazy-loaded builder resource (namespace + locale or delivery area). */
2
+ export function formatBuilderResourceKey(namespace: string, partition: string | undefined): string {
3
+ return `${namespace}\0${partition ?? ""}`;
4
+ }
5
+
6
+ export const SINGLE_BUILDER_RESOURCE = "__dictionary__";
7
+
8
+ export class BuilderLoadRegistry {
9
+ private readonly loaded = new Set<string>();
10
+
11
+ has(namespace: string, partition: string | undefined): boolean {
12
+ return this.loaded.has(formatBuilderResourceKey(namespace, partition));
13
+ }
14
+
15
+ mark(namespace: string, partition: string | undefined): void {
16
+ this.loaded.add(formatBuilderResourceKey(namespace, partition));
17
+ }
18
+ }
@@ -0,0 +1,18 @@
1
+ /** Canonical lazy loader — no locale or delivery-area partition. */
2
+ export type CanonicalLoader<T> = () => Promise<T> | T;
3
+
4
+ /** Split-by-locale or custom-area loader — partition key is locale or area name. */
5
+ export type PartitionedLoader<T> = (partition: string) => Promise<T> | T;
6
+
7
+ export type NamespaceLoader<T> = CanonicalLoader<T> | PartitionedLoader<T>;
8
+
9
+ export async function invokeNamespaceLoader<T>(
10
+ loader: NamespaceLoader<T>,
11
+ partition: string | undefined
12
+ ): Promise<T> {
13
+ if (partition === undefined) {
14
+ return await (loader as CanonicalLoader<T>)();
15
+ }
16
+
17
+ return await (loader as PartitionedLoader<T>)(partition);
18
+ }