@xndrjs/i18n 0.2.1 → 0.3.1

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.
@@ -1,5 +1,5 @@
1
- import { GENERATED_FILE_BANNER, toImportPath } from "../paths.js";
2
- import type { NamespaceEntry } from "../types.js";
1
+ import { GENERATED_FILE_BANNER, toImportPath, toRelativeModuleImport } from "../paths.js";
2
+ import type { ImportExtension, NamespaceEntry } from "../types.js";
3
3
 
4
4
  export function formatNamespaceLoadersFile(
5
5
  loadersOutputPath: string,
@@ -8,8 +8,12 @@ export function formatNamespaceLoadersFile(
8
8
  typesModule: string,
9
9
  dictionarySchemaModule: string,
10
10
  instanceModule: string,
11
- factoryName: string
11
+ factoryName: string,
12
+ importExtension: ImportExtension
12
13
  ): string {
14
+ const dictionarySchemaImport = toRelativeModuleImport(dictionarySchemaModule, importExtension);
15
+ const typesImport = toRelativeModuleImport(typesModule, importExtension);
16
+ const instanceImport = toRelativeModuleImport(instanceModule, importExtension);
13
17
  const loaderEntries = lazyEntries
14
18
  .map((entry) => {
15
19
  const importPath = toImportPath(loadersOutputPath, entry.absolutePath);
@@ -20,9 +24,9 @@ export function formatNamespaceLoadersFile(
20
24
  return (
21
25
  `${GENERATED_FILE_BANNER}` +
22
26
  `import { ensureNamespacesLoadedImpl } from '@xndrjs/i18n';\n` +
23
- `import { validateExternalNamespace } from './${dictionarySchemaModule}.js';\n` +
24
- `import type { ${schemaTypeName}, LazyNamespace } from './${typesModule}.js';\n` +
25
- `import type { ${factoryName} } from './${instanceModule}.js';\n\n` +
27
+ `import { validateExternalNamespace } from '${dictionarySchemaImport}';\n` +
28
+ `import type { ${schemaTypeName}, LazyNamespace } from '${typesImport}';\n` +
29
+ `import type { ${factoryName} } from '${instanceImport}';\n\n` +
26
30
  `export const namespaceLoaders: Record<\n` +
27
31
  ` LazyNamespace,\n` +
28
32
  ` () => Promise<${schemaTypeName}[LazyNamespace]>\n` +
@@ -73,6 +73,9 @@ 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("locales: readonly AppLocale[]");
78
+ expect(factory).toContain("projectLocalesCore(dictionary, locales)");
76
79
  expect(factory).toContain("IcuTranslationProviderMulti");
77
80
  expect(types).toContain("login_button: never");
78
81
  expect(types).toContain("welcome: { name: string }");
@@ -289,11 +292,81 @@ describe("generate-i18n-types", () => {
289
292
  expect(types).toContain('"de-CH": "en"');
290
293
  expect(types).toContain("export type AppLocale = 'de-CH' | 'en'");
291
294
  expect(factory).toContain("localeFallback: LOCALE_FALLBACK");
295
+ expect(factory).toContain("projectLocalesCore(dictionary, locales, LOCALE_FALLBACK)");
292
296
  expect(factory).toContain(
293
297
  "IcuTranslationProviderSingle<AppSchema, AppParams, AppLocale, typeof LOCALE_FALLBACK>"
294
298
  );
295
299
  });
296
300
 
301
+ it("enriches LOCALE_FALLBACK with null for dictionary locales missing from config", () => {
302
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
303
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
304
+
305
+ writeFileSync(
306
+ join(tempDir, "src/i18n/translations/translations.json"),
307
+ JSON.stringify({
308
+ login_button: { en: "Login", fr: "Connexion" },
309
+ })
310
+ );
311
+ writeFileSync(
312
+ join(tempDir, "i18n.codegen.json"),
313
+ JSON.stringify({
314
+ dictionary: "src/i18n/translations/translations.json",
315
+ typesOutput: "src/i18n/i18n-types.generated.ts",
316
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
317
+ instanceOutput: "src/i18n/instance.generated.ts",
318
+ paramsTypeName: "AppParams",
319
+ schemaTypeName: "AppSchema",
320
+ localeFallback: {
321
+ en: null,
322
+ it: "en",
323
+ },
324
+ })
325
+ );
326
+
327
+ const result = runCodegen(tempDir);
328
+ expect(result.status).toBe(0);
329
+
330
+ const types = readFileSync(join(tempDir, "src/i18n/i18n-types.generated.ts"), "utf8");
331
+ expect(types).toContain('"fr": null');
332
+ expect(types).toContain('"it": "en"');
333
+ expect(types).toContain("export type AppLocale = 'en' | 'fr' | 'it'");
334
+ });
335
+
336
+ it("does not emit LOCALE_FALLBACK when localeFallback is absent from config", () => {
337
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
338
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
339
+
340
+ writeFileSync(
341
+ join(tempDir, "src/i18n/translations/translations.json"),
342
+ JSON.stringify({
343
+ login_button: { en: "Login", it: "Accedi" },
344
+ })
345
+ );
346
+ writeFileSync(
347
+ join(tempDir, "i18n.codegen.json"),
348
+ JSON.stringify({
349
+ dictionary: "src/i18n/translations/translations.json",
350
+ typesOutput: "src/i18n/i18n-types.generated.ts",
351
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
352
+ instanceOutput: "src/i18n/instance.generated.ts",
353
+ paramsTypeName: "AppParams",
354
+ schemaTypeName: "AppSchema",
355
+ })
356
+ );
357
+
358
+ const result = runCodegen(tempDir);
359
+ expect(result.status).toBe(0);
360
+
361
+ const types = readFileSync(join(tempDir, "src/i18n/i18n-types.generated.ts"), "utf8");
362
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
363
+ expect(types).not.toContain("export const LOCALE_FALLBACK");
364
+ expect(factory).not.toContain("localeFallback:");
365
+ expect(factory).toContain("projectLocalesCore(dictionary, locales)");
366
+ expect(factory).not.toContain("LOCALE_FALLBACK");
367
+ expect(types).toContain("export type AppLocale = 'en' | 'it'");
368
+ });
369
+
297
370
  it("fails on circular locale fallback in config", () => {
298
371
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
299
372
  mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
@@ -601,4 +674,311 @@ describe("generate-i18n-types", () => {
601
674
  expect(types).toContain("default: {");
602
675
  expect(factory).toContain("IcuTranslationProviderMulti");
603
676
  });
677
+
678
+ it("omits import extension by default between generated modules", () => {
679
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
680
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
681
+
682
+ writeFileSync(
683
+ join(tempDir, "src/i18n/translations/translations.json"),
684
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
685
+ );
686
+ writeFileSync(
687
+ join(tempDir, "i18n.codegen.json"),
688
+ JSON.stringify({
689
+ dictionary: "src/i18n/translations/translations.json",
690
+ typesOutput: "src/i18n/i18n-types.generated.ts",
691
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
692
+ instanceOutput: "src/i18n/instance.generated.ts",
693
+ paramsTypeName: "AppParams",
694
+ schemaTypeName: "AppSchema",
695
+ })
696
+ );
697
+
698
+ const result = runCodegen(tempDir);
699
+ expect(result.status).toBe(0);
700
+
701
+ const dictionary = readFileSync(join(tempDir, "src/i18n/dictionary.generated.ts"), "utf8");
702
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
703
+ expect(dictionary).toContain("from './i18n-types.generated'");
704
+ expect(factory).toContain("from './dictionary.generated'");
705
+ expect(factory).toContain("from './i18n-types.generated'");
706
+ expect(dictionary).not.toContain(".generated.js");
707
+ expect(dictionary).not.toContain(".generated.ts");
708
+ expect(factory).not.toContain(".generated.js");
709
+ expect(factory).not.toContain(".generated.ts");
710
+ });
711
+
712
+ it("supports importExtension .ts", () => {
713
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
714
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
715
+
716
+ writeFileSync(
717
+ join(tempDir, "src/i18n/translations/translations.json"),
718
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
719
+ );
720
+ writeFileSync(
721
+ join(tempDir, "i18n.codegen.json"),
722
+ JSON.stringify({
723
+ dictionary: "src/i18n/translations/translations.json",
724
+ typesOutput: "src/i18n/i18n-types.generated.ts",
725
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
726
+ instanceOutput: "src/i18n/instance.generated.ts",
727
+ importExtension: ".ts",
728
+ paramsTypeName: "AppParams",
729
+ schemaTypeName: "AppSchema",
730
+ })
731
+ );
732
+
733
+ const result = runCodegen(tempDir);
734
+ expect(result.status).toBe(0);
735
+
736
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
737
+ expect(factory).toContain("from './dictionary.generated.ts'");
738
+ expect(factory).toContain("from './i18n-types.generated.ts'");
739
+ });
740
+
741
+ it("supports importExtension .js for NodeNext ESM projects", () => {
742
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
743
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
744
+
745
+ writeFileSync(
746
+ join(tempDir, "src/i18n/translations/translations.json"),
747
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
748
+ );
749
+ writeFileSync(
750
+ join(tempDir, "i18n.codegen.json"),
751
+ JSON.stringify({
752
+ dictionary: "src/i18n/translations/translations.json",
753
+ typesOutput: "src/i18n/i18n-types.generated.ts",
754
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
755
+ instanceOutput: "src/i18n/instance.generated.ts",
756
+ importExtension: ".js",
757
+ paramsTypeName: "AppParams",
758
+ schemaTypeName: "AppSchema",
759
+ })
760
+ );
761
+
762
+ const result = runCodegen(tempDir);
763
+ expect(result.status).toBe(0);
764
+
765
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
766
+ expect(factory).toContain("from './dictionary.generated.js'");
767
+ expect(factory).toContain("from './i18n-types.generated.js'");
768
+ });
769
+
770
+ it("fails when importExtension is invalid", () => {
771
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
772
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
773
+
774
+ writeFileSync(
775
+ join(tempDir, "src/i18n/translations/translations.json"),
776
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
777
+ );
778
+ writeFileSync(
779
+ join(tempDir, "i18n.codegen.json"),
780
+ JSON.stringify({
781
+ dictionary: "src/i18n/translations/translations.json",
782
+ typesOutput: "src/i18n/i18n-types.generated.ts",
783
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
784
+ instanceOutput: "src/i18n/instance.generated.ts",
785
+ importExtension: ".mjs",
786
+ paramsTypeName: "AppParams",
787
+ schemaTypeName: "AppSchema",
788
+ })
789
+ );
790
+
791
+ const result = runCodegen(tempDir);
792
+ expect(result.status).not.toBe(0);
793
+ expect(result.stderr).toContain('importExtension must be "none", ".ts", or ".js"');
794
+ });
795
+
796
+ it("compiles yaml dictionaries to json and generates json imports", () => {
797
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
798
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
799
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
800
+
801
+ writeFileSync(
802
+ join(tempDir, "src/i18n/translations/translations.yaml"),
803
+ `welcome:
804
+ en: |
805
+ Line one
806
+ Line two
807
+ it: |
808
+ Riga uno
809
+ Riga due
810
+ `
811
+ );
812
+ writeFileSync(
813
+ join(tempDir, "i18n.codegen.json"),
814
+ JSON.stringify({
815
+ dictionary: "src/i18n/translations/translations.yaml",
816
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
817
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
818
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
819
+ paramsTypeName: "AppParams",
820
+ schemaTypeName: "AppSchema",
821
+ })
822
+ );
823
+
824
+ const result = runCodegen(tempDir);
825
+ expect(result.status).toBe(0);
826
+ expect(result.stdout).toContain(
827
+ "Compiled: src/i18n/translations/translations.yaml → src/i18n/generated/translations/translations.json"
828
+ );
829
+
830
+ const compiled = JSON.parse(
831
+ readFileSync(join(tempDir, "src/i18n/generated/translations/translations.json"), "utf8")
832
+ ) as Record<string, Record<string, string>>;
833
+ expect(compiled.welcome?.en).toBe("Line one\nLine two\n");
834
+
835
+ const dictionary = readFileSync(
836
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
837
+ "utf8"
838
+ );
839
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
840
+ expect(dictionary).toContain("from './translations/translations.json'");
841
+ expect(types).toContain("typeof import('./translations/translations.json')");
842
+ });
843
+
844
+ it("supports mixed json and yaml namespaces", () => {
845
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
846
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
847
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
848
+
849
+ writeFileSync(
850
+ join(tempDir, "src/i18n/translations/default.json"),
851
+ JSON.stringify({
852
+ login_button: { en: "Login", it: "Accedi" },
853
+ })
854
+ );
855
+ writeFileSync(
856
+ join(tempDir, "src/i18n/translations/billing.yaml"),
857
+ `invoice_summary:
858
+ en: You have {count} invoices
859
+ `
860
+ );
861
+ writeFileSync(
862
+ join(tempDir, "i18n.codegen.json"),
863
+ JSON.stringify({
864
+ namespaces: {
865
+ default: "src/i18n/translations/default.json",
866
+ billing: "src/i18n/translations/billing.yaml",
867
+ },
868
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
869
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
870
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
871
+ paramsTypeName: "AppParams",
872
+ schemaTypeName: "AppSchema",
873
+ })
874
+ );
875
+
876
+ const result = runCodegen(tempDir);
877
+ expect(result.status).toBe(0);
878
+
879
+ const dictionary = readFileSync(
880
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
881
+ "utf8"
882
+ );
883
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
884
+ expect(dictionary).toContain("from '../translations/default.json'");
885
+ expect(dictionary).toContain("from './translations/billing.json'");
886
+ expect(types).toContain("default: typeof import('../translations/default.json')");
887
+ expect(types).toContain("billing: typeof import('./translations/billing.json')");
888
+ });
889
+
890
+ it("generates lazy loaders that import compiled json for yaml namespaces", () => {
891
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
892
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
893
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
894
+
895
+ writeFileSync(
896
+ join(tempDir, "src/i18n/translations/default.json"),
897
+ JSON.stringify({
898
+ login_button: { en: "Login" },
899
+ })
900
+ );
901
+ writeFileSync(
902
+ join(tempDir, "src/i18n/translations/billing.yaml"),
903
+ `invoice_summary:
904
+ en: You have {count} invoices
905
+ `
906
+ );
907
+ writeFileSync(
908
+ join(tempDir, "i18n.codegen.json"),
909
+ JSON.stringify({
910
+ namespaces: {
911
+ default: "src/i18n/translations/default.json",
912
+ billing: "src/i18n/translations/billing.yaml",
913
+ },
914
+ loadOnInit: ["default"],
915
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
916
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
917
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
918
+ dictionarySchemaOutput: "src/i18n/generated/dictionary-schema.generated.ts",
919
+ namespaceLoadersOutput: "src/i18n/generated/namespace-loaders.generated.ts",
920
+ paramsTypeName: "AppParams",
921
+ schemaTypeName: "AppSchema",
922
+ })
923
+ );
924
+
925
+ const result = runCodegen(tempDir);
926
+ expect(result.status).toBe(0);
927
+
928
+ const loaders = readFileSync(
929
+ join(tempDir, "src/i18n/generated/namespace-loaders.generated.ts"),
930
+ "utf8"
931
+ );
932
+ expect(loaders).toContain("import('./translations/billing.json')");
933
+ expect(loaders).not.toContain("billing.yaml");
934
+ });
935
+
936
+ it("fails when dictionary extension is unsupported", () => {
937
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
938
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
939
+
940
+ writeFileSync(join(tempDir, "src/i18n/translations/translations.toml"), "welcome = {}");
941
+ writeFileSync(
942
+ join(tempDir, "i18n.codegen.json"),
943
+ JSON.stringify({
944
+ dictionary: "src/i18n/translations/translations.toml",
945
+ typesOutput: "src/i18n/i18n-types.generated.ts",
946
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
947
+ instanceOutput: "src/i18n/instance.generated.ts",
948
+ paramsTypeName: "AppParams",
949
+ schemaTypeName: "AppSchema",
950
+ })
951
+ );
952
+
953
+ const result = runCodegen(tempDir);
954
+ expect(result.status).not.toBe(0);
955
+ expect(result.stderr).toContain("unsupported dictionary extension");
956
+ });
957
+
958
+ it("fails when yaml dictionary has invalid shape", () => {
959
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
960
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
961
+
962
+ writeFileSync(
963
+ join(tempDir, "src/i18n/translations/translations.yaml"),
964
+ `welcome:
965
+ en: 123
966
+ `
967
+ );
968
+ writeFileSync(
969
+ join(tempDir, "i18n.codegen.json"),
970
+ JSON.stringify({
971
+ dictionary: "src/i18n/translations/translations.yaml",
972
+ typesOutput: "src/i18n/i18n-types.generated.ts",
973
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
974
+ instanceOutput: "src/i18n/instance.generated.ts",
975
+ paramsTypeName: "AppParams",
976
+ schemaTypeName: "AppSchema",
977
+ })
978
+ );
979
+
980
+ const result = runCodegen(tempDir);
981
+ expect(result.status).not.toBe(0);
982
+ expect(result.stderr).toContain("must be a string");
983
+ });
604
984
  });
@@ -11,7 +11,9 @@ import { formatNamespaceLoadersFile } from "./emit/namespace-loaders-file.js";
11
11
  import { formatTypesFile } from "./emit/types-file.js";
12
12
  import { analyzeDictionaries } from "./icu-analysis.js";
13
13
  import { collectRequestLocales, validateCodegenLocaleFallback } from "./locale-fallback.js";
14
- import { fail, toModuleBasename } from "./paths.js";
14
+ import { enrichLocaleFallback } from "./locale-policy.js";
15
+ import { fail, resolveImportExtension, toModuleBasename } from "./paths.js";
16
+ import { prepareDictionaryEntries } from "./read-dictionary.js";
15
17
 
16
18
  function main() {
17
19
  const configArgIndex = process.argv.indexOf("--config");
@@ -26,7 +28,21 @@ function main() {
26
28
  }
27
29
 
28
30
  const config = loadConfig(configPath);
29
- const entries = resolveNamespaces(config);
31
+ const sourceEntries = resolveNamespaces(config);
32
+ const generatedDirRelative = path.dirname(config.typesOutput);
33
+ let resolvedEntries;
34
+ let compiledFiles: string[];
35
+
36
+ try {
37
+ const prepared = prepareDictionaryEntries(projectRoot, sourceEntries, generatedDirRelative);
38
+ resolvedEntries = prepared.resolvedEntries;
39
+ compiledFiles = prepared.compiledFiles;
40
+ } catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ fail(message);
43
+ }
44
+
45
+ const entries = resolvedEntries;
30
46
  const isSingle = Boolean(config.dictionary);
31
47
  const { loadOnInitSet, lazyEntries, hasLazy } = resolveLoadOnInit(config, entries, isSingle);
32
48
 
@@ -57,6 +73,7 @@ function main() {
57
73
  const localeFallbackConstName = config.localeFallbackConstName ?? "LOCALE_FALLBACK";
58
74
  const localeFallbackTypeName = `${localeTypeName}Fallback`;
59
75
  const factoryName = config.factoryName ?? "createI18n";
76
+ const importExtension = resolveImportExtension(config);
60
77
  const typesModule = toModuleBasename(typesOutputPath);
61
78
 
62
79
  const requestLocales = collectRequestLocales(locales, config.localeFallback);
@@ -69,6 +86,10 @@ function main() {
69
86
  ? entries.filter((entry) => loadOnInitSet.has(entry.namespace))
70
87
  : entries;
71
88
 
89
+ const localeFallbackForEmit = config.localeFallback
90
+ ? enrichLocaleFallback(locales, config.localeFallback)
91
+ : undefined;
92
+
72
93
  const typesContent = formatTypesFile({
73
94
  isSingle,
74
95
  entries,
@@ -79,7 +100,7 @@ function main() {
79
100
  localeTypeName,
80
101
  localeFallbackConstName,
81
102
  localeFallbackTypeName,
82
- localeFallback: config.localeFallback,
103
+ localeFallback: localeFallbackForEmit,
83
104
  paramsByNamespace,
84
105
  requestLocaleUnion,
85
106
  hasLazy,
@@ -96,6 +117,7 @@ function main() {
96
117
  dictionaryOutputPath,
97
118
  typesOutputPath,
98
119
  schemaTypeName,
120
+ importExtension,
99
121
  });
100
122
 
101
123
  const instanceContent = formatInstanceFile({
@@ -109,6 +131,8 @@ function main() {
109
131
  localeFallbackConstName,
110
132
  factoryName,
111
133
  hasLocaleFallback: Boolean(config.localeFallback),
134
+ hasLocaleType: Boolean(requestLocaleUnion),
135
+ importExtension,
112
136
  });
113
137
 
114
138
  fs.mkdirSync(path.dirname(typesOutputPath), { recursive: true });
@@ -133,7 +157,8 @@ function main() {
133
157
  schemaTypeName,
134
158
  typesModule,
135
159
  isSingle,
136
- dictionarySpecBlock
160
+ dictionarySpecBlock,
161
+ importExtension
137
162
  );
138
163
 
139
164
  fs.mkdirSync(path.dirname(dictionarySchemaOutputPath), { recursive: true });
@@ -159,7 +184,8 @@ function main() {
159
184
  typesModule,
160
185
  toModuleBasename(dictionarySchemaOutputPath),
161
186
  toModuleBasename(instanceOutputPath),
162
- factoryName
187
+ factoryName,
188
+ importExtension
163
189
  );
164
190
 
165
191
  fs.mkdirSync(path.dirname(namespaceLoadersOutputPath), { recursive: true });
@@ -168,6 +194,9 @@ function main() {
168
194
  }
169
195
 
170
196
  console.log(`✅ Generated: ${generatedFiles.join(", ")}`);
197
+ if (compiledFiles.length > 0) {
198
+ console.log(`✅ Compiled: ${compiledFiles.join(", ")}`);
199
+ }
171
200
  }
172
201
 
173
202
  main();
@@ -6,7 +6,8 @@ import {
6
6
  mergeVariableMetaAcrossLocales,
7
7
  type VariableSpec,
8
8
  } from "../icu/extract-variables.js";
9
- import type { DictionaryJson, NamespaceEntry } from "./types.js";
9
+ import type { NamespaceEntry } from "./types.js";
10
+ import { readDictionaryFile } from "./read-dictionary.js";
10
11
 
11
12
  export function paramsTypeForVariables(variables: VariableSpec): string {
12
13
  const keys = Object.keys(variables);
@@ -47,7 +48,7 @@ export function analyzeDictionaries(
47
48
  continue;
48
49
  }
49
50
 
50
- const dictionary = JSON.parse(fs.readFileSync(absolutePath, "utf8")) as DictionaryJson;
51
+ const dictionary = readDictionaryFile(absolutePath);
51
52
  paramsByNamespace[entry.namespace] = {};
52
53
  argsSpecByNamespace[entry.namespace] = {};
53
54
 
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildRequiredLocales, enrichLocaleFallback } from "./locale-policy.js";
3
+
4
+ describe("locale-policy", () => {
5
+ it("buildRequiredLocales unions dictionary locales with fallback keys and targets", () => {
6
+ const locales = new Set(["en", "it"]);
7
+ const fallback = { en: null, "de-CH": "de-DE", "de-DE": "en", it: "en" };
8
+
9
+ expect(buildRequiredLocales(locales, fallback)).toEqual(["de-CH", "de-DE", "en", "it"]);
10
+ expect(buildRequiredLocales(locales)).toEqual(["en", "it"]);
11
+ });
12
+
13
+ it("enrichLocaleFallback adds null for required locales missing from config", () => {
14
+ const locales = new Set(["en", "fr"]);
15
+ const fallback = { en: null, it: "en" };
16
+
17
+ expect(enrichLocaleFallback(locales, fallback)).toEqual({
18
+ en: null,
19
+ fr: null,
20
+ it: "en",
21
+ });
22
+ });
23
+
24
+ it("enrichLocaleFallback is a no-op when all required locales are already configured", () => {
25
+ const locales = new Set(["en", "it"]);
26
+ const fallback = {
27
+ en: null,
28
+ "de-DE": "en",
29
+ "de-CH": "de-DE",
30
+ it: "en",
31
+ };
32
+
33
+ expect(enrichLocaleFallback(locales, fallback)).toEqual(fallback);
34
+ });
35
+ });
@@ -0,0 +1,26 @@
1
+ import { collectRequestLocales } from "./locale-fallback.js";
2
+
3
+ export function buildRequiredLocales(
4
+ dictionaryLocales: Set<string>,
5
+ configFallback?: Record<string, string | null>
6
+ ): string[] {
7
+ return [...collectRequestLocales(dictionaryLocales, configFallback)].sort();
8
+ }
9
+
10
+ export function enrichLocaleFallback(
11
+ dictionaryLocales: Set<string>,
12
+ configFallback: Record<string, string | null>
13
+ ): Record<string, string | null> {
14
+ const enriched: Record<string, string | null> = { ...configFallback };
15
+
16
+ for (const locale of buildRequiredLocales(dictionaryLocales, configFallback)) {
17
+ if (!(locale in enriched)) {
18
+ enriched[locale] = null;
19
+ }
20
+ }
21
+
22
+ const sortedEntries = Object.entries(enriched).sort(([left], [right]) =>
23
+ left.localeCompare(right)
24
+ );
25
+ return Object.fromEntries(sortedEntries);
26
+ }
@@ -1,6 +1,9 @@
1
1
  import path from "node:path";
2
+ import type { CodegenConfig, ImportExtension } from "./types.js";
3
+ import { SUPPORTED_IMPORT_EXTENSIONS } from "./types.js";
2
4
 
3
5
  export const GENERATED_FILE_BANNER = "// Automatically generated code. Do not edit manually.\n";
6
+ export const DEFAULT_IMPORT_EXTENSION: ImportExtension = "none";
4
7
 
5
8
  export function fail(message: string): never {
6
9
  console.error(message);
@@ -10,7 +13,7 @@ export function fail(message: string): never {
10
13
 
11
14
  export function toImportPath(fromFile: string, toFile: string): string {
12
15
  const relative = path.relative(path.dirname(fromFile), toFile).replace(/\\/g, "/");
13
- const withoutExt = relative.replace(/\.json$/, "");
16
+ const withoutExt = relative.replace(/\.(json|ya?ml)$/i, "");
14
17
  return withoutExt.startsWith(".") ? withoutExt : `./${withoutExt}`;
15
18
  }
16
19
 
@@ -18,6 +21,29 @@ export function toModuleBasename(filePath: string): string {
18
21
  return path.basename(filePath).replace(/\.ts$/, "");
19
22
  }
20
23
 
24
+ export function resolveImportExtension(
25
+ config: Pick<CodegenConfig, "importExtension">
26
+ ): ImportExtension {
27
+ const extension = config.importExtension ?? DEFAULT_IMPORT_EXTENSION;
28
+ if (!SUPPORTED_IMPORT_EXTENSIONS.includes(extension)) {
29
+ fail(
30
+ `[Codegen Error] importExtension must be "none", ".ts", or ".js", got ${JSON.stringify(extension)}.`
31
+ );
32
+ }
33
+ return extension;
34
+ }
35
+
36
+ export function importExtensionSuffix(importExtension: ImportExtension): string {
37
+ return importExtension === "none" ? "" : importExtension;
38
+ }
39
+
40
+ export function toRelativeModuleImport(
41
+ moduleBasename: string,
42
+ importExtension: ImportExtension
43
+ ): string {
44
+ return `./${moduleBasename}${importExtensionSuffix(importExtension)}`;
45
+ }
46
+
21
47
  export function toImportIdentifier(namespace: string): string {
22
48
  const safe = namespace.replace(/[^a-zA-Z0-9_$]/g, "_");
23
49
  if (/^[0-9]/.test(safe)) {