@xndrjs/i18n 0.7.0 → 0.8.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 (69) hide show
  1. package/README.md +67 -872
  2. package/dist/codegen/index.d.ts +117 -51
  3. package/dist/codegen/index.js +1585 -93
  4. package/dist/codegen/index.js.map +1 -1
  5. package/dist/index.d.ts +100 -234
  6. package/dist/index.js +74 -638
  7. package/dist/index.js.map +1 -1
  8. package/dist/validation/index.d.ts +5 -0
  9. package/dist/validation/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/IcuTranslationProviderMulti.test.ts +18 -40
  12. package/src/IcuTranslationProviderMulti.ts +27 -37
  13. package/src/audit/audit-dictionaries.ts +1 -1
  14. package/src/audit/run-audit.test.ts +6 -7
  15. package/src/builder-load-registry.ts +26 -5
  16. package/src/builder-loaders.ts +8 -11
  17. package/src/builder-types.test.ts +11 -42
  18. package/src/builder.test.ts +82 -243
  19. package/src/builder.ts +8 -110
  20. package/src/codegen/codegen-config-schema.ts +82 -77
  21. package/src/codegen/config.test.ts +55 -143
  22. package/src/codegen/config.ts +10 -67
  23. package/src/codegen/dictionary-spec-contract.test.ts +28 -0
  24. package/src/codegen/dictionary-spec-contract.ts +138 -0
  25. package/src/codegen/emit/dictionary-schema-file.ts +7 -43
  26. package/src/codegen/emit/instance-file.test.ts +35 -52
  27. package/src/codegen/emit/instance-file.ts +170 -268
  28. package/src/codegen/emit/namespace-loaders-file.test.ts +72 -74
  29. package/src/codegen/emit/namespace-loaders-file.ts +147 -90
  30. package/src/codegen/emit/types-file.test.ts +20 -41
  31. package/src/codegen/emit/types-file.ts +48 -74
  32. package/src/codegen/generate-i18n-types.test.ts +163 -492
  33. package/src/codegen/generate-i18n-types.ts +7 -269
  34. package/src/codegen/paths.ts +0 -14
  35. package/src/codegen/project-locales-set-namespace.test.ts +44 -86
  36. package/src/codegen/read-dictionary.test.ts +27 -9
  37. package/src/codegen/read-dictionary.ts +18 -40
  38. package/src/codegen/regenerate-namespaces.ts +180 -0
  39. package/src/codegen/run-codegen.test.ts +230 -0
  40. package/src/codegen/run-codegen.ts +252 -0
  41. package/src/codegen/types.ts +0 -6
  42. package/src/codegen-config/build-config.ts +4 -23
  43. package/src/codegen-config/codegen-config.test.ts +10 -6
  44. package/src/codegen-config/index.ts +18 -3
  45. package/src/engine.ts +3 -27
  46. package/src/fetch-artifact.ts +16 -0
  47. package/src/i18n-handle.ts +202 -0
  48. package/src/index.ts +15 -34
  49. package/src/project-locales.ts +2 -2
  50. package/src/scope-multi.ts +1 -20
  51. package/src/scope-types.ts +14 -6
  52. package/src/scope.test.ts +3 -312
  53. package/src/serialized-state.test.ts +114 -0
  54. package/src/serialized-state.ts +28 -0
  55. package/src/setup/setup-i18n.test.ts +22 -33
  56. package/src/setup/setup-i18n.ts +22 -27
  57. package/src/types.ts +3 -3
  58. package/src/validation/index.ts +4 -0
  59. package/src/IcuTranslationProviderSingle.test.ts +0 -177
  60. package/src/IcuTranslationProviderSingle.ts +0 -114
  61. package/src/builder-multi.ts +0 -481
  62. package/src/codegen/emit/dictionary-file.test.ts +0 -215
  63. package/src/codegen/emit/dictionary-file.ts +0 -244
  64. package/src/deep-freeze.test.ts +0 -40
  65. package/src/deep-freeze.ts +0 -22
  66. package/src/patch-key.test.ts +0 -186
  67. package/src/patch-key.ts +0 -140
  68. package/src/scope-single.ts +0 -85
  69. package/src/single-builder.ts +0 -153
package/src/scope.test.ts CHANGED
@@ -1,64 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
3
- import { IcuTranslationProviderSingle } from "./IcuTranslationProviderSingle.js";
4
- import type { LocaleOfMulti, LocaleOfSingle } from "./types.js";
5
-
6
- type SingleSchema = {
7
- login_button: { en: string; it: string };
8
- welcome: { en: string; it: string };
9
- empty_label: { en: string };
10
- broken: { en: string };
11
- invoice_count: { en: string; it: string };
12
- dashboard_status: { en: string; it: string };
13
- inbox_owner: { en: string; it: string };
14
- ranking_position: { en: string; it: string };
15
- account_balance: { en: string; it: string };
16
- appointment_summary: { en: string; it: string };
17
- };
18
-
19
- type SingleParams = {
20
- login_button: never;
21
- welcome: { name: string };
22
- empty_label: never;
23
- broken: { name: string };
24
- invoice_count: { count: number };
25
- dashboard_status: { msgCount: number; chatCount: number };
26
- inbox_owner: { gender: string; name: string };
27
- ranking_position: { position: number };
28
- account_balance: { amount: number };
29
- appointment_summary: { dueDate: Date | number; startTime: Date | number };
30
- };
31
-
32
- const singleDictionary: SingleSchema = {
33
- login_button: { en: "Login", it: "Accedi" },
34
- welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
35
- empty_label: { en: "" },
36
- broken: { en: "Hi {name" },
37
- invoice_count: {
38
- en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
39
- it: "Hai {count, plural, one {1 fattura} other {{count} fatture}}",
40
- },
41
- dashboard_status: {
42
- en: "You have {msgCount, plural, one {1 message} other {{msgCount} messages}} in {chatCount, plural, one {one chat} other {{chatCount} chats}}",
43
- it: "Hai {msgCount, plural, one {1 messaggio} other {{msgCount} messaggi}} in {chatCount, plural, one {una chat} other {{chatCount} chat}}",
44
- },
45
- inbox_owner: {
46
- en: "{gender, select, female {{name} owns her inbox} male {{name} owns his inbox} other {{name} owns their inbox}}",
47
- it: "{gender, select, other {{name} gestisce la propria casella}}",
48
- },
49
- ranking_position: {
50
- en: "You finished {position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
51
- it: "Hai concluso al {position, selectordinal, one {#°} other {#°}} posto",
52
- },
53
- account_balance: {
54
- en: "Balance: {amount, number, ::currency/EUR}",
55
- it: "Saldo: {amount, number, ::currency/EUR}",
56
- },
57
- appointment_summary: {
58
- en: "Due {dueDate, date, short} at {startTime, time, short}",
59
- it: "Scade il {dueDate, date, short} alle {startTime, time, short}",
60
- },
61
- };
3
+ import type { LocaleOfMulti } from "./types.js";
62
4
 
63
5
  type MultiSchema = {
64
6
  default: {
@@ -126,169 +68,6 @@ const multiDictionary: MultiSchema = {
126
68
  },
127
69
  };
128
70
 
129
- describe("I18nScope single", () => {
130
- const engine = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary);
131
- const view = engine.toScope();
132
-
133
- it("translates with t(key, locale)", () => {
134
- expect(view.t("login_button", "it")).toBe("Accedi");
135
- expect(view.t("welcome", "en", { name: "Ada" })).toBe("Welcome Ada!");
136
- });
137
-
138
- it("formats ICU plurals and nested plurals", () => {
139
- expect(view.t("invoice_count", "en", { count: 1 })).toBe("You have 1 invoice");
140
- expect(view.t("invoice_count", "en", { count: 5 })).toBe("You have 5 invoices");
141
- expect(view.t("dashboard_status", "en", { msgCount: 3, chatCount: 2 })).toBe(
142
- "You have 3 messages in 2 chats"
143
- );
144
- });
145
-
146
- it("binds locale via forLocale", () => {
147
- const en = view.forLocale("en");
148
-
149
- expect(en.locale).toBe("en");
150
- expect(en.t("login_button")).toBe("Login");
151
- expect(en.t("welcome", { name: "Ada" })).toBe("Welcome Ada!");
152
- });
153
-
154
- it("binds locale via toScope({ locale })", () => {
155
- const it = engine.toScope({ locale: "it" });
156
-
157
- expect(it.locale).toBe("it");
158
- expect(it.t("login_button")).toBe("Accedi");
159
- expect(it.t("welcome", { name: "Ada" })).toBe("Benvenuto Ada!");
160
- });
161
-
162
- it("supports destructuring t and set from locale-bound scopes", () => {
163
- const { t, set } = engine.toScope().forLocale("en");
164
-
165
- expect(t("login_button")).toBe("Login");
166
- set("login_button", "Sign in");
167
- expect(t("login_button")).toBe("Sign in");
168
- });
169
-
170
- it("throws when key or locale is missing", () => {
171
- expect(() => view.t("login_button", "fr" as unknown as LocaleOfSingle<SingleSchema>)).toThrow(
172
- '[i18n] Missing key or locale: "login_button" [fr] (fallback chain: fr)'
173
- );
174
- expect(() => view.t("missing_key" as "login_button", "en")).toThrow(
175
- '[i18n] Missing key or locale: "missing_key" [en] (fallback chain: en)'
176
- );
177
- });
178
-
179
- describe("locale fallback", () => {
180
- const fallbackMap = {
181
- en: null,
182
- "de-DE": "en",
183
- "de-CH": "de-DE",
184
- it: "en",
185
- } as const;
186
-
187
- const fallbackEngine = new IcuTranslationProviderSingle<
188
- SingleSchema,
189
- SingleParams,
190
- keyof typeof fallbackMap | LocaleOfSingle<SingleSchema>,
191
- typeof fallbackMap
192
- >(singleDictionary, { localeFallback: fallbackMap });
193
- const fallbackView = fallbackEngine.toScope();
194
-
195
- it("resolves a locale through the fallback chain", () => {
196
- expect(fallbackView.t("login_button", "de-CH")).toBe("Login");
197
- });
198
- });
199
-
200
- describe("onMissing", () => {
201
- it('returns the key itself with onMissing: "key"', () => {
202
- const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary, {
203
- onMissing: "key",
204
- });
205
- expect(local.toScope().t("missing_key" as "login_button", "en")).toBe("missing_key");
206
- });
207
-
208
- it("applies onMissing through forLocale wrappers", () => {
209
- const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary, {
210
- onMissing: "key",
211
- });
212
- expect(
213
- local
214
- .toScope()
215
- .forLocale("en")
216
- .t("missing_key" as "login_button")
217
- ).toBe("missing_key");
218
- });
219
- });
220
-
221
- describe("set()", () => {
222
- it("patches a preloaded key after load merge", () => {
223
- const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
224
- login_button: { en: "Login", it: "Accedi" },
225
- welcome: { en: "Welcome {name}!", it: "benvenuto {name}..." },
226
- empty_label: { en: "" },
227
- broken: { en: "Hi {name" },
228
- invoice_count: singleDictionary.invoice_count,
229
- dashboard_status: singleDictionary.dashboard_status,
230
- inbox_owner: singleDictionary.inbox_owner,
231
- ranking_position: singleDictionary.ranking_position,
232
- account_balance: singleDictionary.account_balance,
233
- appointment_summary: singleDictionary.appointment_summary,
234
- });
235
- local.applyLoadMergeSingle({
236
- welcome: { it: "Benvenuto {name}!" },
237
- });
238
-
239
- const enScope = local.toScope().forLocale("en");
240
- enScope.set("login_button", "Sign in");
241
-
242
- const itScope = local.toScope().forLocale("it");
243
-
244
- expect(enScope.t("login_button")).toBe("Sign in");
245
- expect(itScope.t("login_button")).toBe("Accedi");
246
-
247
- expect(itScope.t("welcome", { name: "Mario" })).toBe("Benvenuto Mario!");
248
- });
249
-
250
- it("throws when patching a key that was not preloaded", () => {
251
- const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>({
252
- // @ts-expect-error missing it
253
- login_button: { en: "Login" },
254
- welcome: { en: "Welcome {name}!", it: "" },
255
- empty_label: { en: "" },
256
- broken: { en: "Hi {name" },
257
- invoice_count: singleDictionary.invoice_count,
258
- dashboard_status: singleDictionary.dashboard_status,
259
- inbox_owner: singleDictionary.inbox_owner,
260
- ranking_position: singleDictionary.ranking_position,
261
- account_balance: singleDictionary.account_balance,
262
- appointment_summary: singleDictionary.appointment_summary,
263
- });
264
-
265
- expect(() => local.toScope().forLocale("it").set("login_button", "Accedi")).toThrow(
266
- "[i18n] Key not preloaded: login_button (it)"
267
- );
268
- });
269
-
270
- it("invalidates compiled cache after set()", () => {
271
- const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary);
272
- const en = local.toScope().forLocale("en");
273
-
274
- expect(en.t("welcome", { name: "Ada" })).toBe("Welcome Ada!");
275
-
276
- en.set("welcome", "Hi {name}!");
277
-
278
- expect(en.t("welcome", { name: "Ada" })).toBe("Hi Ada!");
279
- });
280
-
281
- it("throws on ICU args mismatch against other locales", () => {
282
- const local = new IcuTranslationProviderSingle<SingleSchema, SingleParams>(singleDictionary);
283
- const en = local.toScope().forLocale("en");
284
-
285
- expect(() => en.set("invoice_count", "{count, select, other {{count}}}")).toThrow(
286
- "[i18n] ICU args mismatch on patch:"
287
- );
288
- });
289
- });
290
- });
291
-
292
71
  describe("I18nScope multi", () => {
293
72
  const engine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(multiDictionary);
294
73
 
@@ -329,20 +108,12 @@ describe("I18nScope multi", () => {
329
108
  );
330
109
  });
331
110
 
332
- it("supports destructuring t and set from locale-bound scopes", () => {
333
- const { t, set } = engine.toScope({ namespaces: ["billing"] }).forLocale("en");
111
+ it("supports destructuring t from locale-bound scopes", () => {
112
+ const { t } = engine.toScope({ namespaces: ["billing"] }).forLocale("en");
334
113
 
335
114
  expect(t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
336
115
  "You have 1 invoice for Ada"
337
116
  );
338
- set(
339
- "billing",
340
- "invoice_summary",
341
- "You have {count, plural, one {1 invoice only} other {{count} invoices only}} for {name}"
342
- );
343
- expect(t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
344
- "You have 1 invoice only for Ada"
345
- );
346
117
  });
347
118
 
348
119
  it("reflects partial applyLoadMergeNamespace via a fresh toScope", () => {
@@ -398,84 +169,4 @@ describe("I18nScope multi", () => {
398
169
  expect(view.t("default", "login_button", "de-CH")).toBe("Login");
399
170
  });
400
171
  });
401
-
402
- describe("set()", () => {
403
- it("patches a preloaded key after load merge", () => {
404
- const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({
405
- billing: {
406
- invoice_summary: {
407
- en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
408
- },
409
- },
410
- });
411
- local.applyLoadMergeNamespace("billing", {
412
- invoice_summary: {
413
- it: "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}",
414
- },
415
- });
416
-
417
- const en = local.toScope({ namespaces: ["billing"] }).forLocale("en");
418
- en.set(
419
- "billing",
420
- "invoice_summary",
421
- "You have {count, plural, one {1 invoice only} other {{count} invoices only}} for {name}"
422
- );
423
-
424
- expect(en.t("billing", "invoice_summary", { count: 1, name: "Ada" })).toBe(
425
- "You have 1 invoice only for Ada"
426
- );
427
- expect(
428
- local
429
- .toScope({ namespaces: ["billing"], locale: "it" })
430
- .t("billing", "invoice_summary", { count: 2, name: "Bob" })
431
- ).toBe("Hai 2 fatture per Bob");
432
- });
433
-
434
- it("throws when patching a key that was not preloaded", () => {
435
- const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({
436
- billing: {
437
- invoice_summary: {
438
- en: "You have {count, plural, one {1 invoice} other {{count} invoices}} for {name}",
439
- },
440
- },
441
- });
442
-
443
- expect(() =>
444
- local
445
- .toScope({ namespaces: ["billing"] })
446
- .forLocale("it")
447
- .set(
448
- "billing",
449
- "invoice_summary",
450
- "Hai {count, plural, one {1 fattura} other {{count} fatture}} per {name}"
451
- )
452
- ).toThrow("[i18n] Key not preloaded: billing.invoice_summary (it)");
453
- });
454
-
455
- it("invalidates compiled cache after set()", () => {
456
- const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(multiDictionary);
457
- const en = local.toScope({ namespaces: ["billing"] }).forLocale("en");
458
-
459
- expect(en.t("billing", "invoice_summary", { count: 2, name: "Bob" })).toBe(
460
- "You have 2 invoices for Bob"
461
- );
462
-
463
- en.set(
464
- "billing",
465
- "invoice_summary",
466
- "{count, plural, one {1 bill} other {{count} bills}} for {name}"
467
- );
468
-
469
- expect(en.t("billing", "invoice_summary", { count: 2, name: "Bob" })).toBe("2 bills for Bob");
470
- });
471
-
472
- it("throws on ICU args mismatch against other locales", () => {
473
- const local = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(multiDictionary);
474
- const en = local.toScope({ namespaces: ["billing"] }).forLocale("en");
475
-
476
- expect(() =>
477
- en.set("billing", "invoice_summary", "{count, select, other {{count}}}")
478
- ).toThrow("[i18n] ICU args mismatch on patch:");
479
- });
480
- });
481
172
  });
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createI18nHandle } from "./builder.js";
3
+ import { IcuTranslationProviderMulti } from "./IcuTranslationProviderMulti.js";
4
+ import { normalizeI18nCreateInput } from "./serialized-state.js";
5
+
6
+ type MultiSchema = {
7
+ default: { greeting: { en: string; it: string } };
8
+ billing: { invoice: { en: string; it: string } };
9
+ };
10
+ type MultiParams = {
11
+ default: { greeting: never };
12
+ billing: { invoice: never };
13
+ };
14
+
15
+ describe("serialize / createI18n({ state })", () => {
16
+ it("normalizeI18nCreateInput defaults missing state to empty dictionary and [] resources", () => {
17
+ expect(normalizeI18nCreateInput()).toEqual({
18
+ dictionary: {},
19
+ resources: [],
20
+ });
21
+ });
22
+
23
+ it("normalizeI18nCreateInput defaults missing resources to []", () => {
24
+ expect(normalizeI18nCreateInput({ dictionary: { greeting: { en: "Hi" } } })).toEqual({
25
+ dictionary: { greeting: { en: "Hi" } },
26
+ resources: [],
27
+ });
28
+ });
29
+
30
+ it("serialize after load → fresh handle skips loaders for marked resources", async () => {
31
+ const defaultLoader = vi.fn(async (locale: string) => ({
32
+ greeting: { [locale]: locale === "it" ? "Ciao" : "Hello" },
33
+ }));
34
+ const billingLoader = vi.fn(async (locale: string) => ({
35
+ invoice: { [locale]: locale === "it" ? "Fattura" : "Invoice" },
36
+ }));
37
+
38
+ const ssrEngine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({});
39
+ const ssr = createI18nHandle(ssrEngine, {
40
+ namespaceLoaders: {
41
+ default: defaultLoader,
42
+ billing: billingLoader,
43
+ },
44
+ });
45
+
46
+ await ssr.load({ namespaces: ["default", "billing"], locale: "it" });
47
+ expect(defaultLoader).toHaveBeenCalledTimes(1);
48
+ expect(billingLoader).toHaveBeenCalledTimes(1);
49
+
50
+ const serialized = ssr.serialize();
51
+ expect(serialized.resources).toEqual(
52
+ expect.arrayContaining([
53
+ ["default", "it"],
54
+ ["billing", "it"],
55
+ ])
56
+ );
57
+ expect(serialized.dictionary.default?.greeting?.it).toBe("Ciao");
58
+
59
+ const csrEngine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(
60
+ serialized.dictionary
61
+ );
62
+ csrEngine.seedBuilderResources(serialized.resources);
63
+ const csr = createI18nHandle(csrEngine, {
64
+ namespaceLoaders: {
65
+ default: defaultLoader,
66
+ billing: billingLoader,
67
+ },
68
+ });
69
+
70
+ expect(csrEngine.hasBuilderResourceLoaded("default", "it")).toBe(true);
71
+ expect(csrEngine.hasBuilderResourceLoaded("billing", "it")).toBe(true);
72
+
73
+ await csr.load({ namespaces: ["default", "billing"], locale: "it" });
74
+ expect(defaultLoader).toHaveBeenCalledTimes(1);
75
+ expect(billingLoader).toHaveBeenCalledTimes(1);
76
+
77
+ const scope = csr.peek({ namespaces: ["default", "billing"], locale: "it" });
78
+ expect(scope).not.toBeNull();
79
+ expect(scope!.t("default", "greeting")).toBe("Ciao");
80
+ expect(scope!.t("billing", "invoice")).toBe("Fattura");
81
+ });
82
+
83
+ it("peek returns scope after hydrate seed without invoking loaders", async () => {
84
+ const defaultLoader = vi.fn(async (locale: string) => ({
85
+ greeting: { [locale]: "Hello" },
86
+ }));
87
+ const billingLoader = vi.fn(async (locale: string) => ({
88
+ invoice: { [locale]: "Invoice" },
89
+ }));
90
+
91
+ const ssrEngine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>({});
92
+ const ssr = createI18nHandle(ssrEngine, {
93
+ namespaceLoaders: { default: defaultLoader, billing: billingLoader },
94
+ });
95
+ await ssr.load({ namespaces: ["default", "billing"], locale: "en" });
96
+ const serialized = ssr.serialize();
97
+
98
+ const csrEngine = new IcuTranslationProviderMulti<MultiSchema, MultiParams>(
99
+ serialized.dictionary
100
+ );
101
+ csrEngine.seedBuilderResources(serialized.resources);
102
+ const csr = createI18nHandle(csrEngine, {
103
+ namespaceLoaders: { default: defaultLoader, billing: billingLoader },
104
+ });
105
+
106
+ const sync = csr.peek({ namespaces: ["default", "billing"], locale: "en" });
107
+ expect(sync).not.toBeNull();
108
+ expect(sync!.t("default", "greeting")).toBe("Hello");
109
+ expect(defaultLoader).toHaveBeenCalledTimes(1);
110
+ expect(billingLoader).toHaveBeenCalledTimes(1);
111
+
112
+ expect(csr.peek({ namespaces: ["default"], locale: "it" })).toBeNull();
113
+ });
114
+ });
@@ -0,0 +1,28 @@
1
+ import type { BuilderResourceEntry } from "./builder-load-registry.js";
2
+
3
+ /**
4
+ * Portable i18n instance state after `serialize()`: dictionary strings + which
5
+ * builder resources are already loaded (namespace × partition).
6
+ */
7
+ export interface I18nSerializedState<Dictionary = Record<string, unknown>> {
8
+ dictionary: Dictionary;
9
+ resources: readonly BuilderResourceEntry[];
10
+ }
11
+
12
+ /**
13
+ * Input nested under generated `createI18n({ state? })`. Omitted `state` defaults to an empty
14
+ * dictionary (lazy cold start). `resources` may be omitted or `[]`.
15
+ */
16
+ export type I18nCreateInput<Dictionary = Record<string, unknown>> = {
17
+ dictionary: Dictionary;
18
+ resources?: readonly BuilderResourceEntry[];
19
+ };
20
+
21
+ export function normalizeI18nCreateInput<Dictionary>(
22
+ value?: I18nCreateInput<Dictionary>
23
+ ): I18nSerializedState<Dictionary> {
24
+ return {
25
+ dictionary: value?.dictionary ?? ({} as Dictionary),
26
+ resources: value?.resources ?? [],
27
+ };
28
+ }
@@ -30,51 +30,40 @@ describe("setup-i18n", () => {
30
30
  }
31
31
  });
32
32
 
33
- it("parses single and multi CLI args", () => {
34
- expect(parseSetupArgs(["single", ".", "--project", "MyApp"])).toEqual({
35
- mode: "single",
36
- targetDir: ".",
37
- project: "MyApp",
38
- force: false,
39
- });
33
+ it("accepts multi alias or positional targetDir", () => {
40
34
  expect(parseSetupArgs(["multi", "apps/myapp", "--project", "MyApp", "--force"])).toEqual({
41
- mode: "multi",
42
35
  targetDir: "apps/myapp",
43
36
  project: "MyApp",
44
37
  force: true,
45
38
  });
39
+ expect(parseSetupArgs([".", "--project", "MyApp"])).toEqual({
40
+ targetDir: ".",
41
+ project: "MyApp",
42
+ force: false,
43
+ });
46
44
  });
47
45
 
48
- it("scaffolds single mode with MyApp type names", () => {
49
- tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
50
- const result = runSetup({ mode: "single", targetDir: tempDir, project: "MyApp" });
51
-
52
- const config = JSON.parse(readFileSync(join(tempDir, "i18n/i18n.codegen.json"), "utf8"));
53
- expect(config.dictionary).toBe("translations/translations.json");
54
- expect(config.paramsTypeName).toBe("MyAppParams");
55
- expect(config.schemaTypeName).toBe("MyAppSchema");
56
- expect(config.localeTypeName).toBe("MyAppLocale");
57
- expect(existsSync(join(tempDir, "i18n/translations/translations.json"))).toBe(true);
58
- expect(existsSync(join(tempDir, "i18n/index.ts"))).toBe(true);
59
- expect(existsSync(join(tempDir, "src"))).toBe(false);
60
- expect(result.created).toContain("i18n/i18n.codegen.json");
61
- });
62
-
63
- it("scaffolds multi mode with default namespace only", () => {
46
+ it("scaffolds multi mode with projectName and codegenPath", () => {
64
47
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
65
- runSetup({ mode: "multi", targetDir: tempDir, project: "MyApp" });
48
+ const result = runSetup({ targetDir: tempDir, project: "MyApp" });
66
49
 
67
50
  const config = JSON.parse(readFileSync(join(tempDir, "i18n/i18n.codegen.json"), "utf8"));
68
51
  expect(config.namespaces).toEqual({
69
52
  default: "translations/default.json",
70
53
  });
54
+ expect(config.projectName).toBe("MyApp");
55
+ expect(config.codegenPath).toBe("generated");
56
+ expect(config).not.toHaveProperty("paramsTypeName");
71
57
  expect(existsSync(join(tempDir, "i18n/translations/default.json"))).toBe(true);
58
+ expect(existsSync(join(tempDir, "i18n/index.ts"))).toBe(true);
59
+ expect(existsSync(join(tempDir, "src"))).toBe(false);
60
+ expect(result.created).toContain("i18n/i18n.codegen.json");
72
61
  });
73
62
 
74
63
  it("scaffolds under src/i18n when targetDir is src", () => {
75
64
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
76
65
  const srcDir = join(tempDir, "src");
77
- runSetup({ mode: "single", targetDir: srcDir, project: "MyApp" });
66
+ runSetup({ targetDir: srcDir, project: "MyApp" });
78
67
 
79
68
  expect(existsSync(join(srcDir, "i18n/i18n.codegen.json"))).toBe(true);
80
69
  expect(existsSync(join(tempDir, "i18n"))).toBe(false);
@@ -82,15 +71,15 @@ describe("setup-i18n", () => {
82
71
 
83
72
  it("refuses to overwrite an existing config without --force", () => {
84
73
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
85
- runSetup({ mode: "single", targetDir: tempDir, project: "MyApp" });
74
+ runSetup({ targetDir: tempDir, project: "MyApp" });
86
75
 
87
- expect(() => runSetup({ mode: "single", targetDir: tempDir, project: "MyApp" })).toThrow(
88
- "already exists"
89
- );
76
+ expect(() => runSetup({ targetDir: tempDir, project: "MyApp" })).toThrow("already exists");
90
77
  });
91
78
 
92
- it("buildCodegenConfig omits lazy and validation keys by default", () => {
93
- expect(buildCodegenConfig("multi", "MyApp")).not.toHaveProperty("loadOnInit");
94
- expect(buildCodegenConfig("multi", "MyApp")).not.toHaveProperty("dictionarySchemaOutput");
79
+ it("buildCodegenConfig defaults to split-by-locale", () => {
80
+ const config = buildCodegenConfig("MyApp");
81
+ expect(config.delivery).toBe("split-by-locale");
82
+ expect(config.codegenPath).toBe("generated");
83
+ expect(config.projectName).toBe("MyApp");
95
84
  });
96
85
  });
@@ -1,16 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { buildCodegenConfig, type SetupMode } from "../codegen-config/build-config.js";
4
+ import { buildCodegenConfig } from "../codegen-config/build-config.js";
5
5
  import { inferProjectName } from "../codegen-config/type-names.js";
6
6
  import { writeCodegenConfig } from "../codegen-config/write-config.js";
7
7
  import { DEFAULT_IMPORT_EXTENSION, importExtensionSuffix } from "../codegen/paths.js";
8
8
 
9
- export type { SetupMode } from "../codegen-config/build-config.js";
10
9
  export { buildCodegenConfig } from "../codegen-config/build-config.js";
11
10
 
12
11
  export interface SetupOptions {
13
- mode: SetupMode;
14
12
  targetDir: string;
15
13
  project?: string | undefined;
16
14
  force?: boolean | undefined;
@@ -35,12 +33,11 @@ const DEFAULT_STARTER = {
35
33
  const DEFAULT_IMPORT_SUFFIX = importExtensionSuffix(DEFAULT_IMPORT_EXTENSION);
36
34
 
37
35
  const INDEX_TS =
38
- `import { createI18n } from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
39
- `import { defaultDictionary } from "./generated/dictionary.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
36
+ `import { createI18n } from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
40
37
  `export * from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
41
- `export * from "./generated/dictionary.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
42
- `export * from "./generated/i18n-types.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
43
- `export const i18n = createI18n(defaultDictionary);\n`;
38
+ `export * from "./generated/i18n-types.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
39
+ `export * from "./generated/namespace-loaders.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
40
+ `export const i18n = createI18n();\n`;
44
41
 
45
42
  function writeJson(filePath: string, value: unknown): void {
46
43
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -76,18 +73,12 @@ export function runSetup(options: SetupOptions): SetupResult {
76
73
 
77
74
  const created: string[] = [];
78
75
 
79
- writeCodegenConfig(configPath, buildCodegenConfig(options.mode, project));
76
+ writeCodegenConfig(configPath, buildCodegenConfig(project));
80
77
  created.push(relative(targetDir, configPath));
81
78
 
82
- if (options.mode === "single") {
83
- const translationsPath = path.join(targetDir, I18N_ROOT, TRANSLATIONS_DIR, "translations.json");
84
- writeJson(translationsPath, DEFAULT_STARTER);
85
- created.push(relative(targetDir, translationsPath));
86
- } else {
87
- const defaultPath = path.join(targetDir, I18N_ROOT, TRANSLATIONS_DIR, "default.json");
88
- writeJson(defaultPath, DEFAULT_STARTER);
89
- created.push(relative(targetDir, defaultPath));
90
- }
79
+ const defaultPath = path.join(targetDir, I18N_ROOT, TRANSLATIONS_DIR, "default.json");
80
+ writeJson(defaultPath, DEFAULT_STARTER);
81
+ created.push(relative(targetDir, defaultPath));
91
82
 
92
83
  const indexPath = path.join(targetDir, I18N_ROOT, "index.ts");
93
84
  writeText(indexPath, INDEX_TS);
@@ -99,18 +90,22 @@ export function runSetup(options: SetupOptions): SetupResult {
99
90
  }
100
91
 
101
92
  export function parseSetupArgs(argv: string[]): SetupOptions {
102
- const mode = argv[0];
103
- if (mode !== "single" && mode !== "multi") {
104
- throw new Error(
105
- `[Setup Error] Usage: xndrjs-i18n-setup <single|multi> [targetDir] [--project MyApp] [--force]`
106
- );
107
- }
93
+ const first = argv[0];
108
94
 
109
95
  let targetDir = ".";
110
96
  let project: string | undefined;
111
97
  let force = false;
98
+ let startIndex = 0;
99
+
100
+ if (first === "multi") {
101
+ startIndex = 1;
102
+ } else if (first !== undefined && !first.startsWith("-")) {
103
+ // positional targetDir without mode
104
+ } else if (first === undefined || first.startsWith("-")) {
105
+ // flags only / empty
106
+ }
112
107
 
113
- for (let index = 1; index < argv.length; index++) {
108
+ for (let index = startIndex; index < argv.length; index++) {
114
109
  const arg = argv[index]!;
115
110
  if (arg === "--project") {
116
111
  project = argv[++index];
@@ -129,7 +124,7 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
129
124
  targetDir = arg;
130
125
  }
131
126
 
132
- return { mode, targetDir, project, force };
127
+ return { targetDir, project, force };
133
128
  }
134
129
 
135
130
  function main() {
@@ -137,7 +132,7 @@ function main() {
137
132
  const options = parseSetupArgs(process.argv.slice(2));
138
133
  const result = runSetup(options);
139
134
 
140
- console.log(`✅ Setup ${options.mode} i18n in ${result.targetDir}`);
135
+ console.log(`✅ Setup i18n in ${result.targetDir}`);
141
136
  console.log(
142
137
  ` Project types: ${result.project}Params, ${result.project}Schema, ${result.project}Locale`
143
138
  );