@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.
- package/README.md +58 -8
- package/bin/audit.mjs +14 -0
- package/package.json +5 -3
- package/src/IcuTranslationProviderSingle.test.ts +54 -0
- package/src/audit/audit-dictionaries.test.ts +134 -0
- package/src/audit/audit-dictionaries.ts +172 -0
- package/src/audit/run-audit.test.ts +136 -0
- package/src/audit/run-audit.ts +81 -0
- package/src/codegen/emit/dictionary-file.ts +7 -3
- package/src/codegen/emit/dictionary-schema-file.ts +6 -4
- package/src/codegen/emit/instance-file.ts +10 -5
- package/src/codegen/emit/namespace-loaders-file.ts +10 -6
- package/src/codegen/generate-i18n-types.test.ts +374 -0
- package/src/codegen/generate-i18n-types.ts +33 -5
- package/src/codegen/icu-analysis.ts +3 -2
- package/src/codegen/locale-policy.test.ts +35 -0
- package/src/codegen/locale-policy.ts +26 -0
- package/src/codegen/paths.ts +27 -1
- package/src/codegen/read-dictionary.test.ts +104 -0
- package/src/codegen/read-dictionary.ts +133 -0
- package/src/codegen/types.ts +5 -0
- package/src/setup/setup-i18n.ts +7 -4
|
@@ -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 {
|
|
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
|
|
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:
|
|
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 {
|
|
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 =
|
|
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
|
+
}
|
package/src/codegen/paths.ts
CHANGED
|
@@ -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)) {
|