@xndrjs/i18n 0.6.1 → 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 -141
  2. package/dist/codegen/index.js.map +1 -1
  3. package/dist/index.d.ts +268 -69
  4. package/dist/index.js +572 -65
  5. package/dist/index.js.map +1 -1
  6. package/dist/validation/index.d.ts +23 -7
  7. package/dist/validation/index.js +196 -89
  8. package/dist/validation/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/IcuTranslationProviderMulti.test.ts +80 -234
  11. package/src/IcuTranslationProviderMulti.ts +101 -112
  12. package/src/IcuTranslationProviderSingle.test.ts +30 -309
  13. package/src/IcuTranslationProviderSingle.ts +42 -75
  14. package/src/builder-load-registry.ts +18 -0
  15. package/src/builder-loaders.ts +18 -0
  16. package/src/builder-multi.ts +481 -0
  17. package/src/builder-types.test.ts +80 -0
  18. package/src/builder-types.ts +24 -0
  19. package/src/builder.test.ts +421 -0
  20. package/src/builder.ts +112 -0
  21. package/src/codegen/delivery-artifacts.test.ts +2 -1
  22. package/src/codegen/delivery-artifacts.ts +2 -1
  23. package/src/codegen/emit/dictionary-file.test.ts +0 -7
  24. package/src/codegen/emit/dictionary-schema-file.ts +55 -42
  25. package/src/codegen/emit/instance-file.test.ts +88 -0
  26. package/src/codegen/emit/instance-file.ts +164 -17
  27. package/src/codegen/emit/namespace-loaders-file.test.ts +4 -18
  28. package/src/codegen/emit/namespace-loaders-file.ts +4 -54
  29. package/src/codegen/emit/types-file.test.ts +0 -2
  30. package/src/codegen/generate-i18n-types.test.ts +8 -303
  31. package/src/codegen/generate-i18n-types.ts +16 -1
  32. package/src/codegen/project-locales-set-namespace.test.ts +25 -32
  33. package/src/engine.ts +71 -0
  34. package/src/index.ts +45 -7
  35. package/src/patch-key.test.ts +186 -0
  36. package/src/patch-key.ts +140 -0
  37. package/src/project-locales.test.ts +16 -6
  38. package/src/project-locales.ts +17 -6
  39. package/src/scope-multi.ts +124 -0
  40. package/src/scope-single.ts +85 -0
  41. package/src/scope-types.ts +27 -0
  42. package/src/scope.test.ts +481 -0
  43. package/src/single-builder.ts +153 -0
  44. package/src/types.ts +16 -0
  45. package/src/validation/create-normalized-schema.ts +1 -1
  46. package/src/validation/errors.ts +2 -0
  47. package/src/validation/index.ts +135 -27
  48. package/src/validation/normalize.ts +176 -46
  49. package/src/validation/types.ts +4 -0
  50. package/src/validation/validate-normalized.ts +43 -1
  51. package/src/validation/validation.test.ts +200 -7
@@ -0,0 +1,80 @@
1
+ import { describe, expectTypeOf, it } from "vitest";
2
+ import { createI18nMultiBuilder } from "./builder.js";
3
+ import type { I18nBuilderMultiInitialImpl, I18nBuilderMultiReadyImpl } from "./builder-multi.js";
4
+ import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
5
+ import type { LocalesForDeliveryArea, LocalesForDeliveryAreaOrAll } from "./builder-types.js";
6
+
7
+ type TypestateSchema = {
8
+ billing: { invoice_summary: { en: string; it: string } };
9
+ };
10
+
11
+ type TypestateParams = {
12
+ billing: { invoice_summary: { count: number } };
13
+ };
14
+
15
+ function assertBuilderTypestate(): void {
16
+ const engine = new IcuTranslationProviderMulti<TypestateSchema, TypestateParams>({});
17
+ const initial = createI18nMultiBuilder(engine);
18
+
19
+ // @ts-expect-error withLocale requires withNamespaces first
20
+ initial.withLocale("en");
21
+ // @ts-expect-error load requires withNamespaces first
22
+ void initial.load();
23
+ // @ts-expect-error withDeliveryArea requires withNamespaces first
24
+ initial.withDeliveryArea("eu");
25
+ // @ts-expect-error empty namespace list is rejected
26
+ initial.withNamespaces([]);
27
+
28
+ const ready = initial.withNamespaces(["billing"]);
29
+ ready.withLocale("en");
30
+ void ready.load();
31
+ }
32
+
33
+ void assertBuilderTypestate;
34
+
35
+ describe("builder-types", () => {
36
+ type ProjectLocale = "en" | "en-US" | "it" | "fr";
37
+ type DeliveryArea = "eu" | "amer";
38
+ type Artifacts = {
39
+ eu: readonly ["en", "it", "fr"];
40
+ amer: readonly ["en-US"];
41
+ };
42
+
43
+ it("LocalesForDeliveryArea indexes artifact locales", () => {
44
+ expectTypeOf<LocalesForDeliveryArea<Artifacts, "eu">>().toEqualTypeOf<"en" | "it" | "fr">();
45
+ expectTypeOf<LocalesForDeliveryArea<Artifacts, "amer">>().toEqualTypeOf<"en-US">();
46
+ });
47
+
48
+ it("LocalesForDeliveryAreaOrAll narrows only when a delivery map is configured", () => {
49
+ expectTypeOf<
50
+ LocalesForDeliveryAreaOrAll<ProjectLocale, DeliveryArea, Artifacts, "eu">
51
+ >().toEqualTypeOf<"en" | "it" | "fr">();
52
+ expectTypeOf<
53
+ LocalesForDeliveryAreaOrAll<ProjectLocale, never, Record<string, never>, "eu">
54
+ >().toEqualTypeOf<ProjectLocale>();
55
+ });
56
+ });
57
+
58
+ describe("I18nBuilderMulti typestate", () => {
59
+ it("initial builder only exposes withNamespaces", () => {
60
+ const engine = new IcuTranslationProviderMulti<TypestateSchema, TypestateParams>({});
61
+ const initial = createI18nMultiBuilder(engine);
62
+ expectTypeOf(initial).toEqualTypeOf<
63
+ I18nBuilderMultiInitialImpl<TypestateSchema, TypestateParams>
64
+ >();
65
+ });
66
+
67
+ it("ready builder exposes partition and load", () => {
68
+ const engine = new IcuTranslationProviderMulti<TypestateSchema, TypestateParams>({});
69
+ const ready = createI18nMultiBuilder(engine).withNamespaces(["billing"]);
70
+ expectTypeOf(ready).toEqualTypeOf<
71
+ I18nBuilderMultiReadyImpl<
72
+ TypestateSchema,
73
+ TypestateParams,
74
+ "en" | "it",
75
+ "en" | "it",
76
+ readonly ["billing"]
77
+ >
78
+ >();
79
+ });
80
+ });
@@ -0,0 +1,24 @@
1
+ /** Locales served by a delivery area, from a codegen `DELIVERY_ARTIFACTS`-shaped map. */
2
+ export type LocalesForDeliveryArea<
3
+ Artifacts extends Record<string, readonly string[]>,
4
+ Area extends keyof Artifacts & string,
5
+ > = Artifacts[Area][number];
6
+
7
+ /** Map from delivery area to locale lists; empty when custom delivery is not configured. */
8
+ export type DeliveryArtifactsMap<RequestLocales extends string, DeliveryArea extends string> = [
9
+ DeliveryArea,
10
+ ] extends [never]
11
+ ? Record<string, never>
12
+ : Record<DeliveryArea, readonly RequestLocales[]>;
13
+
14
+ /** When a delivery-area map is configured, resolve partition locales; otherwise keep the full pool. */
15
+ export type LocalesForDeliveryAreaOrAll<
16
+ RequestLocales extends string,
17
+ DeliveryArea extends string,
18
+ DeliveryArtifacts extends DeliveryArtifactsMap<RequestLocales, DeliveryArea>,
19
+ Area extends string,
20
+ > = [DeliveryArea] extends [never]
21
+ ? RequestLocales
22
+ : Area extends DeliveryArea
23
+ ? DeliveryArtifacts[Area][number]
24
+ : never;
@@ -0,0 +1,421 @@
1
+ import { describe, expect, expectTypeOf, it, vi } from "vitest";
2
+ import { createI18nBuilder, createI18nMultiBuilder } from "./builder.js";
3
+ import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
4
+ import { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
5
+
6
+ type SingleSchema = {
7
+ login_button: { en: string; it: string };
8
+ welcome: { en: string; it: string };
9
+ };
10
+
11
+ type SingleParams = {
12
+ login_button: never;
13
+ welcome: { name: string };
14
+ };
15
+
16
+ type MultiSchema = {
17
+ default: {
18
+ login_button: { en: string; it: string };
19
+ welcome: { en: string };
20
+ };
21
+ billing: {
22
+ invoice_summary: { en: string; it: string };
23
+ };
24
+ };
25
+
26
+ type TestMultiParams = {
27
+ default: {
28
+ login_button: never;
29
+ welcome: { name: string };
30
+ };
31
+ billing: {
32
+ invoice_summary: { count: number; name: string };
33
+ };
34
+ };
35
+
36
+ type TestDeliveryArea = "eu" | "us";
37
+ type TestDeliveryArtifacts = {
38
+ eu: readonly ["it"];
39
+ us: readonly ["en"];
40
+ };
41
+
42
+ const billingIt = {
43
+ invoice_summary: {
44
+ it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
45
+ },
46
+ };
47
+
48
+ const billingEn = {
49
+ invoice_summary: {
50
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
51
+ },
52
+ };
53
+
54
+ const defaultIt = {
55
+ login_button: { it: "Accedi" },
56
+ welcome: { it: "Benvenuto {name}!" },
57
+ };
58
+
59
+ describe("I18nBuilder multi", () => {
60
+ it("load() invokes mock loaders, merges into the engine, and returns a bound view", async () => {
61
+ const billingLoader = vi.fn(async (locale: string) => {
62
+ return locale === "it" ? billingIt : billingEn;
63
+ });
64
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
65
+ const builder = createI18nBuilder(engine, {
66
+ namespaceLoaders: {
67
+ billing: billingLoader,
68
+ },
69
+ });
70
+
71
+ const view = await builder.withNamespaces(["billing"]).withLocale("it").load();
72
+
73
+ expect(billingLoader).toHaveBeenCalledWith("it");
74
+ expect(view.locale).toBe("it");
75
+ expect(view.t("billing", "invoice_summary", { count: 2, name: "Ada" })).toBe(
76
+ "Hai 2 fatture per Ada"
77
+ );
78
+ });
79
+
80
+ it("chains withNamespaces and withLocale before load()", async () => {
81
+ const billingLoader = vi.fn(async (locale: string) =>
82
+ locale === "it" ? billingIt : billingEn
83
+ );
84
+ const defaultLoader = vi.fn(async () => defaultIt);
85
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
86
+
87
+ const view = await createI18nMultiBuilder(engine, {
88
+ namespaceLoaders: {
89
+ billing: billingLoader,
90
+ default: defaultLoader,
91
+ },
92
+ })
93
+ .withNamespaces(["billing", "default"])
94
+ .withLocale("it")
95
+ .load();
96
+
97
+ expect(billingLoader).toHaveBeenCalledWith("it");
98
+ expect(defaultLoader).toHaveBeenCalledWith("it");
99
+ expect(view.t("default", "login_button")).toBe("Accedi");
100
+ expect(view.t("billing", "invoice_summary", { count: 1, name: "Bob" })).toBe(
101
+ "Hai 1 fattura per Bob"
102
+ );
103
+ });
104
+
105
+ it("patches a preloaded key via scope.set() after load()", async () => {
106
+ const billingLoader = vi.fn(async () => billingEn);
107
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
108
+
109
+ const view = await createI18nBuilder(engine, {
110
+ namespaceLoaders: { billing: billingLoader },
111
+ })
112
+ .withNamespaces(["billing"])
113
+ .withLocale("en")
114
+ .load();
115
+
116
+ view.set(
117
+ "billing",
118
+ "invoice_summary",
119
+ "You have {count, plural, one {1 invoice only} other {{count} invoices only}} for {name}"
120
+ );
121
+
122
+ expect(view.t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
123
+ "You have 1 invoice only for Ada"
124
+ );
125
+ });
126
+
127
+ it("accumulates engine data across repeated load() calls on the same builder", async () => {
128
+ const billingLoader = vi.fn(async (locale: string) =>
129
+ locale === "it" ? billingIt : billingEn
130
+ );
131
+ const defaultLoader = vi.fn(async () => defaultIt);
132
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
133
+ const builder = createI18nBuilder(engine, {
134
+ namespaceLoaders: {
135
+ billing: billingLoader,
136
+ default: defaultLoader,
137
+ },
138
+ });
139
+
140
+ const billingView = await builder.withNamespaces(["billing"]).withLocale("it").load();
141
+ const defaultView = await builder.withNamespaces(["default"]).withLocale("it").load();
142
+
143
+ expect(billingLoader).toHaveBeenCalledTimes(1);
144
+ expect(defaultLoader).toHaveBeenCalledTimes(1);
145
+ expect(billingView.t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
146
+ "Hai 1 fattura per Ada"
147
+ );
148
+ expect(defaultView.t("default", "login_button")).toBe("Accedi");
149
+ expect(engine.getAll().billing.invoice_summary.it).toBe(billingIt.invoice_summary.it);
150
+ expect(engine.getAll().default.login_button.it).toBe("Accedi");
151
+ });
152
+
153
+ it("returns a view scoped to namespaces declared at load time", async () => {
154
+ const billingLoader = vi.fn(async (locale: string) =>
155
+ locale === "it" ? billingIt : billingEn
156
+ );
157
+ const defaultLoader = vi.fn(async () => defaultIt);
158
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
159
+ const builder = createI18nBuilder(engine, {
160
+ namespaceLoaders: {
161
+ billing: billingLoader,
162
+ default: defaultLoader,
163
+ },
164
+ });
165
+
166
+ const billingView = await builder.withNamespaces(["billing"]).withLocale("it").load();
167
+ await builder.withNamespaces(["default"]).withLocale("it").load();
168
+
169
+ expect(billingView.t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
170
+ "Hai 1 fattura per Ada"
171
+ );
172
+ // billingView is typed for the billing namespace only; default is excluded at compile time.
173
+ });
174
+
175
+ it("loads via withDeliveryArea for custom delivery partitions", async () => {
176
+ const billingLoader = vi.fn(async (area: string) => {
177
+ return area === "eu" ? billingIt : billingEn;
178
+ });
179
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
180
+
181
+ const euView = await createI18nMultiBuilder<
182
+ MultiSchema,
183
+ TestMultiParams,
184
+ "en" | "it",
185
+ TestDeliveryArea,
186
+ TestDeliveryArtifacts
187
+ >(engine, {
188
+ namespaceLoaders: {
189
+ billing: billingLoader,
190
+ },
191
+ })
192
+ .withNamespaces(["billing"])
193
+ .withDeliveryArea("eu")
194
+ .load();
195
+
196
+ expect(billingLoader).toHaveBeenCalledWith("eu");
197
+ expect(euView.t("billing", "invoice_summary", "it", { count: 2, name: "Ada" })).toBe(
198
+ "Hai 2 fatture per Ada"
199
+ );
200
+ expectTypeOf(euView.t).toBeCallableWith("billing", "invoice_summary", "it", {
201
+ count: 2,
202
+ name: "Ada",
203
+ });
204
+ expectTypeOf(euView.forLocale).toBeCallableWith("it");
205
+ expectTypeOf(euView.forLocale).parameters.not.toMatchTypeOf<["en-US"]>();
206
+ });
207
+
208
+ it("loads canonical namespace loaders without a partition", async () => {
209
+ const billingLoader = vi.fn(async () => ({
210
+ invoice_summary: {
211
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
212
+ it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
213
+ },
214
+ }));
215
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
216
+
217
+ const view = await createI18nBuilder(engine, {
218
+ namespaceLoaders: {
219
+ billing: billingLoader,
220
+ },
221
+ })
222
+ .withNamespaces(["billing"])
223
+ .load();
224
+
225
+ expect(billingLoader).toHaveBeenCalledTimes(1);
226
+ expect(billingLoader.mock.calls[0]).toEqual([]);
227
+ expect(view.t("billing", "invoice_summary", "en", { count: 1, name: "Ada" })).toBe(
228
+ "You have 1 invoice for Ada"
229
+ );
230
+ });
231
+
232
+ it("skips a repeated load for the same namespace partition", async () => {
233
+ const billingLoader = vi.fn(async (locale: string) =>
234
+ locale === "it" ? billingIt : billingEn
235
+ );
236
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
237
+ const options = { namespaceLoaders: { billing: billingLoader } };
238
+ const chain = () =>
239
+ createI18nBuilder(engine, options).withNamespaces(["billing"]).withLocale("it");
240
+
241
+ await chain().load();
242
+ await chain().load();
243
+
244
+ expect(billingLoader).toHaveBeenCalledTimes(1);
245
+ });
246
+
247
+ it("returns a destructuring-safe locale-bound scope from load()", async () => {
248
+ const billingLoader = vi.fn(async () => billingEn);
249
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
250
+ const { t } = await createI18nBuilder(engine, {
251
+ namespaceLoaders: { billing: billingLoader },
252
+ })
253
+ .withNamespaces(["billing"])
254
+ .withLocale("en")
255
+ .load();
256
+
257
+ expect(t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
258
+ "You have 1 invoice for Ada"
259
+ );
260
+ });
261
+
262
+ it("preserves runtime patches when a later builder reloads the same resource", async () => {
263
+ const billingLoader = vi.fn(async () => billingEn);
264
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
265
+ const options = { namespaceLoaders: { billing: billingLoader } };
266
+
267
+ const firstView = await createI18nBuilder(engine, options)
268
+ .withNamespaces(["billing"])
269
+ .withLocale("en")
270
+ .load();
271
+
272
+ firstView.set(
273
+ "billing",
274
+ "invoice_summary",
275
+ "You have {count, plural, one {1 invoice only} other {{count} invoices only}} for {name}"
276
+ );
277
+
278
+ const secondView = await createI18nBuilder(engine, options)
279
+ .withNamespaces(["billing"])
280
+ .withLocale("en")
281
+ .load();
282
+
283
+ expect(billingLoader).toHaveBeenCalledTimes(1);
284
+ expect(secondView.t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
285
+ "You have 1 invoice only for Ada"
286
+ );
287
+ });
288
+
289
+ it("still loads a different partition for the same namespace", async () => {
290
+ const billingLoader = vi.fn(async (locale: string) =>
291
+ locale === "it" ? billingIt : billingEn
292
+ );
293
+ const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
294
+ const builder = createI18nBuilder(engine, {
295
+ namespaceLoaders: { billing: billingLoader },
296
+ });
297
+
298
+ await builder.withNamespaces(["billing"]).withLocale("it").load();
299
+ await builder.withNamespaces(["billing"]).withLocale("en").load();
300
+
301
+ expect(billingLoader).toHaveBeenCalledTimes(2);
302
+ expect(billingLoader).toHaveBeenNthCalledWith(1, "it");
303
+ expect(billingLoader).toHaveBeenNthCalledWith(2, "en");
304
+ });
305
+ });
306
+
307
+ describe("I18nBuilder single", () => {
308
+ it("load() invokes a locale loader and returns a bound view", async () => {
309
+ const dictionaryLoader = vi.fn(async (locale: string) => {
310
+ return locale === "it"
311
+ ? {
312
+ login_button: { it: "Accedi" },
313
+ welcome: { it: "Benvenuto {name}!" },
314
+ }
315
+ : {
316
+ login_button: { en: "Login" },
317
+ welcome: { en: "Welcome {name}!" },
318
+ };
319
+ });
320
+ const engine = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
321
+ login_button: { en: "", it: "" },
322
+ welcome: { en: "", it: "" },
323
+ });
324
+
325
+ const view = await createI18nBuilder<SingleSchema, SingleParams>(engine, { dictionaryLoader })
326
+ .withLocale("it")
327
+ .load();
328
+
329
+ expect(dictionaryLoader).toHaveBeenCalledWith("it");
330
+ expect(view.locale).toBe("it");
331
+ expect(view.t("login_button")).toBe("Accedi");
332
+ expect(view.t("welcome", { name: "Ada" })).toBe("Benvenuto Ada!");
333
+ });
334
+
335
+ it("patches a preloaded key via scope.set() after load()", async () => {
336
+ const dictionaryLoader = vi.fn(async () => ({
337
+ login_button: { en: "Login" },
338
+ welcome: { en: "Welcome {name}!" },
339
+ }));
340
+ const engine = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
341
+ login_button: { en: "", it: "" },
342
+ welcome: { en: "", it: "" },
343
+ });
344
+
345
+ const view = await createI18nBuilder<SingleSchema, SingleParams>(engine, { dictionaryLoader })
346
+ .withLocale("en")
347
+ .load();
348
+
349
+ view.set("login_button", "Sign in");
350
+
351
+ expect(view.t("login_button")).toBe("Sign in");
352
+ expect(view.t("welcome", { name: "Ada" })).toBe("Welcome Ada!");
353
+ });
354
+
355
+ it("throws when scope.set() targets a key that was not preloaded", async () => {
356
+ const dictionaryLoader = vi.fn(async () => ({
357
+ login_button: { en: "Login" },
358
+ welcome: { en: "Welcome {name}!" },
359
+ }));
360
+ const engine = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
361
+ // @ts-expect-error
362
+ login_button: { en: "" },
363
+ // @ts-expect-error
364
+ welcome: { en: "" },
365
+ });
366
+
367
+ const view = await createI18nBuilder<SingleSchema, SingleParams>(engine, { dictionaryLoader })
368
+ .withLocale("en")
369
+ .load();
370
+
371
+ // @ts-expect-error
372
+ expect(() => view.forLocale("it").set("login_button", "Accedi")).toThrow(
373
+ "[i18n] Key not preloaded: login_button (it)"
374
+ );
375
+ });
376
+
377
+ it("skips a repeated load for the same locale partition", async () => {
378
+ const dictionaryLoader = vi.fn(async () => ({
379
+ login_button: { en: "Login" },
380
+ welcome: { en: "Welcome {name}!" },
381
+ }));
382
+ const engine = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
383
+ login_button: { en: "", it: "" },
384
+ welcome: { en: "", it: "" },
385
+ });
386
+ const options = { dictionaryLoader };
387
+ const chain = () =>
388
+ createI18nBuilder<SingleSchema, SingleParams>(engine, options).withLocale("en");
389
+
390
+ await chain().load();
391
+ await chain().load();
392
+
393
+ expect(dictionaryLoader).toHaveBeenCalledTimes(1);
394
+ });
395
+
396
+ it("preserves runtime patches when a later builder reloads the same resource", async () => {
397
+ const dictionaryLoader = vi.fn(async () => ({
398
+ login_button: { en: "Login" },
399
+ welcome: { en: "Welcome {name}!" },
400
+ }));
401
+ const engine = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
402
+ login_button: { en: "", it: "" },
403
+ welcome: { en: "", it: "" },
404
+ });
405
+ const options = { dictionaryLoader };
406
+
407
+ const firstView = await createI18nBuilder<SingleSchema, SingleParams>(engine, options)
408
+ .withLocale("en")
409
+ .load();
410
+
411
+ firstView.set("login_button", "Sign in");
412
+
413
+ const secondView = await createI18nBuilder<SingleSchema, SingleParams>(engine, options)
414
+ .withLocale("en")
415
+ .load();
416
+
417
+ expect(dictionaryLoader).toHaveBeenCalledTimes(1);
418
+ expect(secondView.t("login_button")).toBe("Sign in");
419
+ expect(secondView.t("welcome", { name: "Ada" })).toBe("Welcome Ada!");
420
+ });
421
+ });
package/src/builder.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { DeliveryArtifactsMap } from "./builder-types.js";
2
+ import { I18nBuilderMultiInitialImpl, type I18nBuilderMultiOptions } from "./builder-multi.js";
3
+ import { I18nBuilderSingleImpl, type I18nBuilderSingleOptions } from "./single-builder.js";
4
+ import type { I18nEngineMultiImpl, I18nEngineSingleImpl } from "./engine.js";
5
+ import type { KeyDictionary, LocaleOfMulti, LocaleOfSingle, MultiDictionary } from "./types.js";
6
+ import type { MultiParams } from "./scope-types.js";
7
+
8
+ export type { CanonicalLoader, NamespaceLoader, PartitionedLoader } from "./builder-loaders.js";
9
+ export { invokeNamespaceLoader } from "./builder-loaders.js";
10
+ export type {
11
+ I18nBuilderMulti,
12
+ I18nBuilderMultiForLocale,
13
+ I18nBuilderMultiInitial,
14
+ I18nBuilderMultiOptions,
15
+ I18nBuilderMultiPartitioned,
16
+ I18nBuilderMultiReady,
17
+ } from "./builder-multi.js";
18
+ export type {
19
+ I18nBuilderSingle,
20
+ I18nBuilderSingleForLocale,
21
+ I18nBuilderSingleOptions,
22
+ } from "./single-builder.js";
23
+
24
+ type AnySingleEngine = I18nEngineSingleImpl<KeyDictionary, { [K in keyof KeyDictionary]: unknown }>;
25
+ type AnyMultiEngine = I18nEngineMultiImpl<MultiDictionary, MultiParams<MultiDictionary>>;
26
+
27
+ function isMultiEngine(engine: AnySingleEngine | AnyMultiEngine): engine is AnyMultiEngine {
28
+ return engine.__i18nEngineMode === "multi";
29
+ }
30
+
31
+ export function createI18nSingleBuilder<
32
+ Schema extends KeyDictionary,
33
+ Params extends { [K in keyof Schema]: unknown },
34
+ RequestLocales extends string = LocaleOfSingle<Schema>,
35
+ >(
36
+ engine: I18nEngineSingleImpl<Schema, Params, RequestLocales>,
37
+ options?: I18nBuilderSingleOptions<Schema, RequestLocales>
38
+ ): I18nBuilderSingleImpl<Schema, Params, RequestLocales> {
39
+ return new I18nBuilderSingleImpl(engine, options);
40
+ }
41
+
42
+ export function createI18nMultiBuilder<
43
+ Schema extends MultiDictionary,
44
+ Params extends MultiParams<Schema>,
45
+ RequestLocales extends string = LocaleOfMulti<Schema>,
46
+ DeliveryArea extends string = never,
47
+ DeliveryArtifacts extends DeliveryArtifactsMap<RequestLocales, DeliveryArea> =
48
+ DeliveryArtifactsMap<RequestLocales, DeliveryArea>,
49
+ >(
50
+ engine: I18nEngineMultiImpl<Schema, Params, RequestLocales>,
51
+ options?: I18nBuilderMultiOptions<Schema, RequestLocales>
52
+ ): I18nBuilderMultiInitialImpl<
53
+ Schema,
54
+ Params,
55
+ RequestLocales,
56
+ RequestLocales,
57
+ DeliveryArea,
58
+ DeliveryArtifacts
59
+ > {
60
+ return new I18nBuilderMultiInitialImpl<
61
+ Schema,
62
+ Params,
63
+ RequestLocales,
64
+ RequestLocales,
65
+ DeliveryArea,
66
+ DeliveryArtifacts
67
+ >(engine, options);
68
+ }
69
+
70
+ export function createI18nBuilder<
71
+ Schema extends MultiDictionary,
72
+ Params extends MultiParams<Schema>,
73
+ RequestLocales extends string = LocaleOfMulti<Schema>,
74
+ >(
75
+ engine: I18nEngineMultiImpl<Schema, Params, RequestLocales>,
76
+ options?: I18nBuilderMultiOptions<Schema, RequestLocales>
77
+ ): I18nBuilderMultiInitialImpl<
78
+ Schema,
79
+ Params,
80
+ RequestLocales,
81
+ RequestLocales,
82
+ never,
83
+ DeliveryArtifactsMap<RequestLocales, never>
84
+ >;
85
+
86
+ export function createI18nBuilder<
87
+ Schema extends KeyDictionary,
88
+ Params extends { [K in keyof Schema]: unknown },
89
+ RequestLocales extends string = LocaleOfSingle<Schema>,
90
+ >(
91
+ engine: I18nEngineSingleImpl<Schema, Params, RequestLocales>,
92
+ options?: I18nBuilderSingleOptions<Schema, RequestLocales>
93
+ ): I18nBuilderSingleImpl<Schema, Params, RequestLocales>;
94
+
95
+ export function createI18nBuilder(
96
+ engine: AnySingleEngine | AnyMultiEngine,
97
+ options?:
98
+ | I18nBuilderMultiOptions<MultiDictionary, string>
99
+ | I18nBuilderSingleOptions<KeyDictionary, string>
100
+ ): never {
101
+ if (isMultiEngine(engine)) {
102
+ return createI18nMultiBuilder(
103
+ engine,
104
+ options as I18nBuilderMultiOptions<MultiDictionary, string> | undefined
105
+ ) as never;
106
+ }
107
+
108
+ return createI18nSingleBuilder(
109
+ engine,
110
+ options as I18nBuilderSingleOptions<KeyDictionary, string> | undefined
111
+ ) as never;
112
+ }
@@ -160,7 +160,8 @@ describe("delivery-artifacts", () => {
160
160
  ' "eu": ["fr", "it"] as const,\n' +
161
161
  ' "us": ["en-US"] as const,\n' +
162
162
  "} as const satisfies Record<AppDeliveryArea, readonly AppLocale[]>;\n\n" +
163
- "export type AppDeliveryArtifacts = typeof DELIVERY_ARTIFACTS;\n\n"
163
+ "export type AppDeliveryArtifacts = typeof DELIVERY_ARTIFACTS;\n\n" +
164
+ "export type LocalesForDeliveryArea<A extends AppDeliveryArea> = AppDeliveryArtifacts[A][number];\n\n"
164
165
  );
165
166
  });
166
167
  });
@@ -148,6 +148,7 @@ export function formatDeliveryArtifactsBlock(
148
148
 
149
149
  return (
150
150
  `export const ${constName} = {\n${areaEntries}\n} as const satisfies Record<${deliveryAreaTypeName}, readonly ${localeTypeName}[]>;\n\n` +
151
- `export type ${deliveryArtifactsTypeName} = typeof ${constName};\n\n`
151
+ `export type ${deliveryArtifactsTypeName} = typeof ${constName};\n\n` +
152
+ `export type LocalesForDeliveryArea<A extends ${deliveryAreaTypeName}> = ${deliveryArtifactsTypeName}[A][number];\n\n`
152
153
  );
153
154
  }