@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.
- package/README.md +80 -11
- package/bin/audit.mjs +14 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- 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 +26 -8
- package/src/codegen/emit/namespace-loaders-file.ts +10 -6
- package/src/codegen/generate-i18n-types.test.ts +380 -0
- package/src/codegen/generate-i18n-types.ts +34 -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/index.ts +2 -0
- package/src/project-locales.test.ts +67 -0
- package/src/project-locales.ts +33 -0
- package/src/setup/setup-i18n.ts +7 -4
|
@@ -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 '
|
|
24
|
-
`import type { ${schemaTypeName}, LazyNamespace } from '
|
|
25
|
-
`import type { ${factoryName} } from '
|
|
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 {
|
|
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,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 {
|
|
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)) {
|