@xndrjs/i18n 0.2.0 → 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/dist/index.d.ts +1 -1
- package/dist/{types-CFWVT2PP.d.ts → types-C1CpXVOJ.d.ts} +7 -1
- package/dist/validation/index.d.ts +3 -2
- package/dist/validation/index.js +125 -28
- package/dist/validation/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 +10 -5
- package/src/codegen/emit/namespace-loaders-file.ts +10 -6
- package/src/codegen/generate-i18n-types.test.ts +496 -0
- package/src/codegen/generate-i18n-types.ts +33 -5
- package/src/codegen/icu-analysis.ts +22 -7
- 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/ensure-namespace.test.ts +1 -1
- package/src/icu/extract-variables.test.ts +199 -0
- package/src/icu/extract-variables.ts +160 -20
- package/src/icu/parse-template.ts +9 -2
- package/src/setup/setup-i18n.ts +7 -4
- package/src/validation/normalize.ts +17 -10
- package/src/validation/validation.test.ts +104 -0
|
@@ -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();
|
|
@@ -2,11 +2,12 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse } from "@formatjs/icu-messageformat-parser";
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
extractVariableMeta,
|
|
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,12 +48,12 @@ 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
|
|
|
54
55
|
for (const [key, localesByKey] of Object.entries(dictionary)) {
|
|
55
|
-
const
|
|
56
|
+
const localeMetas: ReturnType<typeof extractVariableMeta>[] = [];
|
|
56
57
|
|
|
57
58
|
for (const locale of Object.keys(localesByKey)) {
|
|
58
59
|
locales.add(locale);
|
|
@@ -61,8 +62,7 @@ export function analyzeDictionaries(
|
|
|
61
62
|
for (const [locale, template] of Object.entries(localesByKey)) {
|
|
62
63
|
try {
|
|
63
64
|
const ast = parse(template);
|
|
64
|
-
|
|
65
|
-
Object.assign(variables, mergeVariableSpecs(variables, extracted));
|
|
65
|
+
localeMetas.push(extractVariableMeta(ast));
|
|
66
66
|
} catch (error) {
|
|
67
67
|
hasErrors = true;
|
|
68
68
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -72,6 +72,21 @@ export function analyzeDictionaries(
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
if (localeMetas.length === 0) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const mergedVariables = mergeVariableMetaAcrossLocales(localeMetas);
|
|
80
|
+
if (!mergedVariables.ok) {
|
|
81
|
+
hasErrors = true;
|
|
82
|
+
console.error(
|
|
83
|
+
`[Codegen Error] ${mergedVariables.message} — namespace "${entry.namespace}", key "${key}"`
|
|
84
|
+
);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const variables = mergedVariables.merged;
|
|
89
|
+
|
|
75
90
|
paramsByNamespace[entry.namespace]![key] = paramsTypeForVariables(variables);
|
|
76
91
|
argsSpecByNamespace[entry.namespace]![key] = variables;
|
|
77
92
|
}
|
|
@@ -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)) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it, afterEach } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
getDictionaryFormat,
|
|
7
|
+
prepareDictionaryEntries,
|
|
8
|
+
readDictionaryFile,
|
|
9
|
+
resolveCompiledJsonPath,
|
|
10
|
+
} from "./read-dictionary.js";
|
|
11
|
+
|
|
12
|
+
describe("read-dictionary", () => {
|
|
13
|
+
let tempDir: string;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
if (tempDir) {
|
|
17
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("detects dictionary formats from file extension", () => {
|
|
22
|
+
expect(getDictionaryFormat("translations/default.json")).toBe("json");
|
|
23
|
+
expect(getDictionaryFormat("translations/default.yaml")).toBe("yaml");
|
|
24
|
+
expect(getDictionaryFormat("translations/default.yml")).toBe("yaml");
|
|
25
|
+
expect(getDictionaryFormat("translations/default.toml")).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("resolves compiled json path under the generated output directory", () => {
|
|
29
|
+
expect(
|
|
30
|
+
resolveCompiledJsonPath("src/i18n/translations/billing.yaml", "src/i18n/generated")
|
|
31
|
+
).toBe("src/i18n/generated/translations/billing.json");
|
|
32
|
+
expect(resolveCompiledJsonPath("translations/billing.yml", "generated")).toBe(
|
|
33
|
+
"generated/translations/billing.json"
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reads multiline yaml dictionaries", () => {
|
|
38
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-read-dict-"));
|
|
39
|
+
const yamlPath = join(tempDir, "welcome.yaml");
|
|
40
|
+
writeFileSync(
|
|
41
|
+
yamlPath,
|
|
42
|
+
`welcome:
|
|
43
|
+
en: |
|
|
44
|
+
Line one
|
|
45
|
+
Line two
|
|
46
|
+
it: Ciao {name}
|
|
47
|
+
`
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const dictionary = readDictionaryFile(yamlPath);
|
|
51
|
+
expect(dictionary.welcome?.en).toBe("Line one\nLine two\n");
|
|
52
|
+
expect(dictionary.welcome?.it).toBe("Ciao {name}");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("compiles yaml sources to json under the generated output directory", () => {
|
|
56
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-read-dict-"));
|
|
57
|
+
mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
|
|
58
|
+
mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
|
|
59
|
+
writeFileSync(
|
|
60
|
+
join(tempDir, "src/i18n/translations/billing.yaml"),
|
|
61
|
+
`invoice_summary:
|
|
62
|
+
en: You have {count} invoices
|
|
63
|
+
`
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const result = prepareDictionaryEntries(
|
|
67
|
+
tempDir,
|
|
68
|
+
[{ namespace: "billing", filePath: "src/i18n/translations/billing.yaml" }],
|
|
69
|
+
"src/i18n/generated"
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(result.resolvedEntries).toEqual([
|
|
73
|
+
{ namespace: "billing", filePath: "src/i18n/generated/translations/billing.json" },
|
|
74
|
+
]);
|
|
75
|
+
expect(result.compiledFiles).toEqual([
|
|
76
|
+
"src/i18n/translations/billing.yaml → src/i18n/generated/translations/billing.json",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const compiled = JSON.parse(
|
|
80
|
+
readFileSync(join(tempDir, "src/i18n/generated/translations/billing.json"), "utf8")
|
|
81
|
+
) as Record<string, Record<string, string>>;
|
|
82
|
+
expect(compiled.invoice_summary?.en).toBe("You have {count} invoices");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("keeps json sources unchanged", () => {
|
|
86
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-read-dict-"));
|
|
87
|
+
mkdirSync(join(tempDir, "translations"), { recursive: true });
|
|
88
|
+
writeFileSync(
|
|
89
|
+
join(tempDir, "translations/default.json"),
|
|
90
|
+
JSON.stringify({ welcome: { en: "Welcome {name}!" } })
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const result = prepareDictionaryEntries(
|
|
94
|
+
tempDir,
|
|
95
|
+
[{ namespace: "default", filePath: "translations/default.json" }],
|
|
96
|
+
"generated"
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(result.resolvedEntries).toEqual([
|
|
100
|
+
{ namespace: "default", filePath: "translations/default.json" },
|
|
101
|
+
]);
|
|
102
|
+
expect(result.compiledFiles).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import type { DictionaryJson, NamespaceEntry } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const SUPPORTED_DICTIONARY_EXTENSIONS = [".json", ".yaml", ".yml"] as const;
|
|
7
|
+
export type DictionaryFormat = "json" | "yaml";
|
|
8
|
+
|
|
9
|
+
export function getDictionaryFormat(filePath: string): DictionaryFormat | null {
|
|
10
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
11
|
+
if (extension === ".json") {
|
|
12
|
+
return "json";
|
|
13
|
+
}
|
|
14
|
+
if (extension === ".yaml" || extension === ".yml") {
|
|
15
|
+
return "yaml";
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveCompiledJsonPath(sourcePath: string, generatedDirRelative: string): string {
|
|
21
|
+
const baseName = `${path.basename(sourcePath, path.extname(sourcePath))}.json`;
|
|
22
|
+
return path.join(generatedDirRelative, "translations", baseName).replace(/\\/g, "/");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assertDictionaryShape(value: unknown, context: string): asserts value is DictionaryJson {
|
|
26
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
27
|
+
throw new Error(`[Codegen Error] ${context} must be a plain object.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const [key, localesByKey] of Object.entries(value)) {
|
|
31
|
+
if (localesByKey === null || typeof localesByKey !== "object" || Array.isArray(localesByKey)) {
|
|
32
|
+
throw new Error(`[Codegen Error] ${context}: key "${key}" must map locales to strings.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const [locale, template] of Object.entries(localesByKey)) {
|
|
36
|
+
if (typeof template !== "string") {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`[Codegen Error] ${context}: key "${key}", locale "${locale}" must be a string.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readDictionaryFile(absolutePath: string): DictionaryJson {
|
|
46
|
+
const format = getDictionaryFormat(absolutePath);
|
|
47
|
+
if (!format) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[Codegen Error] Unsupported dictionary format "${path.extname(absolutePath)}" for ${absolutePath}. Use .json, .yaml, or .yml.`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const source = fs.readFileSync(absolutePath, "utf8");
|
|
54
|
+
let parsed: unknown;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
parsed = format === "json" ? JSON.parse(source) : parseYaml(source);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
throw new Error(`[Codegen Error] Failed to parse dictionary ${absolutePath}: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
assertDictionaryShape(parsed, `Dictionary file ${absolutePath}`);
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeCompiledJson(absoluteJsonPath: string, dictionary: DictionaryJson): boolean {
|
|
68
|
+
const nextContent = `${JSON.stringify(dictionary, null, 2)}\n`;
|
|
69
|
+
if (fs.existsSync(absoluteJsonPath)) {
|
|
70
|
+
const currentContent = fs.readFileSync(absoluteJsonPath, "utf8");
|
|
71
|
+
if (currentContent === nextContent) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fs.mkdirSync(path.dirname(absoluteJsonPath), { recursive: true });
|
|
77
|
+
fs.writeFileSync(absoluteJsonPath, nextContent);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PrepareDictionariesResult {
|
|
82
|
+
resolvedEntries: NamespaceEntry[];
|
|
83
|
+
compiledFiles: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function prepareDictionaryEntries(
|
|
87
|
+
projectRoot: string,
|
|
88
|
+
entries: NamespaceEntry[],
|
|
89
|
+
generatedDirRelative: string
|
|
90
|
+
): PrepareDictionariesResult {
|
|
91
|
+
const resolvedEntries: NamespaceEntry[] = [];
|
|
92
|
+
const compiledFiles: string[] = [];
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const sourceAbsolutePath = path.resolve(projectRoot, entry.filePath);
|
|
96
|
+
const format = getDictionaryFormat(entry.filePath);
|
|
97
|
+
|
|
98
|
+
if (!format) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`[Codegen Error] Namespace "${entry.namespace}" uses unsupported dictionary extension in "${entry.filePath}". Use .json, .yaml, or .yml.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!fs.existsSync(sourceAbsolutePath)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`[Codegen Error] Dictionary file not found for namespace "${entry.namespace}": ${sourceAbsolutePath}`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const dictionary = readDictionaryFile(sourceAbsolutePath);
|
|
111
|
+
|
|
112
|
+
if (format === "yaml") {
|
|
113
|
+
const compiledRelativePath = resolveCompiledJsonPath(entry.filePath, generatedDirRelative);
|
|
114
|
+
const compiledAbsolutePath = path.resolve(projectRoot, compiledRelativePath);
|
|
115
|
+
const wroteFile = writeCompiledJson(compiledAbsolutePath, dictionary);
|
|
116
|
+
|
|
117
|
+
if (wroteFile) {
|
|
118
|
+
compiledFiles.push(
|
|
119
|
+
`${path.relative(projectRoot, sourceAbsolutePath)} → ${compiledRelativePath}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
resolvedEntries.push({
|
|
123
|
+
namespace: entry.namespace,
|
|
124
|
+
filePath: compiledRelativePath,
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
resolvedEntries.push(entry);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { resolvedEntries, compiledFiles };
|
|
133
|
+
}
|
package/src/codegen/types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export const SUPPORTED_IMPORT_EXTENSIONS = ["none", ".ts", ".js"] as const;
|
|
2
|
+
export type ImportExtension = (typeof SUPPORTED_IMPORT_EXTENSIONS)[number];
|
|
3
|
+
|
|
1
4
|
export interface CodegenConfig {
|
|
2
5
|
dictionary?: string;
|
|
3
6
|
namespaces?: Record<string, string>;
|
|
@@ -8,6 +11,8 @@ export interface CodegenConfig {
|
|
|
8
11
|
dictionarySchemaOutput?: string;
|
|
9
12
|
loadOnInit?: string[];
|
|
10
13
|
namespaceLoadersOutput?: string;
|
|
14
|
+
/** Extension used in relative imports between generated modules. Default: "none" */
|
|
15
|
+
importExtension?: ImportExtension;
|
|
11
16
|
paramsTypeName: string;
|
|
12
17
|
schemaTypeName: string;
|
|
13
18
|
localeTypeName?: string;
|
|
@@ -89,7 +89,7 @@ describe("ensureNamespacesLoadedImpl", () => {
|
|
|
89
89
|
const options = {
|
|
90
90
|
provider,
|
|
91
91
|
resolveLoader: () => load,
|
|
92
|
-
validate: () => ({ ok: true, data: billingNs }),
|
|
92
|
+
validate: () => ({ ok: true, data: billingNs }) as const,
|
|
93
93
|
};
|
|
94
94
|
|
|
95
95
|
const first = ensureNamespacesLoadedImpl(options, ["billing"]);
|