@xndrjs/i18n 0.2.1 → 0.3.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.
@@ -294,6 +294,73 @@ describe("generate-i18n-types", () => {
294
294
  );
295
295
  });
296
296
 
297
+ it("enriches LOCALE_FALLBACK with null for dictionary locales missing from 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", fr: "Connexion" },
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
+ en: null,
318
+ it: "en",
319
+ },
320
+ })
321
+ );
322
+
323
+ const result = runCodegen(tempDir);
324
+ expect(result.status).toBe(0);
325
+
326
+ const types = readFileSync(join(tempDir, "src/i18n/i18n-types.generated.ts"), "utf8");
327
+ expect(types).toContain('"fr": null');
328
+ expect(types).toContain('"it": "en"');
329
+ expect(types).toContain("export type AppLocale = 'en' | 'fr' | 'it'");
330
+ });
331
+
332
+ it("does not emit LOCALE_FALLBACK when localeFallback is absent from config", () => {
333
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
334
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
335
+
336
+ writeFileSync(
337
+ join(tempDir, "src/i18n/translations/translations.json"),
338
+ JSON.stringify({
339
+ login_button: { en: "Login", it: "Accedi" },
340
+ })
341
+ );
342
+ writeFileSync(
343
+ join(tempDir, "i18n.codegen.json"),
344
+ JSON.stringify({
345
+ dictionary: "src/i18n/translations/translations.json",
346
+ typesOutput: "src/i18n/i18n-types.generated.ts",
347
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
348
+ instanceOutput: "src/i18n/instance.generated.ts",
349
+ paramsTypeName: "AppParams",
350
+ schemaTypeName: "AppSchema",
351
+ })
352
+ );
353
+
354
+ const result = runCodegen(tempDir);
355
+ expect(result.status).toBe(0);
356
+
357
+ const types = readFileSync(join(tempDir, "src/i18n/i18n-types.generated.ts"), "utf8");
358
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
359
+ expect(types).not.toContain("export const LOCALE_FALLBACK");
360
+ expect(factory).not.toContain("localeFallback:");
361
+ expect(types).toContain("export type AppLocale = 'en' | 'it'");
362
+ });
363
+
297
364
  it("fails on circular locale fallback in config", () => {
298
365
  tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
299
366
  mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
@@ -601,4 +668,311 @@ describe("generate-i18n-types", () => {
601
668
  expect(types).toContain("default: {");
602
669
  expect(factory).toContain("IcuTranslationProviderMulti");
603
670
  });
671
+
672
+ it("omits import extension by default between generated modules", () => {
673
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
674
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
675
+
676
+ writeFileSync(
677
+ join(tempDir, "src/i18n/translations/translations.json"),
678
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
679
+ );
680
+ writeFileSync(
681
+ join(tempDir, "i18n.codegen.json"),
682
+ JSON.stringify({
683
+ dictionary: "src/i18n/translations/translations.json",
684
+ typesOutput: "src/i18n/i18n-types.generated.ts",
685
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
686
+ instanceOutput: "src/i18n/instance.generated.ts",
687
+ paramsTypeName: "AppParams",
688
+ schemaTypeName: "AppSchema",
689
+ })
690
+ );
691
+
692
+ const result = runCodegen(tempDir);
693
+ expect(result.status).toBe(0);
694
+
695
+ const dictionary = readFileSync(join(tempDir, "src/i18n/dictionary.generated.ts"), "utf8");
696
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
697
+ expect(dictionary).toContain("from './i18n-types.generated'");
698
+ expect(factory).toContain("from './dictionary.generated'");
699
+ expect(factory).toContain("from './i18n-types.generated'");
700
+ expect(dictionary).not.toContain(".generated.js");
701
+ expect(dictionary).not.toContain(".generated.ts");
702
+ expect(factory).not.toContain(".generated.js");
703
+ expect(factory).not.toContain(".generated.ts");
704
+ });
705
+
706
+ it("supports importExtension .ts", () => {
707
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
708
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
709
+
710
+ writeFileSync(
711
+ join(tempDir, "src/i18n/translations/translations.json"),
712
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
713
+ );
714
+ writeFileSync(
715
+ join(tempDir, "i18n.codegen.json"),
716
+ JSON.stringify({
717
+ dictionary: "src/i18n/translations/translations.json",
718
+ typesOutput: "src/i18n/i18n-types.generated.ts",
719
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
720
+ instanceOutput: "src/i18n/instance.generated.ts",
721
+ importExtension: ".ts",
722
+ paramsTypeName: "AppParams",
723
+ schemaTypeName: "AppSchema",
724
+ })
725
+ );
726
+
727
+ const result = runCodegen(tempDir);
728
+ expect(result.status).toBe(0);
729
+
730
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
731
+ expect(factory).toContain("from './dictionary.generated.ts'");
732
+ expect(factory).toContain("from './i18n-types.generated.ts'");
733
+ });
734
+
735
+ it("supports importExtension .js for NodeNext ESM projects", () => {
736
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
737
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
738
+
739
+ writeFileSync(
740
+ join(tempDir, "src/i18n/translations/translations.json"),
741
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
742
+ );
743
+ writeFileSync(
744
+ join(tempDir, "i18n.codegen.json"),
745
+ JSON.stringify({
746
+ dictionary: "src/i18n/translations/translations.json",
747
+ typesOutput: "src/i18n/i18n-types.generated.ts",
748
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
749
+ instanceOutput: "src/i18n/instance.generated.ts",
750
+ importExtension: ".js",
751
+ paramsTypeName: "AppParams",
752
+ schemaTypeName: "AppSchema",
753
+ })
754
+ );
755
+
756
+ const result = runCodegen(tempDir);
757
+ expect(result.status).toBe(0);
758
+
759
+ const factory = readFileSync(join(tempDir, "src/i18n/instance.generated.ts"), "utf8");
760
+ expect(factory).toContain("from './dictionary.generated.js'");
761
+ expect(factory).toContain("from './i18n-types.generated.js'");
762
+ });
763
+
764
+ it("fails when importExtension is invalid", () => {
765
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
766
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
767
+
768
+ writeFileSync(
769
+ join(tempDir, "src/i18n/translations/translations.json"),
770
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
771
+ );
772
+ writeFileSync(
773
+ join(tempDir, "i18n.codegen.json"),
774
+ JSON.stringify({
775
+ dictionary: "src/i18n/translations/translations.json",
776
+ typesOutput: "src/i18n/i18n-types.generated.ts",
777
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
778
+ instanceOutput: "src/i18n/instance.generated.ts",
779
+ importExtension: ".mjs",
780
+ paramsTypeName: "AppParams",
781
+ schemaTypeName: "AppSchema",
782
+ })
783
+ );
784
+
785
+ const result = runCodegen(tempDir);
786
+ expect(result.status).not.toBe(0);
787
+ expect(result.stderr).toContain('importExtension must be "none", ".ts", or ".js"');
788
+ });
789
+
790
+ it("compiles yaml dictionaries to json and generates json imports", () => {
791
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
792
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
793
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
794
+
795
+ writeFileSync(
796
+ join(tempDir, "src/i18n/translations/translations.yaml"),
797
+ `welcome:
798
+ en: |
799
+ Line one
800
+ Line two
801
+ it: |
802
+ Riga uno
803
+ Riga due
804
+ `
805
+ );
806
+ writeFileSync(
807
+ join(tempDir, "i18n.codegen.json"),
808
+ JSON.stringify({
809
+ dictionary: "src/i18n/translations/translations.yaml",
810
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
811
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
812
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
813
+ paramsTypeName: "AppParams",
814
+ schemaTypeName: "AppSchema",
815
+ })
816
+ );
817
+
818
+ const result = runCodegen(tempDir);
819
+ expect(result.status).toBe(0);
820
+ expect(result.stdout).toContain(
821
+ "Compiled: src/i18n/translations/translations.yaml → src/i18n/generated/translations/translations.json"
822
+ );
823
+
824
+ const compiled = JSON.parse(
825
+ readFileSync(join(tempDir, "src/i18n/generated/translations/translations.json"), "utf8")
826
+ ) as Record<string, Record<string, string>>;
827
+ expect(compiled.welcome?.en).toBe("Line one\nLine two\n");
828
+
829
+ const dictionary = readFileSync(
830
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
831
+ "utf8"
832
+ );
833
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
834
+ expect(dictionary).toContain("from './translations/translations.json'");
835
+ expect(types).toContain("typeof import('./translations/translations.json')");
836
+ });
837
+
838
+ it("supports mixed json and yaml namespaces", () => {
839
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
840
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
841
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
842
+
843
+ writeFileSync(
844
+ join(tempDir, "src/i18n/translations/default.json"),
845
+ JSON.stringify({
846
+ login_button: { en: "Login", it: "Accedi" },
847
+ })
848
+ );
849
+ writeFileSync(
850
+ join(tempDir, "src/i18n/translations/billing.yaml"),
851
+ `invoice_summary:
852
+ en: You have {count} invoices
853
+ `
854
+ );
855
+ writeFileSync(
856
+ join(tempDir, "i18n.codegen.json"),
857
+ JSON.stringify({
858
+ namespaces: {
859
+ default: "src/i18n/translations/default.json",
860
+ billing: "src/i18n/translations/billing.yaml",
861
+ },
862
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
863
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
864
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
865
+ paramsTypeName: "AppParams",
866
+ schemaTypeName: "AppSchema",
867
+ })
868
+ );
869
+
870
+ const result = runCodegen(tempDir);
871
+ expect(result.status).toBe(0);
872
+
873
+ const dictionary = readFileSync(
874
+ join(tempDir, "src/i18n/generated/dictionary.generated.ts"),
875
+ "utf8"
876
+ );
877
+ const types = readFileSync(join(tempDir, "src/i18n/generated/i18n-types.generated.ts"), "utf8");
878
+ expect(dictionary).toContain("from '../translations/default.json'");
879
+ expect(dictionary).toContain("from './translations/billing.json'");
880
+ expect(types).toContain("default: typeof import('../translations/default.json')");
881
+ expect(types).toContain("billing: typeof import('./translations/billing.json')");
882
+ });
883
+
884
+ it("generates lazy loaders that import compiled json for yaml namespaces", () => {
885
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
886
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
887
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
888
+
889
+ writeFileSync(
890
+ join(tempDir, "src/i18n/translations/default.json"),
891
+ JSON.stringify({
892
+ login_button: { en: "Login" },
893
+ })
894
+ );
895
+ writeFileSync(
896
+ join(tempDir, "src/i18n/translations/billing.yaml"),
897
+ `invoice_summary:
898
+ en: You have {count} invoices
899
+ `
900
+ );
901
+ writeFileSync(
902
+ join(tempDir, "i18n.codegen.json"),
903
+ JSON.stringify({
904
+ namespaces: {
905
+ default: "src/i18n/translations/default.json",
906
+ billing: "src/i18n/translations/billing.yaml",
907
+ },
908
+ loadOnInit: ["default"],
909
+ typesOutput: "src/i18n/generated/i18n-types.generated.ts",
910
+ dictionaryOutput: "src/i18n/generated/dictionary.generated.ts",
911
+ instanceOutput: "src/i18n/generated/instance.generated.ts",
912
+ dictionarySchemaOutput: "src/i18n/generated/dictionary-schema.generated.ts",
913
+ namespaceLoadersOutput: "src/i18n/generated/namespace-loaders.generated.ts",
914
+ paramsTypeName: "AppParams",
915
+ schemaTypeName: "AppSchema",
916
+ })
917
+ );
918
+
919
+ const result = runCodegen(tempDir);
920
+ expect(result.status).toBe(0);
921
+
922
+ const loaders = readFileSync(
923
+ join(tempDir, "src/i18n/generated/namespace-loaders.generated.ts"),
924
+ "utf8"
925
+ );
926
+ expect(loaders).toContain("import('./translations/billing.json')");
927
+ expect(loaders).not.toContain("billing.yaml");
928
+ });
929
+
930
+ it("fails when dictionary extension is unsupported", () => {
931
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
932
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
933
+
934
+ writeFileSync(join(tempDir, "src/i18n/translations/translations.toml"), "welcome = {}");
935
+ writeFileSync(
936
+ join(tempDir, "i18n.codegen.json"),
937
+ JSON.stringify({
938
+ dictionary: "src/i18n/translations/translations.toml",
939
+ typesOutput: "src/i18n/i18n-types.generated.ts",
940
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
941
+ instanceOutput: "src/i18n/instance.generated.ts",
942
+ paramsTypeName: "AppParams",
943
+ schemaTypeName: "AppSchema",
944
+ })
945
+ );
946
+
947
+ const result = runCodegen(tempDir);
948
+ expect(result.status).not.toBe(0);
949
+ expect(result.stderr).toContain("unsupported dictionary extension");
950
+ });
951
+
952
+ it("fails when yaml dictionary has invalid shape", () => {
953
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-"));
954
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
955
+
956
+ writeFileSync(
957
+ join(tempDir, "src/i18n/translations/translations.yaml"),
958
+ `welcome:
959
+ en: 123
960
+ `
961
+ );
962
+ writeFileSync(
963
+ join(tempDir, "i18n.codegen.json"),
964
+ JSON.stringify({
965
+ dictionary: "src/i18n/translations/translations.yaml",
966
+ typesOutput: "src/i18n/i18n-types.generated.ts",
967
+ dictionaryOutput: "src/i18n/dictionary.generated.ts",
968
+ instanceOutput: "src/i18n/instance.generated.ts",
969
+ paramsTypeName: "AppParams",
970
+ schemaTypeName: "AppSchema",
971
+ })
972
+ );
973
+
974
+ const result = runCodegen(tempDir);
975
+ expect(result.status).not.toBe(0);
976
+ expect(result.stderr).toContain("must be a string");
977
+ });
604
978
  });
@@ -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,7 @@ function main() {
109
131
  localeFallbackConstName,
110
132
  factoryName,
111
133
  hasLocaleFallback: Boolean(config.localeFallback),
134
+ importExtension,
112
135
  });
113
136
 
114
137
  fs.mkdirSync(path.dirname(typesOutputPath), { recursive: true });
@@ -133,7 +156,8 @@ function main() {
133
156
  schemaTypeName,
134
157
  typesModule,
135
158
  isSingle,
136
- dictionarySpecBlock
159
+ dictionarySpecBlock,
160
+ importExtension
137
161
  );
138
162
 
139
163
  fs.mkdirSync(path.dirname(dictionarySchemaOutputPath), { recursive: true });
@@ -159,7 +183,8 @@ function main() {
159
183
  typesModule,
160
184
  toModuleBasename(dictionarySchemaOutputPath),
161
185
  toModuleBasename(instanceOutputPath),
162
- factoryName
186
+ factoryName,
187
+ importExtension
163
188
  );
164
189
 
165
190
  fs.mkdirSync(path.dirname(namespaceLoadersOutputPath), { recursive: true });
@@ -168,6 +193,9 @@ function main() {
168
193
  }
169
194
 
170
195
  console.log(`✅ Generated: ${generatedFiles.join(", ")}`);
196
+ if (compiledFiles.length > 0) {
197
+ console.log(`✅ Compiled: ${compiledFiles.join(", ")}`);
198
+ }
171
199
  }
172
200
 
173
201
  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)) {