@xndrjs/i18n 0.2.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 (50) hide show
  1. package/README.md +572 -0
  2. package/bin/codegen.mjs +17 -0
  3. package/bin/setup.mjs +14 -0
  4. package/dist/index.d.ts +124 -0
  5. package/dist/index.js +320 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/types-CFWVT2PP.d.ts +77 -0
  8. package/dist/validation/index.d.ts +44 -0
  9. package/dist/validation/index.js +490 -0
  10. package/dist/validation/index.js.map +1 -0
  11. package/package.json +65 -0
  12. package/src/IcuTranslationProviderMulti.test.ts +290 -0
  13. package/src/IcuTranslationProviderMulti.ts +195 -0
  14. package/src/IcuTranslationProviderSingle.test.ts +314 -0
  15. package/src/IcuTranslationProviderSingle.ts +155 -0
  16. package/src/codegen/config.ts +63 -0
  17. package/src/codegen/emit/dictionary-file.ts +73 -0
  18. package/src/codegen/emit/dictionary-schema-file.ts +129 -0
  19. package/src/codegen/emit/instance-file.ts +60 -0
  20. package/src/codegen/emit/namespace-loaders-file.ts +44 -0
  21. package/src/codegen/emit/types-file.ts +118 -0
  22. package/src/codegen/generate-i18n-types.test.ts +482 -0
  23. package/src/codegen/generate-i18n-types.ts +173 -0
  24. package/src/codegen/icu-analysis.ts +88 -0
  25. package/src/codegen/locale-fallback.ts +64 -0
  26. package/src/codegen/paths.ts +27 -0
  27. package/src/codegen/types.ts +30 -0
  28. package/src/deep-freeze.test.ts +27 -0
  29. package/src/deep-freeze.ts +17 -0
  30. package/src/ensure-namespace.integration.test.ts +66 -0
  31. package/src/ensure-namespace.test.ts +125 -0
  32. package/src/ensure-namespace.ts +70 -0
  33. package/src/icu/extract-variables.ts +87 -0
  34. package/src/icu/parse-template.ts +24 -0
  35. package/src/index.ts +32 -0
  36. package/src/resolve-locale.test.ts +91 -0
  37. package/src/resolve-locale.ts +88 -0
  38. package/src/setup/setup-i18n.test.ts +86 -0
  39. package/src/setup/setup-i18n.ts +179 -0
  40. package/src/setup/type-names.ts +20 -0
  41. package/src/types.ts +34 -0
  42. package/src/validation/create-args-schema.ts +76 -0
  43. package/src/validation/create-normalized-schema.ts +52 -0
  44. package/src/validation/errors.ts +41 -0
  45. package/src/validation/index.ts +90 -0
  46. package/src/validation/normalize.ts +158 -0
  47. package/src/validation/to-dictionary.ts +67 -0
  48. package/src/validation/types.ts +78 -0
  49. package/src/validation/validate-normalized.ts +91 -0
  50. package/src/validation/validation.test.ts +265 -0
@@ -0,0 +1,482 @@
1
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { describe, expect, it, afterEach } from "vitest";
7
+
8
+ const codegenScript = fileURLToPath(new URL("./generate-i18n-types.ts", import.meta.url));
9
+
10
+ function runCodegen(cwd: string, configFile = "i18n.codegen.json") {
11
+ return spawnSync("tsx", [codegenScript, "--config", configFile], {
12
+ cwd,
13
+ encoding: "utf8",
14
+ env: process.env,
15
+ });
16
+ }
17
+
18
+ describe("generate-i18n-types", () => {
19
+ let tempDir: string;
20
+
21
+ afterEach(() => {
22
+ if (tempDir) {
23
+ rmSync(tempDir, { recursive: true, force: true });
24
+ }
25
+ });
26
+
27
+ it("generates nested types for multi-namespace config", () => {
28
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
29
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
30
+
31
+ writeFileSync(
32
+ join(tempDir, "src/i18n/translations/default.json"),
33
+ JSON.stringify({
34
+ login_button: { en: "Login" },
35
+ welcome: { en: "Welcome {name}!" },
36
+ inbox_owner: {
37
+ en: "{gender, select, female {{name} owns her inbox} other {{name} owns their inbox}}",
38
+ },
39
+ account_balance: { en: "Balance: {amount, number, ::currency/EUR}" },
40
+ appointment_summary: {
41
+ en: "Due {dueDate, date, short} at {startTime, time, short}",
42
+ },
43
+ })
44
+ );
45
+ writeFileSync(
46
+ join(tempDir, "src/i18n/translations/billing.json"),
47
+ JSON.stringify({
48
+ invoice_summary: {
49
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
50
+ },
51
+ })
52
+ );
53
+ writeFileSync(
54
+ join(tempDir, "i18n.codegen.json"),
55
+ JSON.stringify({
56
+ namespaces: {
57
+ default: "src/i18n/translations/default.json",
58
+ billing: "src/i18n/translations/billing.json",
59
+ },
60
+ typesOutput: "src/i18n/i18n-types.generated.ts",
61
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
62
+ instanceOutput: "src/i18n/instance.generated.ts",
63
+ paramsTypeName: "AppParams",
64
+ schemaTypeName: "AppSchema",
65
+ })
66
+ );
67
+
68
+ const result = runCodegen(tempDir);
69
+ expect(result.status).toBe(0);
70
+
71
+ const types = readFileSync(join(tempDir, "src/i18n/i18n-types.generated.ts"), "utf8");
72
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
73
+ expect(types).toContain("export const I18N_MODE = 'multi' as const");
74
+ expect(types).toContain("export type AppLocale = 'en'");
75
+ expect(factory).toContain("export function createI18n(");
76
+ expect(factory).toContain("IcuTranslationProviderMulti");
77
+ expect(types).toContain("login_button: never");
78
+ expect(types).toContain("welcome: { name: string }");
79
+ expect(types).toContain("inbox_owner: { gender: string; name: string }");
80
+ expect(types).toContain("account_balance: { amount: number }");
81
+ expect(types).toContain(
82
+ "appointment_summary: { dueDate: Date | number; startTime: Date | number }"
83
+ );
84
+ expect(types).toContain("invoice_summary: { count: number }");
85
+ expect(types).toContain("export type AppParams");
86
+ expect(types).toContain("export type AppSchema");
87
+ });
88
+
89
+ it("generates dictionary schema file when dictionarySchemaOutput is set", () => {
90
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
91
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
92
+
93
+ writeFileSync(
94
+ join(tempDir, "src/i18n/translations/default.json"),
95
+ JSON.stringify({
96
+ welcome: { en: "Welcome {name}!" },
97
+ })
98
+ );
99
+ writeFileSync(
100
+ join(tempDir, "src/i18n/translations/billing.json"),
101
+ JSON.stringify({
102
+ invoice_summary: {
103
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
104
+ },
105
+ })
106
+ );
107
+ writeFileSync(
108
+ join(tempDir, "i18n.codegen.json"),
109
+ JSON.stringify({
110
+ namespaces: {
111
+ default: "src/i18n/translations/default.json",
112
+ billing: "src/i18n/translations/billing.json",
113
+ },
114
+ typesOutput: "src/i18n/i18n-types.generated.ts",
115
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
116
+ instanceOutput: "src/i18n/instance.generated.ts",
117
+ dictionarySchemaOutput: "src/i18n/dictionary-schema.generated.ts",
118
+ paramsTypeName: "AppParams",
119
+ schemaTypeName: "AppSchema",
120
+ })
121
+ );
122
+
123
+ const result = runCodegen(tempDir);
124
+ expect(result.status).toBe(0);
125
+
126
+ const schema = readFileSync(join(tempDir, "src/i18n/dictionary-schema.generated.ts"), "utf8");
127
+ expect(schema).toContain("export const DICTIONARY_SPEC");
128
+ expect(schema).toContain("mode: 'multi' as const");
129
+ expect(schema).toContain("validateExternalDictionary");
130
+ expect(schema).toContain("validateExternalNamespace");
131
+ expect(schema).toContain('"name": "string"');
132
+ });
133
+
134
+ it("does not generate dictionary schema file when dictionarySchemaOutput is omitted", () => {
135
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
136
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
137
+
138
+ writeFileSync(
139
+ join(tempDir, "src/i18n/translations/translations.json"),
140
+ JSON.stringify({
141
+ login_button: { en: "Login" },
142
+ })
143
+ );
144
+ writeFileSync(
145
+ join(tempDir, "i18n.codegen.json"),
146
+ JSON.stringify({
147
+ dictionary: "src/i18n/translations/translations.json",
148
+ typesOutput: "src/i18n/i18n-types.generated.ts",
149
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
150
+ instanceOutput: "src/i18n/instance.generated.ts",
151
+ paramsTypeName: "AppParams",
152
+ schemaTypeName: "AppSchema",
153
+ })
154
+ );
155
+
156
+ const result = runCodegen(tempDir);
157
+ expect(result.status).toBe(0);
158
+ expect(result.stdout).not.toContain("dictionary-schema.generated.ts");
159
+ });
160
+
161
+ it("generates single-mode dictionary schema without namespace validator", () => {
162
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
163
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
164
+
165
+ writeFileSync(
166
+ join(tempDir, "src/i18n/translations/translations.json"),
167
+ JSON.stringify({
168
+ welcome: { en: "Welcome {name}!" },
169
+ })
170
+ );
171
+ writeFileSync(
172
+ join(tempDir, "i18n.codegen.json"),
173
+ JSON.stringify({
174
+ dictionary: "src/i18n/translations/translations.json",
175
+ typesOutput: "src/i18n/i18n-types.generated.ts",
176
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
177
+ instanceOutput: "src/i18n/instance.generated.ts",
178
+ dictionarySchemaOutput: "src/i18n/dictionary-schema.generated.ts",
179
+ paramsTypeName: "AppParams",
180
+ schemaTypeName: "AppSchema",
181
+ })
182
+ );
183
+
184
+ const result = runCodegen(tempDir);
185
+ expect(result.status).toBe(0);
186
+
187
+ const schema = readFileSync(join(tempDir, "src/i18n/dictionary-schema.generated.ts"), "utf8");
188
+ expect(schema).toContain("mode: 'single' as const");
189
+ expect(schema).toContain("validateExternalDictionary");
190
+ expect(schema).not.toContain("validateExternalNamespaceImpl");
191
+ });
192
+
193
+ it("generates flat types for single-file config", () => {
194
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
195
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
196
+
197
+ writeFileSync(
198
+ join(tempDir, "src/i18n/translations/translations.json"),
199
+ JSON.stringify({
200
+ login_button: { en: "Login" },
201
+ welcome: { en: "Welcome {name}!" },
202
+ })
203
+ );
204
+ writeFileSync(
205
+ join(tempDir, "i18n.codegen.json"),
206
+ JSON.stringify({
207
+ dictionary: "src/i18n/translations/translations.json",
208
+ typesOutput: "src/i18n/i18n-types.generated.ts",
209
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
210
+ instanceOutput: "src/i18n/instance.generated.ts",
211
+ paramsTypeName: "AppParams",
212
+ schemaTypeName: "AppSchema",
213
+ })
214
+ );
215
+
216
+ const result = runCodegen(tempDir);
217
+ expect(result.status).toBe(0);
218
+
219
+ const types = readFileSync(join(tempDir, "src/i18n/i18n-types.generated.ts"), "utf8");
220
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
221
+ expect(types).toContain("export const I18N_MODE = 'single' as const");
222
+ expect(types).toContain("export type AppLocale = 'en'");
223
+ expect(factory).toContain("export function createI18n(");
224
+ expect(factory).toContain("IcuTranslationProviderSingle");
225
+ expect(types).toContain("login_button: never;");
226
+ expect(types).toContain("welcome: { name: string };");
227
+ expect(types).not.toContain("default: {");
228
+ });
229
+
230
+ it("fails with a non-zero exit code on malformed ICU", () => {
231
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
232
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
233
+
234
+ writeFileSync(
235
+ join(tempDir, "src/i18n/translations/translations.json"),
236
+ JSON.stringify({
237
+ broken: { en: "Hello {{name}}" },
238
+ })
239
+ );
240
+ writeFileSync(
241
+ join(tempDir, "i18n.codegen.json"),
242
+ JSON.stringify({
243
+ dictionary: "src/i18n/translations/translations.json",
244
+ typesOutput: "src/i18n/i18n-types.generated.ts",
245
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
246
+ instanceOutput: "src/i18n/instance.generated.ts",
247
+ paramsTypeName: "AppParams",
248
+ schemaTypeName: "AppSchema",
249
+ })
250
+ );
251
+
252
+ const result = runCodegen(tempDir);
253
+ expect(result.status).not.toBe(0);
254
+ expect(result.stderr).toContain("MALFORMED_ARGUMENT");
255
+ });
256
+
257
+ it("generates locale fallback constants and wires them into the factory", () => {
258
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
259
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
260
+
261
+ writeFileSync(
262
+ join(tempDir, "src/i18n/translations/translations.json"),
263
+ JSON.stringify({
264
+ login_button: { en: "Login" },
265
+ })
266
+ );
267
+ writeFileSync(
268
+ join(tempDir, "i18n.codegen.json"),
269
+ JSON.stringify({
270
+ dictionary: "src/i18n/translations/translations.json",
271
+ typesOutput: "src/i18n/i18n-types.generated.ts",
272
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
273
+ instanceOutput: "src/i18n/instance.generated.ts",
274
+ paramsTypeName: "AppParams",
275
+ schemaTypeName: "AppSchema",
276
+ localeFallback: {
277
+ en: null,
278
+ "de-CH": "en",
279
+ },
280
+ })
281
+ );
282
+
283
+ const result = runCodegen(tempDir);
284
+ expect(result.status).toBe(0);
285
+
286
+ const types = readFileSync(join(tempDir, "src/i18n/i18n-types.generated.ts"), "utf8");
287
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
288
+ expect(types).toContain("export const LOCALE_FALLBACK");
289
+ expect(types).toContain('"de-CH": "en"');
290
+ expect(types).toContain("export type AppLocale = 'de-CH' | 'en'");
291
+ expect(factory).toContain("localeFallback: LOCALE_FALLBACK");
292
+ expect(factory).toContain(
293
+ "IcuTranslationProviderSingle<AppSchema, AppParams, AppLocale, typeof LOCALE_FALLBACK>"
294
+ );
295
+ });
296
+
297
+ it("fails on circular locale fallback in config", () => {
298
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
299
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
300
+
301
+ writeFileSync(
302
+ join(tempDir, "src/i18n/translations/translations.json"),
303
+ JSON.stringify({
304
+ login_button: { en: "Login" },
305
+ })
306
+ );
307
+ writeFileSync(
308
+ join(tempDir, "i18n.codegen.json"),
309
+ JSON.stringify({
310
+ dictionary: "src/i18n/translations/translations.json",
311
+ typesOutput: "src/i18n/i18n-types.generated.ts",
312
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
313
+ instanceOutput: "src/i18n/instance.generated.ts",
314
+ paramsTypeName: "AppParams",
315
+ schemaTypeName: "AppSchema",
316
+ localeFallback: {
317
+ a: "b",
318
+ b: "a",
319
+ },
320
+ })
321
+ );
322
+
323
+ const result = runCodegen(tempDir);
324
+ expect(result.status).not.toBe(0);
325
+ expect(result.stderr).toContain("Circular locale fallback");
326
+ });
327
+
328
+ it("generates lazy namespace loaders when loadOnInit is a subset", () => {
329
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
330
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
331
+
332
+ writeFileSync(
333
+ join(tempDir, "src/i18n/translations/default.json"),
334
+ JSON.stringify({
335
+ welcome: { en: "Welcome {name}!" },
336
+ })
337
+ );
338
+ writeFileSync(
339
+ join(tempDir, "src/i18n/translations/billing.json"),
340
+ JSON.stringify({
341
+ invoice_summary: {
342
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
343
+ },
344
+ })
345
+ );
346
+ writeFileSync(
347
+ join(tempDir, "i18n.codegen.json"),
348
+ JSON.stringify({
349
+ namespaces: {
350
+ default: "src/i18n/translations/default.json",
351
+ billing: "src/i18n/translations/billing.json",
352
+ },
353
+ loadOnInit: ["default"],
354
+ typesOutput: "src/i18n/i18n-types.generated.ts",
355
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
356
+ instanceOutput: "src/i18n/instance.generated.ts",
357
+ dictionarySchemaOutput: "src/i18n/dictionary-schema.generated.ts",
358
+ namespaceLoadersOutput: "src/i18n/namespace-loaders.generated.ts",
359
+ paramsTypeName: "AppParams",
360
+ schemaTypeName: "AppSchema",
361
+ })
362
+ );
363
+
364
+ const result = runCodegen(tempDir);
365
+ expect(result.status).toBe(0);
366
+
367
+ const types = readFileSync(join(tempDir, "src/i18n/i18n-types.generated.ts"), "utf8");
368
+ const dictionary = readFileSync(join(tempDir, "src/i18n/dictionary.generated.ts"), "utf8");
369
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
370
+ const loaders = readFileSync(join(tempDir, "src/i18n/namespace-loaders.generated.ts"), "utf8");
371
+
372
+ expect(types).toContain("export type LoadOnInitNamespace = 'default'");
373
+ expect(types).toContain("export type LazyNamespace = 'billing'");
374
+ expect(types).toContain("export type InitialSchema = Pick<AppSchema, LoadOnInitNamespace>");
375
+ expect(dictionary).toContain("export const dictionary: InitialSchema");
376
+ expect(dictionary).not.toContain("billingNs");
377
+ expect(factory).toContain("initialDictionary: InitialSchema = dictionary");
378
+ expect(loaders).toContain("export const namespaceLoaders");
379
+ expect(loaders).toContain("export async function ensureNamespacesLoaded(");
380
+ expect(loaders).toContain("namespaces: LazyNamespace[]");
381
+ expect(loaders).toContain("import('./translations/billing.json')");
382
+ });
383
+
384
+ it("keeps eager output when loadOnInit is omitted", () => {
385
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
386
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
387
+
388
+ writeFileSync(
389
+ join(tempDir, "src/i18n/translations/default.json"),
390
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
391
+ );
392
+ writeFileSync(
393
+ join(tempDir, "src/i18n/translations/billing.json"),
394
+ JSON.stringify({ invoice_summary: { en: "Invoice" } })
395
+ );
396
+ writeFileSync(
397
+ join(tempDir, "i18n.codegen.json"),
398
+ JSON.stringify({
399
+ namespaces: {
400
+ default: "src/i18n/translations/default.json",
401
+ billing: "src/i18n/translations/billing.json",
402
+ },
403
+ typesOutput: "src/i18n/i18n-types.generated.ts",
404
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
405
+ instanceOutput: "src/i18n/instance.generated.ts",
406
+ paramsTypeName: "AppParams",
407
+ schemaTypeName: "AppSchema",
408
+ })
409
+ );
410
+
411
+ const result = runCodegen(tempDir);
412
+ expect(result.status).toBe(0);
413
+
414
+ const types = readFileSync(join(tempDir, "src/i18n/i18n-types.generated.ts"), "utf8");
415
+ const dictionary = readFileSync(join(tempDir, "src/i18n/dictionary.generated.ts"), "utf8");
416
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
417
+
418
+ expect(types).not.toContain("LazyNamespace");
419
+ expect(dictionary).toContain("export const dictionary: AppSchema");
420
+ expect(dictionary).toContain("billingNs");
421
+ expect(factory).toContain("initialDictionary: AppSchema = dictionary");
422
+ expect(result.stdout).not.toContain("namespace-loaders.generated.ts");
423
+ });
424
+
425
+ it("fails when loadOnInit is used in single mode", () => {
426
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
427
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
428
+
429
+ writeFileSync(
430
+ join(tempDir, "src/i18n/translations/translations.json"),
431
+ JSON.stringify({ welcome: { en: "Welcome" } })
432
+ );
433
+ writeFileSync(
434
+ join(tempDir, "i18n.codegen.json"),
435
+ JSON.stringify({
436
+ dictionary: "src/i18n/translations/translations.json",
437
+ loadOnInit: ["default"],
438
+ typesOutput: "src/i18n/i18n-types.generated.ts",
439
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
440
+ instanceOutput: "src/i18n/instance.generated.ts",
441
+ paramsTypeName: "AppParams",
442
+ schemaTypeName: "AppSchema",
443
+ })
444
+ );
445
+
446
+ const result = runCodegen(tempDir);
447
+ expect(result.status).not.toBe(0);
448
+ expect(result.stderr).toContain("loadOnInit");
449
+ });
450
+
451
+ it("keeps multi mode when namespaces has a single entry", () => {
452
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
453
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
454
+
455
+ writeFileSync(
456
+ join(tempDir, "src/i18n/translations/default.json"),
457
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
458
+ );
459
+ writeFileSync(
460
+ join(tempDir, "i18n.codegen.json"),
461
+ JSON.stringify({
462
+ namespaces: {
463
+ default: "src/i18n/translations/default.json",
464
+ },
465
+ typesOutput: "src/i18n/i18n-types.generated.ts",
466
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
467
+ instanceOutput: "src/i18n/instance.generated.ts",
468
+ paramsTypeName: "AppParams",
469
+ schemaTypeName: "AppSchema",
470
+ })
471
+ );
472
+
473
+ const result = runCodegen(tempDir);
474
+ expect(result.status).toBe(0);
475
+
476
+ const types = readFileSync(join(tempDir, "src/i18n/i18n-types.generated.ts"), "utf8");
477
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
478
+ expect(types).toContain("export const I18N_MODE = 'multi' as const");
479
+ expect(types).toContain("default: {");
480
+ expect(factory).toContain("IcuTranslationProviderMulti");
481
+ });
482
+ });
@@ -0,0 +1,173 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { loadConfig, resolveLoadOnInit, resolveNamespaces } from "./config.js";
4
+ import { formatDictionaryFile } from "./emit/dictionary-file.js";
5
+ import {
6
+ formatDictionarySchemaFile,
7
+ formatDictionarySpecBlock,
8
+ } from "./emit/dictionary-schema-file.js";
9
+ import { formatInstanceFile } from "./emit/instance-file.js";
10
+ import { formatNamespaceLoadersFile } from "./emit/namespace-loaders-file.js";
11
+ import { formatTypesFile } from "./emit/types-file.js";
12
+ import { analyzeDictionaries } from "./icu-analysis.js";
13
+ import { collectRequestLocales, validateCodegenLocaleFallback } from "./locale-fallback.js";
14
+ import { fail, toModuleBasename } from "./paths.js";
15
+
16
+ function main() {
17
+ const configArgIndex = process.argv.indexOf("--config");
18
+ const configPath = path.resolve(
19
+ process.cwd(),
20
+ configArgIndex >= 0 ? process.argv[configArgIndex + 1]! : "i18n.codegen.json"
21
+ );
22
+ const projectRoot = path.dirname(configPath);
23
+
24
+ if (!fs.existsSync(configPath)) {
25
+ fail(`[Codegen Error] Config file not found: ${configPath}`);
26
+ }
27
+
28
+ const config = loadConfig(configPath);
29
+ const entries = resolveNamespaces(config);
30
+ const isSingle = Boolean(config.dictionary);
31
+ const { loadOnInitSet, lazyEntries, hasLazy } = resolveLoadOnInit(config, entries, isSingle);
32
+
33
+ if (hasLazy && !config.dictionarySchemaOutput) {
34
+ fail(
35
+ '[Codegen Error] Lazy namespaces require "dictionarySchemaOutput" for validateExternalNamespace.'
36
+ );
37
+ }
38
+
39
+ const typesOutputPath = path.resolve(projectRoot, config.typesOutput);
40
+ const dictionaryOutputPath = path.resolve(projectRoot, config.dictionaryOutput);
41
+ const instanceOutputPath = path.resolve(projectRoot, config.instanceOutput);
42
+
43
+ const analysisResult = analyzeDictionaries(projectRoot, entries);
44
+ if (!analysisResult.ok) {
45
+ process.exit(1);
46
+ }
47
+
48
+ const { paramsByNamespace, argsSpecByNamespace, locales } = analysisResult.analysis;
49
+
50
+ if (config.localeFallback && validateCodegenLocaleFallback(config.localeFallback, locales)) {
51
+ process.exit(1);
52
+ }
53
+
54
+ const paramsTypeName = config.paramsTypeName;
55
+ const schemaTypeName = config.schemaTypeName;
56
+ const localeTypeName = config.localeTypeName ?? schemaTypeName.replace(/Schema$/, "Locale");
57
+ const localeFallbackConstName = config.localeFallbackConstName ?? "LOCALE_FALLBACK";
58
+ const localeFallbackTypeName = `${localeTypeName}Fallback`;
59
+ const factoryName = config.factoryName ?? "createI18n";
60
+ const typesModule = toModuleBasename(typesOutputPath);
61
+
62
+ const requestLocales = collectRequestLocales(locales, config.localeFallback);
63
+ const requestLocaleUnion = [...requestLocales]
64
+ .sort()
65
+ .map((locale) => `'${locale}'`)
66
+ .join(" | ");
67
+
68
+ const eagerEntries = hasLazy
69
+ ? entries.filter((entry) => loadOnInitSet.has(entry.namespace))
70
+ : entries;
71
+
72
+ const typesContent = formatTypesFile({
73
+ isSingle,
74
+ entries,
75
+ projectRoot,
76
+ typesOutputPath,
77
+ paramsTypeName,
78
+ schemaTypeName,
79
+ localeTypeName,
80
+ localeFallbackConstName,
81
+ localeFallbackTypeName,
82
+ localeFallback: config.localeFallback,
83
+ paramsByNamespace,
84
+ requestLocaleUnion,
85
+ hasLazy,
86
+ loadOnInitSet,
87
+ lazyEntries,
88
+ });
89
+
90
+ const dictionaryContent = formatDictionaryFile({
91
+ isSingle,
92
+ hasLazy,
93
+ entries,
94
+ eagerEntries,
95
+ projectRoot,
96
+ dictionaryOutputPath,
97
+ typesOutputPath,
98
+ schemaTypeName,
99
+ });
100
+
101
+ const instanceContent = formatInstanceFile({
102
+ isSingle,
103
+ hasLazy,
104
+ typesOutputPath,
105
+ dictionaryOutputPath,
106
+ paramsTypeName,
107
+ schemaTypeName,
108
+ localeTypeName,
109
+ localeFallbackConstName,
110
+ factoryName,
111
+ hasLocaleFallback: Boolean(config.localeFallback),
112
+ });
113
+
114
+ fs.mkdirSync(path.dirname(typesOutputPath), { recursive: true });
115
+ fs.writeFileSync(typesOutputPath, typesContent);
116
+
117
+ fs.mkdirSync(path.dirname(dictionaryOutputPath), { recursive: true });
118
+ fs.writeFileSync(dictionaryOutputPath, dictionaryContent);
119
+
120
+ fs.mkdirSync(path.dirname(instanceOutputPath), { recursive: true });
121
+ fs.writeFileSync(instanceOutputPath, instanceContent);
122
+
123
+ const generatedFiles = [
124
+ path.relative(projectRoot, typesOutputPath),
125
+ path.relative(projectRoot, dictionaryOutputPath),
126
+ path.relative(projectRoot, instanceOutputPath),
127
+ ];
128
+
129
+ if (config.dictionarySchemaOutput) {
130
+ const dictionarySchemaOutputPath = path.resolve(projectRoot, config.dictionarySchemaOutput);
131
+ const dictionarySpecBlock = formatDictionarySpecBlock(isSingle, entries, argsSpecByNamespace);
132
+ const dictionarySchemaContent = formatDictionarySchemaFile(
133
+ schemaTypeName,
134
+ typesModule,
135
+ isSingle,
136
+ dictionarySpecBlock
137
+ );
138
+
139
+ fs.mkdirSync(path.dirname(dictionarySchemaOutputPath), { recursive: true });
140
+ fs.writeFileSync(dictionarySchemaOutputPath, dictionarySchemaContent);
141
+ generatedFiles.push(path.relative(projectRoot, dictionarySchemaOutputPath));
142
+ }
143
+
144
+ if (hasLazy) {
145
+ const namespaceLoadersOutputPath = path.resolve(
146
+ projectRoot,
147
+ config.namespaceLoadersOutput ??
148
+ path.join(path.dirname(config.instanceOutput), "namespace-loaders.generated.ts")
149
+ );
150
+ const dictionarySchemaOutputPath = path.resolve(projectRoot, config.dictionarySchemaOutput!);
151
+ const lazyEntriesWithPaths = lazyEntries.map((entry) => ({
152
+ ...entry,
153
+ absolutePath: path.resolve(projectRoot, entry.filePath),
154
+ }));
155
+ const namespaceLoadersContent = formatNamespaceLoadersFile(
156
+ namespaceLoadersOutputPath,
157
+ lazyEntriesWithPaths,
158
+ schemaTypeName,
159
+ typesModule,
160
+ toModuleBasename(dictionarySchemaOutputPath),
161
+ toModuleBasename(instanceOutputPath),
162
+ factoryName
163
+ );
164
+
165
+ fs.mkdirSync(path.dirname(namespaceLoadersOutputPath), { recursive: true });
166
+ fs.writeFileSync(namespaceLoadersOutputPath, namespaceLoadersContent);
167
+ generatedFiles.push(path.relative(projectRoot, namespaceLoadersOutputPath));
168
+ }
169
+
170
+ console.log(`✅ Generated: ${generatedFiles.join(", ")}`);
171
+ }
172
+
173
+ main();