@xndrjs/i18n 0.7.0 → 0.8.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 +67 -872
- package/dist/codegen/index.d.ts +117 -51
- package/dist/codegen/index.js +1585 -93
- package/dist/codegen/index.js.map +1 -1
- package/dist/index.d.ts +100 -234
- package/dist/index.js +74 -638
- package/dist/index.js.map +1 -1
- package/dist/validation/index.d.ts +5 -0
- package/dist/validation/index.js.map +1 -1
- package/package.json +2 -2
- package/src/IcuTranslationProviderMulti.test.ts +18 -40
- package/src/IcuTranslationProviderMulti.ts +27 -37
- package/src/audit/audit-dictionaries.ts +1 -1
- package/src/audit/run-audit.test.ts +6 -7
- package/src/builder-load-registry.ts +26 -5
- package/src/builder-loaders.ts +8 -11
- package/src/builder-types.test.ts +11 -42
- package/src/builder.test.ts +82 -243
- package/src/builder.ts +8 -110
- package/src/codegen/codegen-config-schema.ts +82 -77
- package/src/codegen/config.test.ts +55 -143
- package/src/codegen/config.ts +10 -67
- package/src/codegen/dictionary-spec-contract.test.ts +28 -0
- package/src/codegen/dictionary-spec-contract.ts +138 -0
- package/src/codegen/emit/dictionary-schema-file.ts +7 -43
- package/src/codegen/emit/instance-file.test.ts +35 -52
- package/src/codegen/emit/instance-file.ts +170 -268
- package/src/codegen/emit/namespace-loaders-file.test.ts +72 -74
- package/src/codegen/emit/namespace-loaders-file.ts +147 -90
- package/src/codegen/emit/types-file.test.ts +20 -41
- package/src/codegen/emit/types-file.ts +48 -74
- package/src/codegen/generate-i18n-types.test.ts +163 -492
- package/src/codegen/generate-i18n-types.ts +7 -269
- package/src/codegen/paths.ts +0 -14
- package/src/codegen/project-locales-set-namespace.test.ts +44 -86
- package/src/codegen/read-dictionary.test.ts +27 -9
- package/src/codegen/read-dictionary.ts +18 -40
- package/src/codegen/regenerate-namespaces.ts +180 -0
- package/src/codegen/run-codegen.test.ts +230 -0
- package/src/codegen/run-codegen.ts +252 -0
- package/src/codegen/types.ts +0 -6
- package/src/codegen-config/build-config.ts +4 -23
- package/src/codegen-config/codegen-config.test.ts +10 -6
- package/src/codegen-config/index.ts +18 -3
- package/src/engine.ts +3 -27
- package/src/fetch-artifact.ts +16 -0
- package/src/i18n-handle.ts +202 -0
- package/src/index.ts +15 -34
- package/src/project-locales.ts +2 -2
- package/src/scope-multi.ts +1 -20
- package/src/scope-types.ts +14 -6
- package/src/scope.test.ts +3 -312
- package/src/serialized-state.test.ts +114 -0
- package/src/serialized-state.ts +28 -0
- package/src/setup/setup-i18n.test.ts +22 -33
- package/src/setup/setup-i18n.ts +22 -27
- package/src/types.ts +3 -3
- package/src/validation/index.ts +4 -0
- package/src/IcuTranslationProviderSingle.test.ts +0 -177
- package/src/IcuTranslationProviderSingle.ts +0 -114
- package/src/builder-multi.ts +0 -481
- package/src/codegen/emit/dictionary-file.test.ts +0 -215
- package/src/codegen/emit/dictionary-file.ts +0 -244
- package/src/deep-freeze.test.ts +0 -40
- package/src/deep-freeze.ts +0 -22
- package/src/patch-key.test.ts +0 -186
- package/src/patch-key.ts +0 -140
- package/src/scope-single.ts +0 -85
- package/src/single-builder.ts +0 -153
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { CodegenConfig } from "./codegen-config-schema.js";
|
|
4
|
+
import {
|
|
5
|
+
loadConfig,
|
|
6
|
+
resolveArtifactsPath,
|
|
7
|
+
resolveCodegenPaths,
|
|
8
|
+
resolveNamespaces,
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
import { getDeliveryArtifactsIssues } from "./delivery-artifacts.js";
|
|
11
|
+
import {
|
|
12
|
+
buildDictionarySpecFromAnalysis,
|
|
13
|
+
loadDictionarySpecFromSchemaFile,
|
|
14
|
+
namespaceContractsMatch,
|
|
15
|
+
} from "./dictionary-spec-contract.js";
|
|
16
|
+
import { analyzeDictionaries } from "./icu-analysis.js";
|
|
17
|
+
import { collectRequestLocales, getCodegenLocaleFallbackIssues } from "./locale-fallback.js";
|
|
18
|
+
import { reportCodegenIssues } from "./paths.js";
|
|
19
|
+
import { prepareDictionaryEntries } from "./read-dictionary.js";
|
|
20
|
+
import type { DictionaryJson } from "./types.js";
|
|
21
|
+
|
|
22
|
+
export interface RegenerateNamespacesInput {
|
|
23
|
+
/** Namespaces whose delivery JSON should be refreshed from authoring sources. */
|
|
24
|
+
namespaces: readonly string[];
|
|
25
|
+
configPath?: string;
|
|
26
|
+
config?: CodegenConfig;
|
|
27
|
+
projectRoot?: string;
|
|
28
|
+
/** When false, skip console.log summaries (default true). */
|
|
29
|
+
log?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RegenerateNamespacesResult {
|
|
33
|
+
projectRoot: string;
|
|
34
|
+
compiledFiles: string[];
|
|
35
|
+
splitPathsByNamespace: Record<string, Record<string, string>>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveRegenerateInput(input: RegenerateNamespacesInput): {
|
|
39
|
+
projectRoot: string;
|
|
40
|
+
config: CodegenConfig;
|
|
41
|
+
namespaces: readonly string[];
|
|
42
|
+
log: boolean;
|
|
43
|
+
} {
|
|
44
|
+
const log = input.log !== false;
|
|
45
|
+
const namespaces = input.namespaces;
|
|
46
|
+
|
|
47
|
+
if (input.config !== undefined) {
|
|
48
|
+
return {
|
|
49
|
+
projectRoot: path.resolve(input.projectRoot ?? process.cwd()),
|
|
50
|
+
config: input.config,
|
|
51
|
+
namespaces,
|
|
52
|
+
log,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const configPath = path.resolve(process.cwd(), input.configPath ?? "i18n/i18n.codegen.json");
|
|
57
|
+
if (!fs.existsSync(configPath)) {
|
|
58
|
+
throw new Error(`[Codegen Error] Config file not found: ${configPath}`);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
projectRoot: path.dirname(configPath),
|
|
62
|
+
config: loadConfig(configPath),
|
|
63
|
+
namespaces,
|
|
64
|
+
log,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Content-only refresh: re-materialize delivery JSON for selected namespaces from
|
|
70
|
+
* current authoring files, without rewriting generated TypeScript.
|
|
71
|
+
*
|
|
72
|
+
* Must not change the ICU key/param contract established by a prior {@link runCodegen}.
|
|
73
|
+
* If authoring changed keys or ICU args, this throws — run {@link runCodegen} and ship a release.
|
|
74
|
+
*
|
|
75
|
+
* Authoring updates are out of scope for this library (CMS or editors write those files).
|
|
76
|
+
* End-to-end without app rebuild requires `loaderStrategy: "fetch"`.
|
|
77
|
+
*/
|
|
78
|
+
export function regenerateNamespaces(input: RegenerateNamespacesInput): RegenerateNamespacesResult {
|
|
79
|
+
const { projectRoot, config, namespaces, log } = resolveRegenerateInput(input);
|
|
80
|
+
|
|
81
|
+
if (namespaces.length === 0) {
|
|
82
|
+
throw new Error("[Codegen Error] regenerateNamespaces requires a non-empty namespaces list.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sourceEntries = resolveNamespaces(config);
|
|
86
|
+
const known = new Set(sourceEntries.map((entry) => entry.namespace));
|
|
87
|
+
const unknown = namespaces.filter((namespace) => !known.has(namespace));
|
|
88
|
+
if (unknown.length > 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`[Codegen Error] Unknown namespace(s) for regenerateNamespaces: ${unknown.join(", ")}.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const requested = new Set(namespaces);
|
|
95
|
+
const selectedEntries = sourceEntries.filter((entry) => requested.has(entry.namespace));
|
|
96
|
+
|
|
97
|
+
const paths = resolveCodegenPaths(config);
|
|
98
|
+
const schemaPath = path.resolve(projectRoot, paths.dictionarySchemaOutput);
|
|
99
|
+
const established = loadDictionarySpecFromSchemaFile(schemaPath);
|
|
100
|
+
|
|
101
|
+
const artifactsPathRelative = resolveArtifactsPath(config);
|
|
102
|
+
const delivery = config.delivery ?? "split-by-locale";
|
|
103
|
+
|
|
104
|
+
// Analyze all namespaces so locale unions / deliveryArtifacts stay consistent with full codegen.
|
|
105
|
+
const analysisResult = analyzeDictionaries(projectRoot, sourceEntries);
|
|
106
|
+
if (!analysisResult.ok) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"[Codegen Error] Dictionary ICU analysis failed while regenerating namespaces."
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const {
|
|
113
|
+
argsSpecByNamespace,
|
|
114
|
+
locales,
|
|
115
|
+
dictionariesByNamespace: sourceDictionaries,
|
|
116
|
+
} = analysisResult.analysis;
|
|
117
|
+
|
|
118
|
+
const current = buildDictionarySpecFromAnalysis(selectedEntries, argsSpecByNamespace);
|
|
119
|
+
if (!namespaceContractsMatch(namespaces, current, established)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"[Codegen Error] Namespace ICU contract changed (keys or params). Contract change requires runCodegen."
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (config.localeFallback) {
|
|
126
|
+
const localeFallbackIssues = getCodegenLocaleFallbackIssues(config.localeFallback, locales);
|
|
127
|
+
if (localeFallbackIssues.length > 0) {
|
|
128
|
+
reportCodegenIssues(localeFallbackIssues);
|
|
129
|
+
throw new Error("[Codegen Error] Invalid localeFallback configuration.");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const requestLocales = collectRequestLocales(locales, config.localeFallback);
|
|
134
|
+
const requestLocalesList = [...requestLocales].sort();
|
|
135
|
+
|
|
136
|
+
if (delivery === "custom" && config.deliveryArtifacts) {
|
|
137
|
+
const deliveryArtifactsIssues = getDeliveryArtifactsIssues(
|
|
138
|
+
config.deliveryArtifacts,
|
|
139
|
+
requestLocales
|
|
140
|
+
);
|
|
141
|
+
if (deliveryArtifactsIssues.length > 0) {
|
|
142
|
+
reportCodegenIssues(deliveryArtifactsIssues);
|
|
143
|
+
throw new Error("[Codegen Error] Invalid deliveryArtifacts configuration.");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const dictionariesByNamespace: Record<string, DictionaryJson> = {};
|
|
148
|
+
for (const entry of selectedEntries) {
|
|
149
|
+
const dictionary = sourceDictionaries[entry.namespace];
|
|
150
|
+
if (!dictionary) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`[Codegen Error] Missing parsed dictionary for namespace "${entry.namespace}".`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
dictionariesByNamespace[entry.namespace] = dictionary;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { splitPathsByNamespace, compiledFiles } = prepareDictionaryEntries(
|
|
159
|
+
projectRoot,
|
|
160
|
+
selectedEntries,
|
|
161
|
+
artifactsPathRelative,
|
|
162
|
+
{
|
|
163
|
+
dictionariesByNamespace,
|
|
164
|
+
delivery,
|
|
165
|
+
localeFallback: config.localeFallback,
|
|
166
|
+
requestLocales: delivery === "split-by-locale" ? requestLocalesList : undefined,
|
|
167
|
+
deliveryArtifacts: delivery === "custom" ? config.deliveryArtifacts : undefined,
|
|
168
|
+
}
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (log) {
|
|
172
|
+
if (compiledFiles.length > 0) {
|
|
173
|
+
console.log(`✅ Regenerated delivery artifacts: ${compiledFiles.join(", ")}`);
|
|
174
|
+
} else {
|
|
175
|
+
console.log("✅ Regenerated delivery artifacts: unchanged");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { projectRoot, compiledFiles, splitPathsByNamespace };
|
|
180
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { regenerateNamespaces } from "./regenerate-namespaces.js";
|
|
6
|
+
import { runCodegen } from "./run-codegen.js";
|
|
7
|
+
|
|
8
|
+
function setupProject() {
|
|
9
|
+
const projectRoot = mkdtempSync(join(tmpdir(), "xndrjs-i18n-a5-"));
|
|
10
|
+
mkdirSync(join(projectRoot, "translations"), { recursive: true });
|
|
11
|
+
mkdirSync(join(projectRoot, "generated"), { recursive: true });
|
|
12
|
+
|
|
13
|
+
writeFileSync(
|
|
14
|
+
join(projectRoot, "translations/default.json"),
|
|
15
|
+
JSON.stringify(
|
|
16
|
+
{
|
|
17
|
+
welcome: { en: "Hello {name}!", it: "Ciao {name}!" },
|
|
18
|
+
login: { en: "Login", it: "Accedi" },
|
|
19
|
+
},
|
|
20
|
+
null,
|
|
21
|
+
2
|
|
22
|
+
)
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
writeFileSync(
|
|
26
|
+
join(projectRoot, "translations/billing.json"),
|
|
27
|
+
JSON.stringify(
|
|
28
|
+
{
|
|
29
|
+
invoice_summary: {
|
|
30
|
+
en: "You have {count} invoices",
|
|
31
|
+
it: "Hai {count} fatture",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
null,
|
|
35
|
+
2
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(projectRoot, "i18n.codegen.json"),
|
|
41
|
+
JSON.stringify(
|
|
42
|
+
{
|
|
43
|
+
projectName: "App",
|
|
44
|
+
namespaces: {
|
|
45
|
+
default: "translations/default.json",
|
|
46
|
+
billing: "translations/billing.json",
|
|
47
|
+
},
|
|
48
|
+
codegenPath: "generated",
|
|
49
|
+
delivery: "split-by-locale",
|
|
50
|
+
artifactsPath: "generated",
|
|
51
|
+
},
|
|
52
|
+
null,
|
|
53
|
+
2
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return projectRoot;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("runCodegen + regenerateNamespaces", () => {
|
|
61
|
+
let projectRoot: string;
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
if (projectRoot) {
|
|
65
|
+
rmSync(projectRoot, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("runCodegen emits types, loaders, and delivery JSON", () => {
|
|
70
|
+
projectRoot = setupProject();
|
|
71
|
+
const result = runCodegen({
|
|
72
|
+
configPath: join(projectRoot, "i18n.codegen.json"),
|
|
73
|
+
log: false,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result.generatedFiles).toEqual(
|
|
77
|
+
expect.arrayContaining([
|
|
78
|
+
"generated/i18n-types.generated.ts",
|
|
79
|
+
"generated/instance.generated.ts",
|
|
80
|
+
"generated/namespace-loaders.generated.ts",
|
|
81
|
+
"generated/dictionary-schema.generated.ts",
|
|
82
|
+
])
|
|
83
|
+
);
|
|
84
|
+
expect(
|
|
85
|
+
readFileSync(join(projectRoot, "generated/translations/default.en.json"), "utf8")
|
|
86
|
+
).toContain("Hello {name}!");
|
|
87
|
+
expect(
|
|
88
|
+
readFileSync(join(projectRoot, "generated/namespace-loaders.generated.ts"), "utf8")
|
|
89
|
+
).toContain("import('./translations/default.en.json')");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("runCodegen emits fetch loaders when configured", () => {
|
|
93
|
+
projectRoot = setupProject();
|
|
94
|
+
writeFileSync(
|
|
95
|
+
join(projectRoot, "i18n.codegen.json"),
|
|
96
|
+
JSON.stringify(
|
|
97
|
+
{
|
|
98
|
+
projectName: "App",
|
|
99
|
+
namespaces: {
|
|
100
|
+
default: "translations/default.json",
|
|
101
|
+
billing: "translations/billing.json",
|
|
102
|
+
},
|
|
103
|
+
codegenPath: "generated",
|
|
104
|
+
delivery: "split-by-locale",
|
|
105
|
+
artifactsPath: "generated",
|
|
106
|
+
loaderStrategy: "fetch",
|
|
107
|
+
},
|
|
108
|
+
null,
|
|
109
|
+
2
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
runCodegen({ configPath: join(projectRoot, "i18n.codegen.json"), log: false });
|
|
114
|
+
const loaders = readFileSync(
|
|
115
|
+
join(projectRoot, "generated/namespace-loaders.generated.ts"),
|
|
116
|
+
"utf8"
|
|
117
|
+
);
|
|
118
|
+
expect(loaders).toContain('fetchImpl({ locale, namespace: "default" })');
|
|
119
|
+
expect(loaders).toContain("createNamespaceLoaders");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("regenerateNamespaces refreshes selected delivery JSON without rewriting types", () => {
|
|
123
|
+
projectRoot = setupProject();
|
|
124
|
+
runCodegen({ configPath: join(projectRoot, "i18n.codegen.json"), log: false });
|
|
125
|
+
|
|
126
|
+
const typesBefore = readFileSync(
|
|
127
|
+
join(projectRoot, "generated/i18n-types.generated.ts"),
|
|
128
|
+
"utf8"
|
|
129
|
+
);
|
|
130
|
+
const instanceBefore = readFileSync(
|
|
131
|
+
join(projectRoot, "generated/instance.generated.ts"),
|
|
132
|
+
"utf8"
|
|
133
|
+
);
|
|
134
|
+
const billingBefore = readFileSync(
|
|
135
|
+
join(projectRoot, "generated/translations/billing.en.json"),
|
|
136
|
+
"utf8"
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(projectRoot, "translations/default.json"),
|
|
141
|
+
JSON.stringify(
|
|
142
|
+
{
|
|
143
|
+
welcome: { en: "Hi {name}!", it: "Ciao {name}!" },
|
|
144
|
+
login: { en: "Login", it: "Accedi" },
|
|
145
|
+
},
|
|
146
|
+
null,
|
|
147
|
+
2
|
|
148
|
+
)
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
regenerateNamespaces({
|
|
152
|
+
configPath: join(projectRoot, "i18n.codegen.json"),
|
|
153
|
+
namespaces: ["default"],
|
|
154
|
+
log: false,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const en = JSON.parse(
|
|
158
|
+
readFileSync(join(projectRoot, "generated/translations/default.en.json"), "utf8")
|
|
159
|
+
);
|
|
160
|
+
expect(en.welcome.en).toBe("Hi {name}!");
|
|
161
|
+
expect(en.login.en).toBe("Login");
|
|
162
|
+
expect(readFileSync(join(projectRoot, "generated/translations/billing.en.json"), "utf8")).toBe(
|
|
163
|
+
billingBefore
|
|
164
|
+
);
|
|
165
|
+
expect(readFileSync(join(projectRoot, "generated/i18n-types.generated.ts"), "utf8")).toBe(
|
|
166
|
+
typesBefore
|
|
167
|
+
);
|
|
168
|
+
expect(readFileSync(join(projectRoot, "generated/instance.generated.ts"), "utf8")).toBe(
|
|
169
|
+
instanceBefore
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("regenerateNamespaces rejects authoring that changes ICU args", () => {
|
|
174
|
+
projectRoot = setupProject();
|
|
175
|
+
runCodegen({ configPath: join(projectRoot, "i18n.codegen.json"), log: false });
|
|
176
|
+
|
|
177
|
+
writeFileSync(
|
|
178
|
+
join(projectRoot, "translations/default.json"),
|
|
179
|
+
JSON.stringify(
|
|
180
|
+
{
|
|
181
|
+
welcome: { en: "Hello {name} {extra}!", it: "Ciao {name} {extra}!" },
|
|
182
|
+
login: { en: "Login", it: "Accedi" },
|
|
183
|
+
},
|
|
184
|
+
null,
|
|
185
|
+
2
|
|
186
|
+
)
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(() =>
|
|
190
|
+
regenerateNamespaces({
|
|
191
|
+
configPath: join(projectRoot, "i18n.codegen.json"),
|
|
192
|
+
namespaces: ["default"],
|
|
193
|
+
log: false,
|
|
194
|
+
})
|
|
195
|
+
).toThrow(/Contract change requires runCodegen/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("regenerateNamespaces rejects empty or unknown namespaces", () => {
|
|
199
|
+
projectRoot = setupProject();
|
|
200
|
+
runCodegen({ configPath: join(projectRoot, "i18n.codegen.json"), log: false });
|
|
201
|
+
|
|
202
|
+
expect(() =>
|
|
203
|
+
regenerateNamespaces({
|
|
204
|
+
configPath: join(projectRoot, "i18n.codegen.json"),
|
|
205
|
+
namespaces: [],
|
|
206
|
+
log: false,
|
|
207
|
+
})
|
|
208
|
+
).toThrow(/non-empty namespaces/);
|
|
209
|
+
|
|
210
|
+
expect(() =>
|
|
211
|
+
regenerateNamespaces({
|
|
212
|
+
configPath: join(projectRoot, "i18n.codegen.json"),
|
|
213
|
+
namespaces: ["missing"],
|
|
214
|
+
log: false,
|
|
215
|
+
})
|
|
216
|
+
).toThrow(/Unknown namespace/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("regenerateNamespaces requires a prior runCodegen schema", () => {
|
|
220
|
+
projectRoot = setupProject();
|
|
221
|
+
|
|
222
|
+
expect(() =>
|
|
223
|
+
regenerateNamespaces({
|
|
224
|
+
configPath: join(projectRoot, "i18n.codegen.json"),
|
|
225
|
+
namespaces: ["default"],
|
|
226
|
+
log: false,
|
|
227
|
+
})
|
|
228
|
+
).toThrow(/Run runCodegen first/);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
loadConfig,
|
|
5
|
+
resolveArtifactsPath,
|
|
6
|
+
resolveCodegenPaths,
|
|
7
|
+
resolveNamespaces,
|
|
8
|
+
} from "./config.js";
|
|
9
|
+
import type { CodegenConfig } from "./codegen-config-schema.js";
|
|
10
|
+
import {
|
|
11
|
+
formatDictionarySchemaFile,
|
|
12
|
+
formatDictionarySpecBlock,
|
|
13
|
+
} from "./emit/dictionary-schema-file.js";
|
|
14
|
+
import { formatInstanceFile } from "./emit/instance-file.js";
|
|
15
|
+
import { formatNamespaceLoadersFile } from "./emit/namespace-loaders-file.js";
|
|
16
|
+
import { formatTypesFile } from "./emit/types-file.js";
|
|
17
|
+
import { analyzeDictionaries } from "./icu-analysis.js";
|
|
18
|
+
import { getDeliveryArtifactsIssues } from "./delivery-artifacts.js";
|
|
19
|
+
import { collectRequestLocales, getCodegenLocaleFallbackIssues } from "./locale-fallback.js";
|
|
20
|
+
import { enrichLocaleFallback } from "./locale-policy.js";
|
|
21
|
+
import { DEFAULT_IMPORT_EXTENSION, reportCodegenIssues, toModuleBasename } from "./paths.js";
|
|
22
|
+
import { prepareDictionaryEntries } from "./read-dictionary.js";
|
|
23
|
+
import { writeFileIfChanged } from "./write-file-if-changed.js";
|
|
24
|
+
|
|
25
|
+
export type RunCodegenInput =
|
|
26
|
+
| string
|
|
27
|
+
| {
|
|
28
|
+
configPath?: string;
|
|
29
|
+
config?: CodegenConfig;
|
|
30
|
+
projectRoot?: string;
|
|
31
|
+
/** When false, skip console.log summaries (default true). */
|
|
32
|
+
log?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface RunCodegenResult {
|
|
36
|
+
projectRoot: string;
|
|
37
|
+
generatedFiles: string[];
|
|
38
|
+
compiledFiles: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveRunCodegenInput(input?: RunCodegenInput): {
|
|
42
|
+
projectRoot: string;
|
|
43
|
+
config: CodegenConfig;
|
|
44
|
+
log: boolean;
|
|
45
|
+
} {
|
|
46
|
+
if (typeof input === "string" || input === undefined) {
|
|
47
|
+
const configPath = path.resolve(
|
|
48
|
+
process.cwd(),
|
|
49
|
+
typeof input === "string" ? input : "i18n/i18n.codegen.json"
|
|
50
|
+
);
|
|
51
|
+
if (!fs.existsSync(configPath)) {
|
|
52
|
+
throw new Error(`[Codegen Error] Config file not found: ${configPath}`);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
projectRoot: path.dirname(configPath),
|
|
56
|
+
config: loadConfig(configPath),
|
|
57
|
+
log: true,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const log = input.log !== false;
|
|
62
|
+
|
|
63
|
+
if (input.config !== undefined) {
|
|
64
|
+
const projectRoot = path.resolve(input.projectRoot ?? process.cwd());
|
|
65
|
+
return { projectRoot, config: input.config, log };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const configPath = path.resolve(process.cwd(), input.configPath ?? "i18n/i18n.codegen.json");
|
|
69
|
+
if (!fs.existsSync(configPath)) {
|
|
70
|
+
throw new Error(`[Codegen Error] Config file not found: ${configPath}`);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
projectRoot: path.dirname(configPath),
|
|
74
|
+
config: loadConfig(configPath),
|
|
75
|
+
log,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Full codegen pipeline: types + instance + loaders + delivery JSON (+ schema).
|
|
81
|
+
* Run at build time or when the *contract* changes (keys, params, namespaces, locales).
|
|
82
|
+
* Content-only refreshes (same ICU contract) should use {@link regenerateNamespaces} instead.
|
|
83
|
+
*/
|
|
84
|
+
export function runCodegen(input?: RunCodegenInput): RunCodegenResult {
|
|
85
|
+
const { projectRoot, config, log } = resolveRunCodegenInput(input);
|
|
86
|
+
const sourceEntries = resolveNamespaces(config);
|
|
87
|
+
const artifactsPathRelative = resolveArtifactsPath(config);
|
|
88
|
+
const delivery = config.delivery ?? "split-by-locale";
|
|
89
|
+
const loaderStrategy = config.loaderStrategy ?? "import";
|
|
90
|
+
|
|
91
|
+
const analysisResult = analyzeDictionaries(projectRoot, sourceEntries);
|
|
92
|
+
if (!analysisResult.ok) {
|
|
93
|
+
throw new Error("[Codegen Error] Dictionary ICU analysis failed. See messages above.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { paramsByNamespace, argsSpecByNamespace, locales, dictionariesByNamespace } =
|
|
97
|
+
analysisResult.analysis;
|
|
98
|
+
|
|
99
|
+
if (config.localeFallback) {
|
|
100
|
+
const localeFallbackIssues = getCodegenLocaleFallbackIssues(config.localeFallback, locales);
|
|
101
|
+
if (localeFallbackIssues.length > 0) {
|
|
102
|
+
reportCodegenIssues(localeFallbackIssues);
|
|
103
|
+
throw new Error("[Codegen Error] Invalid localeFallback configuration.");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const paths = resolveCodegenPaths(config);
|
|
108
|
+
const {
|
|
109
|
+
paramsTypeName,
|
|
110
|
+
schemaTypeName,
|
|
111
|
+
localeTypeName,
|
|
112
|
+
localeFallbackConstName,
|
|
113
|
+
factoryName,
|
|
114
|
+
deliveryAreaTypeName: resolvedDeliveryAreaTypeName,
|
|
115
|
+
} = paths;
|
|
116
|
+
const localeFallbackTypeName = `${localeTypeName}Fallback`;
|
|
117
|
+
const importExtension = DEFAULT_IMPORT_EXTENSION;
|
|
118
|
+
|
|
119
|
+
const requestLocales = collectRequestLocales(locales, config.localeFallback);
|
|
120
|
+
const requestLocalesList = [...requestLocales].sort();
|
|
121
|
+
|
|
122
|
+
if (delivery === "custom" && config.deliveryArtifacts) {
|
|
123
|
+
const deliveryArtifactsIssues = getDeliveryArtifactsIssues(
|
|
124
|
+
config.deliveryArtifacts,
|
|
125
|
+
requestLocales
|
|
126
|
+
);
|
|
127
|
+
if (deliveryArtifactsIssues.length > 0) {
|
|
128
|
+
reportCodegenIssues(deliveryArtifactsIssues);
|
|
129
|
+
throw new Error("[Codegen Error] Invalid deliveryArtifacts configuration.");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const {
|
|
134
|
+
resolvedEntries: entries,
|
|
135
|
+
splitPathsByNamespace,
|
|
136
|
+
compiledFiles,
|
|
137
|
+
} = prepareDictionaryEntries(projectRoot, sourceEntries, artifactsPathRelative, {
|
|
138
|
+
dictionariesByNamespace,
|
|
139
|
+
delivery,
|
|
140
|
+
localeFallback: config.localeFallback,
|
|
141
|
+
requestLocales: delivery === "split-by-locale" ? requestLocalesList : undefined,
|
|
142
|
+
deliveryArtifacts: delivery === "custom" ? config.deliveryArtifacts : undefined,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const typesOutputPath = path.resolve(projectRoot, paths.typesOutput);
|
|
146
|
+
const instanceOutputPath = path.resolve(projectRoot, paths.instanceOutput);
|
|
147
|
+
const namespaceLoadersOutputPath = path.resolve(projectRoot, paths.namespaceLoadersOutput);
|
|
148
|
+
const dictionarySchemaOutputPath = path.resolve(projectRoot, paths.dictionarySchemaOutput);
|
|
149
|
+
const typesModule = toModuleBasename(typesOutputPath);
|
|
150
|
+
|
|
151
|
+
const deliveryAreaNames =
|
|
152
|
+
delivery === "custom" && config.deliveryArtifacts
|
|
153
|
+
? Object.keys(config.deliveryArtifacts).sort()
|
|
154
|
+
: undefined;
|
|
155
|
+
const deliveryAreaTypeName = delivery === "custom" ? resolvedDeliveryAreaTypeName : undefined;
|
|
156
|
+
|
|
157
|
+
const localeFallbackForEmit = config.localeFallback
|
|
158
|
+
? enrichLocaleFallback(locales, config.localeFallback)
|
|
159
|
+
: undefined;
|
|
160
|
+
|
|
161
|
+
const typesContent = formatTypesFile({
|
|
162
|
+
entries,
|
|
163
|
+
projectRoot,
|
|
164
|
+
typesOutputPath,
|
|
165
|
+
paramsTypeName,
|
|
166
|
+
schemaTypeName,
|
|
167
|
+
localeTypeName,
|
|
168
|
+
localeFallbackConstName,
|
|
169
|
+
localeFallbackTypeName,
|
|
170
|
+
localeFallback: localeFallbackForEmit,
|
|
171
|
+
paramsByNamespace,
|
|
172
|
+
requestLocales: requestLocalesList,
|
|
173
|
+
...(deliveryAreaTypeName && deliveryAreaNames
|
|
174
|
+
? {
|
|
175
|
+
deliveryAreaTypeName,
|
|
176
|
+
deliveryAreaNames,
|
|
177
|
+
...(delivery === "custom" && config.deliveryArtifacts
|
|
178
|
+
? { deliveryArtifacts: config.deliveryArtifacts }
|
|
179
|
+
: {}),
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
182
|
+
lazyEntries: entries,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const instanceContent = formatInstanceFile({
|
|
186
|
+
typesOutputPath,
|
|
187
|
+
namespaceLoadersOutputPath,
|
|
188
|
+
paramsTypeName,
|
|
189
|
+
schemaTypeName,
|
|
190
|
+
localeTypeName,
|
|
191
|
+
localeFallbackConstName,
|
|
192
|
+
factoryName,
|
|
193
|
+
hasLocaleFallback: Boolean(config.localeFallback),
|
|
194
|
+
hasLocaleType: requestLocalesList.length > 0,
|
|
195
|
+
importExtension,
|
|
196
|
+
delivery,
|
|
197
|
+
loaderStrategy,
|
|
198
|
+
...(delivery === "custom" ? { localeDeliveryAreaConstName: "LOCALE_DELIVERY_AREA" } : {}),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
writeFileIfChanged(typesOutputPath, typesContent);
|
|
202
|
+
|
|
203
|
+
const generatedFiles = [
|
|
204
|
+
path.relative(projectRoot, typesOutputPath),
|
|
205
|
+
path.relative(projectRoot, instanceOutputPath),
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
writeFileIfChanged(instanceOutputPath, instanceContent);
|
|
209
|
+
|
|
210
|
+
const dictionarySpecBlock = formatDictionarySpecBlock(entries, argsSpecByNamespace);
|
|
211
|
+
const dictionarySchemaContent = formatDictionarySchemaFile(
|
|
212
|
+
schemaTypeName,
|
|
213
|
+
typesModule,
|
|
214
|
+
dictionarySpecBlock,
|
|
215
|
+
importExtension
|
|
216
|
+
);
|
|
217
|
+
writeFileIfChanged(dictionarySchemaOutputPath, dictionarySchemaContent);
|
|
218
|
+
generatedFiles.push(path.relative(projectRoot, dictionarySchemaOutputPath));
|
|
219
|
+
|
|
220
|
+
const lazyEntriesWithPaths = entries.map((entry) => ({
|
|
221
|
+
...entry,
|
|
222
|
+
absolutePath: path.resolve(projectRoot, entry.filePath),
|
|
223
|
+
}));
|
|
224
|
+
const namespaceLoadersContent = formatNamespaceLoadersFile({
|
|
225
|
+
loadersOutputPath: namespaceLoadersOutputPath,
|
|
226
|
+
lazyEntries: lazyEntriesWithPaths,
|
|
227
|
+
schemaTypeName,
|
|
228
|
+
localeTypeName,
|
|
229
|
+
typesModule,
|
|
230
|
+
importExtension,
|
|
231
|
+
projectRoot,
|
|
232
|
+
delivery,
|
|
233
|
+
splitPathsByNamespace,
|
|
234
|
+
loaderStrategy,
|
|
235
|
+
...(delivery === "split-by-locale" ? { requestLocales: requestLocalesList } : {}),
|
|
236
|
+
...(delivery === "custom" && deliveryAreaTypeName && deliveryAreaNames
|
|
237
|
+
? { deliveryAreaTypeName, deliveryAreaNames }
|
|
238
|
+
: {}),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
writeFileIfChanged(namespaceLoadersOutputPath, namespaceLoadersContent);
|
|
242
|
+
generatedFiles.push(path.relative(projectRoot, namespaceLoadersOutputPath));
|
|
243
|
+
|
|
244
|
+
if (log) {
|
|
245
|
+
console.log(`✅ Generated: ${generatedFiles.join(", ")}`);
|
|
246
|
+
if (compiledFiles.length > 0) {
|
|
247
|
+
console.log(`✅ Compiled: ${compiledFiles.join(", ")}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { projectRoot, generatedFiles, compiledFiles };
|
|
252
|
+
}
|
package/src/codegen/types.ts
CHANGED