@xndrjs/i18n 0.4.0 → 0.5.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 (57) hide show
  1. package/README.md +156 -29
  2. package/dist/codegen/index.d.ts +85 -0
  3. package/dist/codegen/index.js +181 -0
  4. package/dist/codegen/index.js.map +1 -0
  5. package/dist/index.d.ts +34 -5
  6. package/dist/index.js +175 -74
  7. package/dist/index.js.map +1 -1
  8. package/dist/validation/index.d.ts +4 -0
  9. package/dist/validation/index.js.map +1 -1
  10. package/package.json +6 -1
  11. package/src/IcuTranslationProviderMulti.test.ts +47 -0
  12. package/src/IcuTranslationProviderMulti.ts +41 -61
  13. package/src/IcuTranslationProviderSingle.test.ts +67 -0
  14. package/src/IcuTranslationProviderSingle.ts +27 -51
  15. package/src/audit/audit-dictionaries.ts +4 -1
  16. package/src/audit/run-audit.test.ts +35 -1
  17. package/src/audit/run-audit.ts +15 -7
  18. package/src/codegen/codegen-config-schema.ts +68 -1
  19. package/src/codegen/config.test.ts +174 -39
  20. package/src/codegen/config.ts +14 -5
  21. package/src/codegen/constants.ts +8 -0
  22. package/src/codegen/delivery-artifacts.test.ts +142 -0
  23. package/src/codegen/delivery-artifacts.ts +124 -0
  24. package/src/codegen/emit/dictionary-file.test.ts +190 -0
  25. package/src/codegen/emit/dictionary-file.ts +163 -0
  26. package/src/codegen/emit/dictionary-schema-file.ts +5 -0
  27. package/src/codegen/emit/instance-file.ts +114 -28
  28. package/src/codegen/emit/namespace-loaders-file.test.ts +176 -0
  29. package/src/codegen/emit/namespace-loaders-file.ts +114 -6
  30. package/src/codegen/emit/types-file.test.ts +114 -0
  31. package/src/codegen/emit/types-file.ts +48 -11
  32. package/src/codegen/generate-i18n-types.test.ts +754 -16
  33. package/src/codegen/generate-i18n-types.ts +111 -47
  34. package/src/codegen/icu-analysis.ts +9 -2
  35. package/src/codegen/locale-fallback.ts +19 -10
  36. package/src/codegen/locale-policy.ts +4 -0
  37. package/src/codegen/paths.ts +9 -5
  38. package/src/codegen/project-locales-set-namespace.test.ts +5 -5
  39. package/src/codegen/read-dictionary.test.ts +474 -2
  40. package/src/codegen/read-dictionary.ts +164 -15
  41. package/src/codegen/write-file-if-changed.test.ts +42 -0
  42. package/src/codegen/write-file-if-changed.ts +20 -0
  43. package/src/codegen-config/build-config.ts +34 -0
  44. package/src/codegen-config/codegen-config.test.ts +32 -0
  45. package/src/codegen-config/index.ts +21 -0
  46. package/src/codegen-config/write-config.ts +8 -0
  47. package/src/deep-freeze.test.ts +13 -0
  48. package/src/deep-freeze.ts +5 -0
  49. package/src/format-core.ts +91 -0
  50. package/src/index.ts +8 -2
  51. package/src/project-locales.test.ts +129 -10
  52. package/src/project-locales.ts +90 -3
  53. package/src/setup/setup-i18n.test.ts +1 -1
  54. package/src/setup/setup-i18n.ts +6 -30
  55. package/src/types.ts +19 -4
  56. package/src/validation/normalize.ts +4 -0
  57. /package/src/{setup → codegen-config}/type-names.ts +0 -0
@@ -1,4 +1,4 @@
1
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
@@ -73,10 +73,11 @@ describe("generate-i18n-types", () => {
73
73
  expect(types).toContain("export const I18N_MODE = 'multi' as const");
74
74
  expect(types).toContain("export type AppLocale = 'en'");
75
75
  expect(factory).toContain("export function createI18n(");
76
- expect(factory).toContain("export function projectLocales(");
77
- expect(factory).toContain("projectNamespacesLocalesCore(dictionary, locales)");
76
+ expect(factory).toContain("options?: { onMissing?: OnMissingTranslation }");
77
+ expect(factory).toContain("export function projectDictionaryLocales(");
78
+ expect(factory).toContain("projectDictionaryLocalesCore(dictionary, locales)");
78
79
  expect(factory).toContain("export function projectNamespaceLocales(");
79
- expect(factory).toContain("projectLocalesCore(dictionary, locales)");
80
+ expect(factory).toContain("projectNamespaceLocalesCore(dictionary, locales)");
80
81
  expect(factory).toContain("IcuTranslationProviderMulti");
81
82
  expect(types).toContain("login_button: never");
82
83
  expect(types).toContain("welcome: { name: string }");
@@ -90,6 +91,41 @@ describe("generate-i18n-types", () => {
90
91
  expect(types).toContain("export type AppSchema");
91
92
  });
92
93
 
94
+ it("does not rewrite unchanged generated files on rerun", () => {
95
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
96
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
97
+
98
+ writeFileSync(
99
+ join(tempDir, "src/i18n/translations/translations.json"),
100
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
101
+ );
102
+ writeFileSync(
103
+ join(tempDir, "i18n.codegen.json"),
104
+ JSON.stringify({
105
+ dictionary: "src/i18n/translations/translations.json",
106
+ typesOutput: "src/i18n/i18n-types.generated.ts",
107
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
108
+ instanceOutput: "src/i18n/instance.generated.ts",
109
+ paramsTypeName: "AppParams",
110
+ schemaTypeName: "AppSchema",
111
+ })
112
+ );
113
+
114
+ expect(runCodegen(tempDir).status).toBe(0);
115
+
116
+ const generatedPaths = [
117
+ join(tempDir, "src/i18n/i18n-types.generated.ts"),
118
+ join(tempDir, "src/i18n/dictionary.generated.ts"),
119
+ join(tempDir, "src/i18n/instance.generated.ts"),
120
+ ];
121
+ const mtimesBefore = generatedPaths.map((filePath) => statSync(filePath).mtimeMs);
122
+
123
+ expect(runCodegen(tempDir).status).toBe(0);
124
+
125
+ const mtimesAfter = generatedPaths.map((filePath) => statSync(filePath).mtimeMs);
126
+ expect(mtimesAfter).toEqual(mtimesBefore);
127
+ });
128
+
93
129
  it("generates dictionary schema file when dictionarySchemaOutput is set", () => {
94
130
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
95
131
  mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
@@ -225,10 +261,10 @@ describe("generate-i18n-types", () => {
225
261
  expect(types).toContain("export const I18N_MODE = 'single' as const");
226
262
  expect(types).toContain("export type AppLocale = 'en'");
227
263
  expect(factory).toContain("export function createI18n(");
228
- expect(factory).toContain("export function projectLocales(");
264
+ expect(factory).toContain("export function projectDictionaryLocales(");
229
265
  expect(factory).toContain("dictionary: AppSchema");
230
- expect(factory).not.toContain("projectNamespaceLocales");
231
- expect(factory).not.toContain("projectNamespacesLocalesCore");
266
+ expect(factory).not.toContain("export function projectNamespaceLocales(");
267
+ expect(factory).not.toContain("projectDictionaryLocalesCore");
232
268
  expect(factory).toContain("IcuTranslationProviderSingle");
233
269
  expect(types).toContain("login_button: never;");
234
270
  expect(types).toContain("welcome: { name: string };");
@@ -297,7 +333,8 @@ describe("generate-i18n-types", () => {
297
333
  expect(types).toContain('"de-CH": "en"');
298
334
  expect(types).toContain("export type AppLocale = 'de-CH' | 'en'");
299
335
  expect(factory).toContain("localeFallback: LOCALE_FALLBACK");
300
- expect(factory).toContain("projectLocalesCore(dictionary, locales, LOCALE_FALLBACK)");
336
+ expect(factory).toContain("...options");
337
+ expect(factory).toContain("projectNamespaceLocalesCore(dictionary, locales, LOCALE_FALLBACK)");
301
338
  expect(factory).toContain(
302
339
  "IcuTranslationProviderSingle<AppSchema, AppParams, AppLocale, typeof LOCALE_FALLBACK>"
303
340
  );
@@ -367,7 +404,7 @@ describe("generate-i18n-types", () => {
367
404
  const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
368
405
  expect(types).not.toContain("export const LOCALE_FALLBACK");
369
406
  expect(factory).not.toContain("localeFallback:");
370
- expect(factory).toContain("projectLocalesCore(dictionary, locales)");
407
+ expect(factory).toContain("projectNamespaceLocalesCore(dictionary, locales)");
371
408
  expect(factory).not.toContain("LOCALE_FALLBACK");
372
409
  expect(types).toContain("export type AppLocale = 'en' | 'it'");
373
410
  });
@@ -456,9 +493,6 @@ describe("generate-i18n-types", () => {
456
493
  expect(factory).not.toContain("import { dictionary }");
457
494
  expect(loaders).toContain("export const namespaceLoaders");
458
495
  expect(loaders).toContain("[K in LazyNamespace]: () => Promise<AppSchema[K]>");
459
- expect(loaders).not.toContain("ensureNamespacesLoaded");
460
- expect(loaders).not.toContain("ensureNamespacesLoadedImpl");
461
- expect(loaders).not.toContain("validateExternalNamespace");
462
496
  expect(loaders).toContain("import('./translations/billing.json')");
463
497
  });
464
498
 
@@ -851,7 +885,8 @@ describe("generate-i18n-types", () => {
851
885
  );
852
886
  const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
853
887
  expect(dictionary).toContain("from './translations/translations.json'");
854
- expect(types).toContain("typeof import('./translations/translations.json')");
888
+ expect(types).toContain("welcome: Partial<Record<AppLocale, string>>;");
889
+ expect(types).not.toContain("typeof import");
855
890
  });
856
891
 
857
892
  it("supports mixed json and yaml namespaces", () => {
@@ -896,8 +931,9 @@ describe("generate-i18n-types", () => {
896
931
  const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
897
932
  expect(dictionary).toContain("from '../translations/default.json'");
898
933
  expect(dictionary).toContain("from './translations/billing.json'");
899
- expect(types).toContain("default: typeof import('../translations/default.json')");
900
- expect(types).toContain("billing: typeof import('./translations/billing.json')");
934
+ expect(types).toContain("login_button: Partial<Record<AppLocale, string>>;");
935
+ expect(types).toContain("invoice_summary: Partial<Record<AppLocale, string>>;");
936
+ expect(types).not.toContain("typeof import");
901
937
  });
902
938
 
903
939
  it("generates lazy loaders that import compiled json for yaml namespaces", () => {
@@ -965,7 +1001,709 @@ describe("generate-i18n-types", () => {
965
1001
 
966
1002
  const result = runCodegen(tempDir);
967
1003
  expect(result.status).not.toBe(0);
968
- expect(result.stderr).toContain("unsupported dictionary extension");
1004
+ expect(result.stderr).toMatch(/unsupported dictionary (extension|format)/i);
1005
+ });
1006
+
1007
+ it("generates split-by-locale delivery with per-locale files and defaultDictionaryFor", () => {
1008
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
1009
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
1010
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
1011
+
1012
+ writeFileSync(
1013
+ join(tempDir, "src/i18n/translations/default.json"),
1014
+ JSON.stringify({
1015
+ welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
1016
+ })
1017
+ );
1018
+ writeFileSync(
1019
+ join(tempDir, "src/i18n/translations/user.json"),
1020
+ JSON.stringify({
1021
+ profile_title: { en: "Your profile", it: "Il tuo profilo" },
1022
+ })
1023
+ );
1024
+ writeFileSync(
1025
+ join(tempDir, "src/i18n/translations/billing.json"),
1026
+ JSON.stringify({
1027
+ invoice_summary: {
1028
+ en: "You have {count, plural, one {1 invoice} other {{count} invoices}}",
1029
+ it: "Hai {count} fatture",
1030
+ },
1031
+ })
1032
+ );
1033
+ writeFileSync(
1034
+ join(tempDir, "i18n.codegen.json"),
1035
+ JSON.stringify({
1036
+ namespaces: {
1037
+ default: "src/i18n/translations/default.json",
1038
+ user: "src/i18n/translations/user.json",
1039
+ billing: "src/i18n/translations/billing.json",
1040
+ },
1041
+ loadOnInit: ["default"],
1042
+ delivery: "split-by-locale",
1043
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
1044
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
1045
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
1046
+ namespaceLoadersOutput: "src/i18n/generated/namespace-loaders.generated.ts",
1047
+ paramsTypeName: "AppParams",
1048
+ schemaTypeName: "AppSchema",
1049
+ })
1050
+ );
1051
+
1052
+ const result = runCodegen(tempDir);
1053
+ expect(result.status).toBe(0);
1054
+ expect(result.stdout).toContain("default.en.json");
1055
+ expect(result.stdout).toContain("user.it.json");
1056
+ expect(result.stdout).toContain("billing.en.json");
1057
+
1058
+ const userEn = JSON.parse(
1059
+ readFileSync(join(tempDir, "src/i18n/generated/translations/user.en.json"), "utf8")
1060
+ ) as Record<string, Record<string, string>>;
1061
+ expect(userEn.profile_title).toEqual({ en: "Your profile" });
1062
+
1063
+ const billingIt = JSON.parse(
1064
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.it.json"), "utf8")
1065
+ ) as Record<string, Record<string, string>>;
1066
+ expect(billingIt.invoice_summary).toEqual({ it: "Hai {count} fatture" });
1067
+
1068
+ expect(() =>
1069
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.json"), "utf8")
1070
+ ).toThrow();
1071
+
1072
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
1073
+ const dictionary = readFileSync(
1074
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
1075
+ "utf8"
1076
+ );
1077
+ const loaders = readFileSync(
1078
+ join(tempDir, "src/i18n/generated/namespace-loaders.generated.ts"),
1079
+ "utf8"
1080
+ );
1081
+
1082
+ expect(types).toContain("welcome: Partial<Record<AppLocale, string>>;");
1083
+ expect(types).toContain("invoice_summary: Partial<Record<AppLocale, string>>;");
1084
+ expect(types).not.toContain("typeof import");
1085
+ expect(types).toContain("export type AppLocale = 'en' | 'it'");
1086
+ expect(types).toContain("invoice_summary: { count: number }");
1087
+
1088
+ expect(dictionary).toContain("import defaultEn from './translations/default.en.json';");
1089
+ expect(dictionary).toContain("import defaultIt from './translations/default.it.json';");
1090
+ expect(dictionary).toContain(
1091
+ "export function defaultDictionaryFor(locale: AppLocale): InitialSchema"
1092
+ );
1093
+ expect(dictionary).toContain("default: defaultByLocale[locale],");
1094
+ expect(dictionary).not.toContain("as unknown as");
1095
+ expect(dictionary).not.toContain("export const defaultDictionary");
1096
+ expect(dictionary).not.toContain("billingEn");
1097
+
1098
+ expect(loaders).toContain(
1099
+ "[K in LazyNamespace]: (locale: AppLocale) => Promise<AppSchema[K]>;"
1100
+ );
1101
+ expect(loaders).toContain("billing: (locale) => {");
1102
+ expect(loaders).toContain('case "it":');
1103
+ expect(loaders).toContain(
1104
+ "return import('./translations/billing.it.json').then((m) => m.default);"
1105
+ );
1106
+ expect(loaders).toContain('case "en":');
1107
+ expect(loaders).toContain(
1108
+ "return import('./translations/billing.en.json').then((m) => m.default);"
1109
+ );
1110
+ expect(loaders).toContain("user: (locale) => {");
1111
+ expect(loaders).not.toContain("import('./translations/billing.json')");
1112
+ });
1113
+
1114
+ it("writes delivery json under deliveryOutput when it differs from typesOutput directory", () => {
1115
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
1116
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
1117
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
1118
+
1119
+ writeFileSync(
1120
+ join(tempDir, "src/i18n/translations/default.json"),
1121
+ JSON.stringify({
1122
+ welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
1123
+ })
1124
+ );
1125
+ writeFileSync(
1126
+ join(tempDir, "src/i18n/translations/billing.yaml"),
1127
+ `invoice_summary:
1128
+ en: You have {count} invoices
1129
+ it: Hai {count} fatture
1130
+ `
1131
+ );
1132
+ writeFileSync(
1133
+ join(tempDir, "i18n.codegen.json"),
1134
+ JSON.stringify({
1135
+ namespaces: {
1136
+ default: "src/i18n/translations/default.json",
1137
+ billing: "src/i18n/translations/billing.yaml",
1138
+ },
1139
+ loadOnInit: ["default"],
1140
+ delivery: "split-by-locale",
1141
+ deliveryOutput: "public/i18n",
1142
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
1143
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
1144
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
1145
+ namespaceLoadersOutput: "src/i18n/generated/namespace-loaders.generated.ts",
1146
+ paramsTypeName: "AppParams",
1147
+ schemaTypeName: "AppSchema",
1148
+ })
1149
+ );
1150
+
1151
+ const result = runCodegen(tempDir);
1152
+ expect(result.status).toBe(0);
1153
+ expect(result.stdout).toContain("public/i18n/translations/billing.en.json");
1154
+ expect(result.stdout).toContain("public/i18n/translations/billing.it.json");
1155
+
1156
+ expect(() =>
1157
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.en.json"), "utf8")
1158
+ ).toThrow();
1159
+
1160
+ const billingEn = JSON.parse(
1161
+ readFileSync(join(tempDir, "public/i18n/translations/billing.en.json"), "utf8")
1162
+ ) as Record<string, Record<string, string>>;
1163
+ expect(billingEn.invoice_summary).toEqual({ en: "You have {count} invoices" });
1164
+
1165
+ const dictionary = readFileSync(
1166
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
1167
+ "utf8"
1168
+ );
1169
+ const loaders = readFileSync(
1170
+ join(tempDir, "src/i18n/generated/namespace-loaders.generated.ts"),
1171
+ "utf8"
1172
+ );
1173
+
1174
+ expect(dictionary).toContain(
1175
+ "import defaultEn from '../../../public/i18n/translations/default.en.json';"
1176
+ );
1177
+ expect(dictionary).toContain(
1178
+ "import defaultIt from '../../../public/i18n/translations/default.it.json';"
1179
+ );
1180
+ expect(loaders).toContain(
1181
+ "return import('../../../public/i18n/translations/billing.en.json').then((m) => m.default);"
1182
+ );
1183
+ expect(loaders).toContain(
1184
+ "return import('../../../public/i18n/translations/billing.it.json').then((m) => m.default);"
1185
+ );
1186
+ });
1187
+
1188
+ it("applies localeFallback when generating split-by-locale files", () => {
1189
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
1190
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
1191
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
1192
+
1193
+ writeFileSync(
1194
+ join(tempDir, "src/i18n/translations/default.json"),
1195
+ JSON.stringify({
1196
+ login_button: { en: "Login", it: "Accedi" },
1197
+ })
1198
+ );
1199
+ writeFileSync(
1200
+ join(tempDir, "i18n.codegen.json"),
1201
+ JSON.stringify({
1202
+ namespaces: {
1203
+ default: "src/i18n/translations/default.json",
1204
+ },
1205
+ delivery: "split-by-locale",
1206
+ localeFallback: {
1207
+ en: null,
1208
+ "de-DE": "en",
1209
+ "de-CH": "de-DE",
1210
+ it: "en",
1211
+ },
1212
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
1213
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
1214
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
1215
+ paramsTypeName: "AppParams",
1216
+ schemaTypeName: "AppSchema",
1217
+ })
1218
+ );
1219
+
1220
+ const result = runCodegen(tempDir);
1221
+ expect(result.status).toBe(0);
1222
+ expect(result.stdout).toContain("default.de-CH.json");
1223
+
1224
+ const deChSplit = JSON.parse(
1225
+ readFileSync(join(tempDir, "src/i18n/generated/translations/default.de-CH.json"), "utf8")
1226
+ ) as Record<string, Record<string, string>>;
1227
+ expect(deChSplit).toEqual({
1228
+ login_button: { "de-CH": "Login" },
1229
+ });
1230
+
1231
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
1232
+ expect(types).toContain("export type AppLocale = 'de-CH' | 'de-DE' | 'en' | 'it'");
1233
+ });
1234
+
1235
+ it("splits yaml sources into per-locale json without a canonical intermediate", () => {
1236
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
1237
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
1238
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
1239
+
1240
+ writeFileSync(
1241
+ join(tempDir, "src/i18n/translations/billing.yaml"),
1242
+ `invoice_summary:
1243
+ en: You have {count} invoices
1244
+ it: Hai {count} fatture
1245
+ `
1246
+ );
1247
+ writeFileSync(
1248
+ join(tempDir, "i18n.codegen.json"),
1249
+ JSON.stringify({
1250
+ namespaces: {
1251
+ billing: "src/i18n/translations/billing.yaml",
1252
+ },
1253
+ delivery: "split-by-locale",
1254
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
1255
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
1256
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
1257
+ paramsTypeName: "AppParams",
1258
+ schemaTypeName: "AppSchema",
1259
+ })
1260
+ );
1261
+
1262
+ const result = runCodegen(tempDir);
1263
+ expect(result.status).toBe(0);
1264
+ expect(result.stdout).toContain("billing.en.json");
1265
+ expect(result.stdout).toContain("billing.it.json");
1266
+
1267
+ expect(() =>
1268
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.json"), "utf8")
1269
+ ).toThrow();
1270
+
1271
+ const enSplit = JSON.parse(
1272
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.en.json"), "utf8")
1273
+ ) as Record<string, Record<string, string>>;
1274
+ expect(enSplit.invoice_summary).toEqual({ en: "You have {count} invoices" });
1275
+
1276
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
1277
+ const dictionary = readFileSync(
1278
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
1279
+ "utf8"
1280
+ );
1281
+ expect(types).toContain("invoice_summary: Partial<Record<AppLocale, string>>;");
1282
+ expect(types).not.toContain("typeof import");
1283
+ expect(dictionary).toContain("import billingEn from './translations/billing.en.json';");
1284
+ expect(dictionary).toContain(
1285
+ "export function defaultDictionaryFor(locale: AppLocale): AppSchema"
1286
+ );
1287
+ expect(dictionary).toContain("billing: billingByLocale[locale],");
1288
+ });
1289
+
1290
+ it("emits defaultDictionaryFor in single mode with split-by-locale delivery", () => {
1291
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
1292
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
1293
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
1294
+
1295
+ const sourcePath = join(tempDir, "src/i18n/translations/translations.json");
1296
+ writeFileSync(
1297
+ sourcePath,
1298
+ JSON.stringify({
1299
+ welcome: { en: "Welcome {name}!", it: "Benvenuto {name}!" },
1300
+ })
1301
+ );
1302
+ writeFileSync(
1303
+ join(tempDir, "i18n.codegen.json"),
1304
+ JSON.stringify({
1305
+ dictionary: "src/i18n/translations/translations.json",
1306
+ delivery: "split-by-locale",
1307
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
1308
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
1309
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
1310
+ paramsTypeName: "AppParams",
1311
+ schemaTypeName: "AppSchema",
1312
+ })
1313
+ );
1314
+
1315
+ const result = runCodegen(tempDir);
1316
+ expect(result.status).toBe(0);
1317
+
1318
+ const sourceAfter = JSON.parse(readFileSync(sourcePath, "utf8")) as Record<
1319
+ string,
1320
+ Record<string, string>
1321
+ >;
1322
+ expect(sourceAfter.welcome).toEqual({
1323
+ en: "Welcome {name}!",
1324
+ it: "Benvenuto {name}!",
1325
+ });
1326
+
1327
+ const dictionary = readFileSync(
1328
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
1329
+ "utf8"
1330
+ );
1331
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
1332
+ expect(types).toContain("welcome: Partial<Record<AppLocale, string>>;");
1333
+ expect(types).not.toContain("typeof import");
1334
+ expect(dictionary).toContain("import defaultEn from './translations/translations.en.json';");
1335
+ expect(dictionary).toContain("import defaultIt from './translations/translations.it.json';");
1336
+ expect(dictionary).toContain(
1337
+ "export function defaultDictionaryFor(locale: AppLocale): AppSchema"
1338
+ );
1339
+ expect(dictionary).toContain("return defaultByLocale[locale];");
1340
+ expect(dictionary).not.toContain("export const defaultDictionary");
1341
+ });
1342
+
1343
+ it("keeps canonical output when delivery is explicitly set to canonical", () => {
1344
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
1345
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
1346
+
1347
+ writeFileSync(
1348
+ join(tempDir, "src/i18n/translations/default.json"),
1349
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
1350
+ );
1351
+ writeFileSync(
1352
+ join(tempDir, "src/i18n/translations/billing.json"),
1353
+ JSON.stringify({ invoice_summary: { en: "Invoice" } })
1354
+ );
1355
+ writeFileSync(
1356
+ join(tempDir, "i18n.codegen.json"),
1357
+ JSON.stringify({
1358
+ namespaces: {
1359
+ default: "src/i18n/translations/default.json",
1360
+ billing: "src/i18n/translations/billing.json",
1361
+ },
1362
+ loadOnInit: ["default"],
1363
+ delivery: "canonical",
1364
+ typesOutput: "src/i18n/i18n-types.generated.ts",
1365
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
1366
+ instanceOutput: "src/i18n/instance.generated.ts",
1367
+ namespaceLoadersOutput: "src/i18n/namespace-loaders.generated.ts",
1368
+ paramsTypeName: "AppParams",
1369
+ schemaTypeName: "AppSchema",
1370
+ })
1371
+ );
1372
+
1373
+ const result = runCodegen(tempDir);
1374
+ expect(result.status).toBe(0);
1375
+
1376
+ const dictionary = readFileSync(join(tempDir, "src/i18n/dictionary.generated.ts"), "utf8");
1377
+ const loaders = readFileSync(join(tempDir, "src/i18n/namespace-loaders.generated.ts"), "utf8");
1378
+
1379
+ expect(dictionary).toContain("export const defaultDictionary: InitialSchema");
1380
+ expect(dictionary).not.toContain("defaultDictionaryFor");
1381
+ expect(loaders).toContain("[K in LazyNamespace]: () => Promise<AppSchema[K]>");
1382
+ expect(loaders).toContain("import('./translations/billing.json')");
1383
+ expect(loaders).not.toContain("billing: {");
1384
+ expect(result.stdout).not.toContain(".en.json");
1385
+ expect(result.stdout).not.toContain(".it.json");
1386
+ });
1387
+
1388
+ it("generates custom delivery with per-area json files", () => {
1389
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
1390
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
1391
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
1392
+
1393
+ writeFileSync(
1394
+ join(tempDir, "src/i18n/translations/default.json"),
1395
+ JSON.stringify({
1396
+ some_key: { it: "Ciao", fr: "Hallo", "en-US": "Hello" },
1397
+ some_other_key: { "en-US": "Computer", fr: "Ordinateur" },
1398
+ })
1399
+ );
1400
+ writeFileSync(
1401
+ join(tempDir, "src/i18n/translations/billing.json"),
1402
+ JSON.stringify({
1403
+ invoice_summary: {
1404
+ "en-US": "You have {count, plural, one {1 invoice} other {{count} invoices}}",
1405
+ it: "Hai {count} fatture",
1406
+ },
1407
+ })
1408
+ );
1409
+ writeFileSync(
1410
+ join(tempDir, "i18n.codegen.json"),
1411
+ JSON.stringify({
1412
+ namespaces: {
1413
+ default: "src/i18n/translations/default.json",
1414
+ billing: "src/i18n/translations/billing.json",
1415
+ },
1416
+ loadOnInit: ["default"],
1417
+ delivery: "custom",
1418
+ deliveryArtifacts: {
1419
+ eu: ["it", "fr"],
1420
+ us: ["en-US"],
1421
+ },
1422
+ localeFallback: {
1423
+ "en-US": null,
1424
+ it: "en-US",
1425
+ fr: null,
1426
+ },
1427
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
1428
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
1429
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
1430
+ namespaceLoadersOutput: "src/i18n/generated/namespace-loaders.generated.ts",
1431
+ paramsTypeName: "AppParams",
1432
+ schemaTypeName: "AppSchema",
1433
+ })
1434
+ );
1435
+
1436
+ const result = runCodegen(tempDir);
1437
+ expect(result.status).toBe(0);
1438
+ expect(result.stdout).toContain("default.eu.json");
1439
+ expect(result.stdout).toContain("default.us.json");
1440
+ expect(result.stdout).toContain("billing.eu.json");
1441
+ expect(result.stdout).toContain("billing.us.json");
1442
+
1443
+ const defaultEu = JSON.parse(
1444
+ readFileSync(join(tempDir, "src/i18n/generated/translations/default.eu.json"), "utf8")
1445
+ ) as Record<string, Record<string, string>>;
1446
+ expect(defaultEu).toEqual({
1447
+ some_key: { it: "Ciao", fr: "Hallo" },
1448
+ some_other_key: { it: "Computer", fr: "Ordinateur" },
1449
+ });
1450
+
1451
+ const defaultUs = JSON.parse(
1452
+ readFileSync(join(tempDir, "src/i18n/generated/translations/default.us.json"), "utf8")
1453
+ ) as Record<string, Record<string, string>>;
1454
+ expect(defaultUs).toEqual({
1455
+ some_key: { "en-US": "Hello" },
1456
+ some_other_key: { "en-US": "Computer" },
1457
+ });
1458
+
1459
+ expect(() =>
1460
+ readFileSync(join(tempDir, "src/i18n/generated/translations/default.json"), "utf8")
1461
+ ).toThrow();
1462
+
1463
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
1464
+ const dictionary = readFileSync(
1465
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
1466
+ "utf8"
1467
+ );
1468
+ const loaders = readFileSync(
1469
+ join(tempDir, "src/i18n/generated/namespace-loaders.generated.ts"),
1470
+ "utf8"
1471
+ );
1472
+ const factory = readFileSync(join(tempDir, "src/i18n/generated/instance.generated.ts"), "utf8");
1473
+
1474
+ const billingEu = JSON.parse(
1475
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.eu.json"), "utf8")
1476
+ ) as Record<string, Record<string, string>>;
1477
+ expect(billingEu.invoice_summary).toEqual({ it: "Hai {count} fatture" });
1478
+
1479
+ const billingUs = JSON.parse(
1480
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.us.json"), "utf8")
1481
+ ) as Record<string, Record<string, string>>;
1482
+ expect(billingUs.invoice_summary).toEqual({
1483
+ "en-US": "You have {count, plural, one {1 invoice} other {{count} invoices}}",
1484
+ });
1485
+
1486
+ expect(() =>
1487
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.json"), "utf8")
1488
+ ).toThrow();
1489
+
1490
+ expect(types).toContain("export type AppDeliveryArea = 'eu' | 'us';");
1491
+ expect(types).toContain("export const LOCALE_DELIVERY_AREA = {");
1492
+ expect(types).toContain('"it": "eu"');
1493
+ expect(types).toContain('"fr": "eu"');
1494
+ expect(types).toContain('"en-US": "us"');
1495
+ expect(types).toContain("} as const satisfies Record<AppLocale, AppDeliveryArea>;");
1496
+ expect(types).toContain("export type AppLocale = 'en-US' | 'fr' | 'it';");
1497
+ expect(types).toContain("export const LOCALE_FALLBACK");
1498
+ expect(types).toContain('"it": "en-US"');
1499
+ expect(types).toContain("some_key: Partial<Record<AppLocale, string>>;");
1500
+ expect(types).toContain("invoice_summary: Partial<Record<AppLocale, string>>;");
1501
+ expect(types).not.toContain("typeof import");
1502
+ expect(types).toContain("invoice_summary: { count: number }");
1503
+
1504
+ expect(dictionary).toContain("import defaultEu from './translations/default.eu.json';");
1505
+ expect(dictionary).toContain("import defaultUs from './translations/default.us.json';");
1506
+ expect(dictionary).toContain(
1507
+ "export function defaultDictionaryFor(area: AppDeliveryArea): InitialSchema"
1508
+ );
1509
+ expect(dictionary).toContain("default: defaultByArea[area],");
1510
+ expect(dictionary).not.toContain("as unknown as");
1511
+ expect(dictionary).not.toContain("export const defaultDictionary");
1512
+ expect(dictionary).not.toContain("billingEu");
1513
+
1514
+ expect(loaders).toContain(
1515
+ "[K in LazyNamespace]: (area: AppDeliveryArea) => Promise<AppSchema[K]>;"
1516
+ );
1517
+ expect(loaders).toContain("billing: (area) => {");
1518
+ expect(loaders).toContain('case "eu":');
1519
+ expect(loaders).toContain('case "us":');
1520
+ expect(loaders).toContain(
1521
+ "return import('./translations/billing.eu.json').then((m) => m.default);"
1522
+ );
1523
+ expect(loaders).toContain(
1524
+ "return import('./translations/billing.us.json').then((m) => m.default);"
1525
+ );
1526
+ expect(loaders).not.toContain("import('./translations/billing.json')");
1527
+
1528
+ expect(factory).toContain("export function projectDictionaryForDeliveryArea(");
1529
+ expect(factory).toContain("export function projectNamespaceForDeliveryArea(");
1530
+ expect(factory).toContain(
1531
+ "projectDictionaryForDeliveryAreaCore(dictionary, areaLocales, LOCALE_FALLBACK)"
1532
+ );
1533
+ expect(factory).toContain(
1534
+ "projectNamespaceForDeliveryAreaCore(dictionary, areaLocales, LOCALE_FALLBACK)"
1535
+ );
1536
+ });
1537
+
1538
+ it("fails when deliveryArtifacts does not partition request locales", () => {
1539
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
1540
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
1541
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
1542
+
1543
+ writeFileSync(
1544
+ join(tempDir, "src/i18n/translations/default.json"),
1545
+ JSON.stringify({
1546
+ some_key: { it: "Ciao", fr: "Hallo", "en-US": "Hello" },
1547
+ })
1548
+ );
1549
+ writeFileSync(
1550
+ join(tempDir, "i18n.codegen.json"),
1551
+ JSON.stringify({
1552
+ namespaces: {
1553
+ default: "src/i18n/translations/default.json",
1554
+ },
1555
+ delivery: "custom",
1556
+ deliveryArtifacts: {
1557
+ eu: ["it", "fr"],
1558
+ },
1559
+ localeFallback: {
1560
+ "en-US": null,
1561
+ it: "en-US",
1562
+ fr: null,
1563
+ },
1564
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
1565
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
1566
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
1567
+ paramsTypeName: "AppParams",
1568
+ schemaTypeName: "AppSchema",
1569
+ })
1570
+ );
1571
+
1572
+ const result = runCodegen(tempDir);
1573
+ expect(result.status).not.toBe(0);
1574
+ expect(result.stderr).toContain("missing locales");
1575
+ expect(result.stderr).toContain("en-US");
1576
+ });
1577
+
1578
+ it("splits yaml sources into per-area json without a canonical intermediate", () => {
1579
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
1580
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
1581
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
1582
+
1583
+ writeFileSync(
1584
+ join(tempDir, "src/i18n/translations/billing.yaml"),
1585
+ `invoice_summary:
1586
+ en-US: You have {count} invoices
1587
+ it: Hai {count} fatture
1588
+ `
1589
+ );
1590
+ writeFileSync(
1591
+ join(tempDir, "i18n.codegen.json"),
1592
+ JSON.stringify({
1593
+ namespaces: {
1594
+ billing: "src/i18n/translations/billing.yaml",
1595
+ },
1596
+ delivery: "custom",
1597
+ deliveryArtifacts: {
1598
+ eu: ["it"],
1599
+ us: ["en-US"],
1600
+ },
1601
+ localeFallback: {
1602
+ "en-US": null,
1603
+ it: "en-US",
1604
+ },
1605
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
1606
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
1607
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
1608
+ paramsTypeName: "AppParams",
1609
+ schemaTypeName: "AppSchema",
1610
+ })
1611
+ );
1612
+
1613
+ const result = runCodegen(tempDir);
1614
+ expect(result.status).toBe(0);
1615
+ expect(result.stdout).toContain("billing.eu.json");
1616
+ expect(result.stdout).toContain("billing.us.json");
1617
+
1618
+ expect(() =>
1619
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.json"), "utf8")
1620
+ ).toThrow();
1621
+
1622
+ const euSplit = JSON.parse(
1623
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.eu.json"), "utf8")
1624
+ ) as Record<string, Record<string, string>>;
1625
+ expect(euSplit.invoice_summary).toEqual({ it: "Hai {count} fatture" });
1626
+
1627
+ const usSplit = JSON.parse(
1628
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.us.json"), "utf8")
1629
+ ) as Record<string, Record<string, string>>;
1630
+ expect(usSplit.invoice_summary).toEqual({ "en-US": "You have {count} invoices" });
1631
+
1632
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
1633
+ const dictionary = readFileSync(
1634
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
1635
+ "utf8"
1636
+ );
1637
+ expect(types).toContain("export type AppDeliveryArea = 'eu' | 'us';");
1638
+ expect(types).toContain("invoice_summary: Partial<Record<AppLocale, string>>;");
1639
+ expect(types).not.toContain("typeof import");
1640
+ expect(dictionary).toContain("import billingEu from './translations/billing.eu.json';");
1641
+ expect(dictionary).toContain("import billingUs from './translations/billing.us.json';");
1642
+ expect(dictionary).toContain(
1643
+ "export function defaultDictionaryFor(area: AppDeliveryArea): AppSchema"
1644
+ );
1645
+ expect(dictionary).toContain("billing: billingByArea[area],");
1646
+ });
1647
+
1648
+ it("emits defaultDictionaryFor in single mode with custom delivery", () => {
1649
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
1650
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
1651
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
1652
+
1653
+ const sourcePath = join(tempDir, "src/i18n/translations/translations.json");
1654
+ writeFileSync(
1655
+ sourcePath,
1656
+ JSON.stringify({
1657
+ some_key: { it: "Ciao", "en-US": "Hello" },
1658
+ })
1659
+ );
1660
+ writeFileSync(
1661
+ join(tempDir, "i18n.codegen.json"),
1662
+ JSON.stringify({
1663
+ dictionary: "src/i18n/translations/translations.json",
1664
+ delivery: "custom",
1665
+ deliveryArtifacts: {
1666
+ eu: ["it"],
1667
+ us: ["en-US"],
1668
+ },
1669
+ localeFallback: {
1670
+ "en-US": null,
1671
+ it: "en-US",
1672
+ },
1673
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
1674
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
1675
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
1676
+ paramsTypeName: "AppParams",
1677
+ schemaTypeName: "AppSchema",
1678
+ })
1679
+ );
1680
+
1681
+ const result = runCodegen(tempDir);
1682
+ expect(result.status).toBe(0);
1683
+
1684
+ const sourceAfter = JSON.parse(readFileSync(sourcePath, "utf8")) as Record<
1685
+ string,
1686
+ Record<string, string>
1687
+ >;
1688
+ expect(sourceAfter.some_key).toEqual({
1689
+ it: "Ciao",
1690
+ "en-US": "Hello",
1691
+ });
1692
+
1693
+ const dictionary = readFileSync(
1694
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
1695
+ "utf8"
1696
+ );
1697
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
1698
+ expect(types).toContain("some_key: Partial<Record<AppLocale, string>>;");
1699
+ expect(types).not.toContain("typeof import");
1700
+ expect(dictionary).toContain("import defaultEu from './translations/translations.eu.json';");
1701
+ expect(dictionary).toContain("import defaultUs from './translations/translations.us.json';");
1702
+ expect(dictionary).toContain(
1703
+ "export function defaultDictionaryFor(area: AppDeliveryArea): AppSchema"
1704
+ );
1705
+ expect(dictionary).toContain("return defaultByArea[area];");
1706
+ expect(dictionary).not.toContain("export const defaultDictionary");
969
1707
  });
970
1708
 
971
1709
  it("fails when yaml dictionary has invalid shape", () => {