@xndrjs/i18n 0.3.3 → 0.5.0-alpha.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 +193 -44
- package/dist/codegen/index.d.ts +85 -0
- package/dist/codegen/index.js +181 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/index.d.ts +34 -18
- package/dist/index.js +174 -145
- package/dist/index.js.map +1 -1
- package/dist/validation/index.d.ts +86 -4
- package/dist/validation/index.js.map +1 -1
- package/package.json +6 -1
- package/src/IcuTranslationProviderMulti.test.ts +48 -1
- package/src/IcuTranslationProviderMulti.ts +41 -61
- package/src/IcuTranslationProviderSingle.test.ts +67 -0
- package/src/IcuTranslationProviderSingle.ts +27 -51
- package/src/audit/audit-dictionaries.ts +4 -1
- package/src/audit/run-audit.test.ts +35 -1
- package/src/audit/run-audit.ts +15 -7
- package/src/codegen/codegen-config-schema.ts +68 -1
- package/src/codegen/config.test.ts +174 -39
- package/src/codegen/config.ts +14 -5
- package/src/codegen/constants.ts +8 -0
- package/src/codegen/delivery-artifacts.test.ts +142 -0
- package/src/codegen/delivery-artifacts.ts +124 -0
- package/src/codegen/emit/dictionary-file.test.ts +190 -0
- package/src/codegen/emit/dictionary-file.ts +165 -2
- package/src/codegen/emit/dictionary-schema-file.ts +5 -0
- package/src/codegen/emit/instance-file.ts +119 -38
- package/src/codegen/emit/namespace-loaders-file.test.ts +176 -0
- package/src/codegen/emit/namespace-loaders-file.ts +118 -32
- package/src/codegen/emit/types-file.test.ts +114 -0
- package/src/codegen/emit/types-file.ts +48 -11
- package/src/codegen/generate-i18n-types.test.ts +765 -22
- package/src/codegen/generate-i18n-types.ts +111 -58
- package/src/codegen/icu-analysis.ts +9 -2
- package/src/codegen/locale-fallback.ts +19 -10
- package/src/codegen/locale-policy.ts +4 -0
- package/src/codegen/paths.ts +9 -5
- package/src/codegen/project-locales-set-namespace.test.ts +11 -9
- package/src/codegen/read-dictionary.test.ts +474 -2
- package/src/codegen/read-dictionary.ts +164 -15
- package/src/codegen/write-file-if-changed.test.ts +42 -0
- package/src/codegen/write-file-if-changed.ts +20 -0
- package/src/codegen-config/build-config.ts +34 -0
- package/src/codegen-config/codegen-config.test.ts +32 -0
- package/src/codegen-config/index.ts +21 -0
- package/src/codegen-config/write-config.ts +8 -0
- package/src/deep-freeze.test.ts +13 -0
- package/src/deep-freeze.ts +5 -0
- package/src/format-core.ts +91 -0
- package/src/index.ts +8 -5
- package/src/project-locales.test.ts +129 -10
- package/src/project-locales.ts +90 -3
- package/src/setup/setup-i18n.test.ts +1 -1
- package/src/setup/setup-i18n.ts +9 -32
- package/src/types.ts +19 -4
- package/src/validation/normalize.ts +4 -0
- package/dist/types-C1CpXVOJ.d.ts +0 -83
- package/src/ensure-namespace.integration.test.ts +0 -66
- package/src/ensure-namespace.test.ts +0 -125
- package/src/ensure-namespace.ts +0 -70
- /package/src/{setup → codegen-config}/type-names.ts +0 -0
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { IDENTIFIER_NAME_PATTERN, IDENTIFIER_NAME_REQUIREMENT } from "./constants.js";
|
|
5
|
+
import type { DeliveryMode } from "./codegen-config-schema.js";
|
|
6
|
+
import type { DeliveryArtifactsMap } from "./delivery-artifacts.js";
|
|
7
|
+
import {
|
|
8
|
+
projectNamespaceForDeliveryAreaCore,
|
|
9
|
+
projectNamespaceLocalesCore,
|
|
10
|
+
} from "../project-locales.js";
|
|
11
|
+
import type { LocaleFallbackMap } from "../types.js";
|
|
4
12
|
import type { DictionaryJson, NamespaceEntry } from "./types.js";
|
|
13
|
+
import { writeFileIfChanged } from "./write-file-if-changed.js";
|
|
5
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Dictionary I/O for codegen and audit: read/validate source files (`readDictionaryFile`)
|
|
17
|
+
* and write compiled or split JSON artifacts (`prepareDictionaryEntries`).
|
|
18
|
+
*/
|
|
6
19
|
export const SUPPORTED_DICTIONARY_EXTENSIONS = [".json", ".yaml", ".yml"] as const;
|
|
7
20
|
export type DictionaryFormat = "json" | "yaml";
|
|
8
21
|
|
|
@@ -22,12 +35,70 @@ export function resolveCompiledJsonPath(sourcePath: string, generatedDirRelative
|
|
|
22
35
|
return path.join(generatedDirRelative, "translations", baseName).replace(/\\/g, "/");
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
export function resolveSplitJsonPath(
|
|
39
|
+
sourcePath: string,
|
|
40
|
+
locale: string,
|
|
41
|
+
generatedDirRelative: string
|
|
42
|
+
): string {
|
|
43
|
+
const baseName = path.basename(sourcePath, path.extname(sourcePath));
|
|
44
|
+
return path
|
|
45
|
+
.join(generatedDirRelative, "translations", `${baseName}.${locale}.json`)
|
|
46
|
+
.replace(/\\/g, "/");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveAreaJsonPath(
|
|
50
|
+
sourcePath: string,
|
|
51
|
+
area: string,
|
|
52
|
+
generatedDirRelative: string
|
|
53
|
+
): string {
|
|
54
|
+
const baseName = path.basename(sourcePath, path.extname(sourcePath));
|
|
55
|
+
return path
|
|
56
|
+
.join(generatedDirRelative, "translations", `${baseName}.${area}.json`)
|
|
57
|
+
.replace(/\\/g, "/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Projects a canonical dictionary into per-locale slices for split-by-locale delivery. */
|
|
61
|
+
export function splitDictionaryByLocale(
|
|
62
|
+
dictionary: DictionaryJson,
|
|
63
|
+
locales: readonly string[],
|
|
64
|
+
localeFallback?: LocaleFallbackMap
|
|
65
|
+
): Record<string, DictionaryJson> {
|
|
66
|
+
const byLocale: Record<string, DictionaryJson> = {};
|
|
67
|
+
|
|
68
|
+
for (const locale of locales) {
|
|
69
|
+
byLocale[locale] = projectNamespaceLocalesCore(dictionary, [locale], localeFallback);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return byLocale;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Projects a canonical dictionary into per-area slices for custom delivery. */
|
|
76
|
+
export function splitDictionaryByDeliveryArea(
|
|
77
|
+
dictionary: DictionaryJson,
|
|
78
|
+
deliveryArtifacts: DeliveryArtifactsMap,
|
|
79
|
+
localeFallback?: LocaleFallbackMap
|
|
80
|
+
): Record<string, DictionaryJson> {
|
|
81
|
+
const byArea: Record<string, DictionaryJson> = {};
|
|
82
|
+
|
|
83
|
+
for (const [area, areaLocales] of Object.entries(deliveryArtifacts)) {
|
|
84
|
+
byArea[area] = projectNamespaceForDeliveryAreaCore(dictionary, areaLocales, localeFallback);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return byArea;
|
|
88
|
+
}
|
|
89
|
+
|
|
25
90
|
function assertDictionaryShape(value: unknown, context: string): asserts value is DictionaryJson {
|
|
26
91
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
27
92
|
throw new Error(`[Codegen Error] ${context} must be a plain object.`);
|
|
28
93
|
}
|
|
29
94
|
|
|
30
95
|
for (const [key, localesByKey] of Object.entries(value)) {
|
|
96
|
+
if (!IDENTIFIER_NAME_PATTERN.test(key)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`[Codegen Error] ${context}: invalid key "${key}" (${IDENTIFIER_NAME_REQUIREMENT}).`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
31
102
|
if (localesByKey === null || typeof localesByKey !== "object" || Array.isArray(localesByKey)) {
|
|
32
103
|
throw new Error(`[Codegen Error] ${context}: key "${key}" must map locales to strings.`);
|
|
33
104
|
}
|
|
@@ -42,6 +113,7 @@ function assertDictionaryShape(value: unknown, context: string): asserts value i
|
|
|
42
113
|
}
|
|
43
114
|
}
|
|
44
115
|
|
|
116
|
+
/** Reads and shape-validates a dictionary file from disk (JSON or YAML). */
|
|
45
117
|
export function readDictionaryFile(absolutePath: string): DictionaryJson {
|
|
46
118
|
const format = getDictionaryFormat(absolutePath);
|
|
47
119
|
if (!format) {
|
|
@@ -65,32 +137,52 @@ export function readDictionaryFile(absolutePath: string): DictionaryJson {
|
|
|
65
137
|
}
|
|
66
138
|
|
|
67
139
|
function writeCompiledJson(absoluteJsonPath: string, dictionary: DictionaryJson): boolean {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const currentContent = fs.readFileSync(absoluteJsonPath, "utf8");
|
|
71
|
-
if (currentContent === nextContent) {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
140
|
+
return writeFileIfChanged(absoluteJsonPath, `${JSON.stringify(dictionary, null, 2)}\n`);
|
|
141
|
+
}
|
|
75
142
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
143
|
+
export interface PrepareDictionaryEntriesOptions {
|
|
144
|
+
dictionariesByNamespace: Record<string, DictionaryJson>;
|
|
145
|
+
delivery?: DeliveryMode;
|
|
146
|
+
localeFallback?: LocaleFallbackMap | undefined;
|
|
147
|
+
requestLocales?: readonly string[] | undefined;
|
|
148
|
+
deliveryArtifacts?: DeliveryArtifactsMap | undefined;
|
|
79
149
|
}
|
|
80
150
|
|
|
81
151
|
export interface PrepareDictionariesResult {
|
|
82
152
|
resolvedEntries: NamespaceEntry[];
|
|
153
|
+
splitPathsByNamespace: Record<string, Record<string, string>>;
|
|
83
154
|
compiledFiles: string[];
|
|
84
155
|
}
|
|
85
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Phase 2 of codegen: materialize JSON artifacts on disk (YAML compile, locale/area split).
|
|
159
|
+
* Returns `splitPathsByNamespace` consumed by dictionary and namespace-loader emitters.
|
|
160
|
+
*/
|
|
86
161
|
export function prepareDictionaryEntries(
|
|
87
162
|
projectRoot: string,
|
|
88
163
|
entries: NamespaceEntry[],
|
|
89
|
-
generatedDirRelative: string
|
|
164
|
+
generatedDirRelative: string,
|
|
165
|
+
options: PrepareDictionaryEntriesOptions
|
|
90
166
|
): PrepareDictionariesResult {
|
|
167
|
+
const delivery = options.delivery ?? "canonical";
|
|
168
|
+
const { dictionariesByNamespace, localeFallback, requestLocales, deliveryArtifacts } = options;
|
|
91
169
|
const resolvedEntries: NamespaceEntry[] = [];
|
|
170
|
+
const splitPathsByNamespace: Record<string, Record<string, string>> = {};
|
|
92
171
|
const compiledFiles: string[] = [];
|
|
93
172
|
|
|
173
|
+
if (delivery === "split-by-locale" && (!requestLocales || requestLocales.length === 0)) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
"[Codegen Error] split-by-locale delivery requires at least one request locale."
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
delivery === "custom" &&
|
|
181
|
+
(!deliveryArtifacts || Object.keys(deliveryArtifacts).length === 0)
|
|
182
|
+
) {
|
|
183
|
+
throw new Error("[Codegen Error] custom delivery requires deliveryArtifacts.");
|
|
184
|
+
}
|
|
185
|
+
|
|
94
186
|
for (const entry of entries) {
|
|
95
187
|
const sourceAbsolutePath = path.resolve(projectRoot, entry.filePath);
|
|
96
188
|
const format = getDictionaryFormat(entry.filePath);
|
|
@@ -101,13 +193,70 @@ export function prepareDictionaryEntries(
|
|
|
101
193
|
);
|
|
102
194
|
}
|
|
103
195
|
|
|
104
|
-
|
|
196
|
+
const dictionary = dictionariesByNamespace[entry.namespace];
|
|
197
|
+
if (!dictionary) {
|
|
105
198
|
throw new Error(
|
|
106
|
-
`[Codegen Error]
|
|
199
|
+
`[Codegen Error] Missing parsed dictionary for namespace "${entry.namespace}".`
|
|
107
200
|
);
|
|
108
201
|
}
|
|
109
202
|
|
|
110
|
-
|
|
203
|
+
if (delivery === "split-by-locale") {
|
|
204
|
+
const splitPaths: Record<string, string> = {};
|
|
205
|
+
const dictionariesByLocale = splitDictionaryByLocale(
|
|
206
|
+
dictionary,
|
|
207
|
+
requestLocales!,
|
|
208
|
+
localeFallback
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
for (const locale of requestLocales!) {
|
|
212
|
+
const splitRelativePath = resolveSplitJsonPath(
|
|
213
|
+
entry.filePath,
|
|
214
|
+
locale,
|
|
215
|
+
generatedDirRelative
|
|
216
|
+
);
|
|
217
|
+
const splitAbsolutePath = path.resolve(projectRoot, splitRelativePath);
|
|
218
|
+
const wroteFile = writeCompiledJson(splitAbsolutePath, dictionariesByLocale[locale]!);
|
|
219
|
+
|
|
220
|
+
if (wroteFile) {
|
|
221
|
+
compiledFiles.push(
|
|
222
|
+
`${path.relative(projectRoot, sourceAbsolutePath)} → ${splitRelativePath}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
splitPaths[locale] = splitRelativePath;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
splitPathsByNamespace[entry.namespace] = splitPaths;
|
|
230
|
+
resolvedEntries.push(entry);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (delivery === "custom") {
|
|
235
|
+
const splitPaths: Record<string, string> = {};
|
|
236
|
+
const dictionariesByArea = splitDictionaryByDeliveryArea(
|
|
237
|
+
dictionary,
|
|
238
|
+
deliveryArtifacts!,
|
|
239
|
+
localeFallback
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
for (const area of Object.keys(deliveryArtifacts!).sort()) {
|
|
243
|
+
const areaRelativePath = resolveAreaJsonPath(entry.filePath, area, generatedDirRelative);
|
|
244
|
+
const areaAbsolutePath = path.resolve(projectRoot, areaRelativePath);
|
|
245
|
+
const wroteFile = writeCompiledJson(areaAbsolutePath, dictionariesByArea[area]!);
|
|
246
|
+
|
|
247
|
+
if (wroteFile) {
|
|
248
|
+
compiledFiles.push(
|
|
249
|
+
`${path.relative(projectRoot, sourceAbsolutePath)} → ${areaRelativePath}`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
splitPaths[area] = areaRelativePath;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
splitPathsByNamespace[entry.namespace] = splitPaths;
|
|
257
|
+
resolvedEntries.push(entry);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
111
260
|
|
|
112
261
|
if (format === "yaml") {
|
|
113
262
|
const compiledRelativePath = resolveCompiledJsonPath(entry.filePath, generatedDirRelative);
|
|
@@ -129,5 +278,5 @@ export function prepareDictionaryEntries(
|
|
|
129
278
|
resolvedEntries.push(entry);
|
|
130
279
|
}
|
|
131
280
|
|
|
132
|
-
return { resolvedEntries, compiledFiles };
|
|
281
|
+
return { resolvedEntries, splitPathsByNamespace, compiledFiles };
|
|
133
282
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, statSync, 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 { writeFileIfChanged } from "./write-file-if-changed.js";
|
|
6
|
+
|
|
7
|
+
describe("writeFileIfChanged", () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (tempDir) {
|
|
12
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("creates missing files together with their parent directories", () => {
|
|
17
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-write-"));
|
|
18
|
+
const filePath = join(tempDir, "nested/dir/output.ts");
|
|
19
|
+
|
|
20
|
+
expect(writeFileIfChanged(filePath, "export {};\n")).toBe(true);
|
|
21
|
+
expect(readFileSync(filePath, "utf8")).toBe("export {};\n");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("skips the write when content is unchanged", () => {
|
|
25
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-write-"));
|
|
26
|
+
const filePath = join(tempDir, "output.ts");
|
|
27
|
+
writeFileSync(filePath, "export {};\n");
|
|
28
|
+
const mtimeBefore = statSync(filePath).mtimeMs;
|
|
29
|
+
|
|
30
|
+
expect(writeFileIfChanged(filePath, "export {};\n")).toBe(false);
|
|
31
|
+
expect(statSync(filePath).mtimeMs).toBe(mtimeBefore);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("overwrites the file when content differs", () => {
|
|
35
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-write-"));
|
|
36
|
+
const filePath = join(tempDir, "output.ts");
|
|
37
|
+
writeFileSync(filePath, "export const a = 1;\n");
|
|
38
|
+
|
|
39
|
+
expect(writeFileIfChanged(filePath, "export const a = 2;\n")).toBe(true);
|
|
40
|
+
expect(readFileSync(filePath, "utf8")).toBe("export const a = 2;\n");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Writes `content` to `absolutePath` only when it differs from the current
|
|
6
|
+
* file content, keeping mtimes stable for downstream watchers/bundlers.
|
|
7
|
+
* Returns true when the file was actually written.
|
|
8
|
+
*/
|
|
9
|
+
export function writeFileIfChanged(absolutePath: string, content: string): boolean {
|
|
10
|
+
if (fs.existsSync(absolutePath)) {
|
|
11
|
+
const currentContent = fs.readFileSync(absolutePath, "utf8");
|
|
12
|
+
if (currentContent === content) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
18
|
+
fs.writeFileSync(absolutePath, content);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CodegenConfigInput } from "../codegen/codegen-config-schema.js";
|
|
2
|
+
import { typeNamesForProject } from "./type-names.js";
|
|
3
|
+
|
|
4
|
+
export type SetupMode = "single" | "multi";
|
|
5
|
+
|
|
6
|
+
const GENERATED_DIR = "generated";
|
|
7
|
+
const TRANSLATIONS_DIR = "translations";
|
|
8
|
+
|
|
9
|
+
export function buildCodegenConfig(mode: SetupMode, project: string): CodegenConfigInput {
|
|
10
|
+
const typeNames = typeNamesForProject(project);
|
|
11
|
+
const base = {
|
|
12
|
+
typesOutput: `${GENERATED_DIR}/i18n-types.generated.ts`,
|
|
13
|
+
dictionaryOutput: `${GENERATED_DIR}/dictionary.generated.ts`,
|
|
14
|
+
instanceOutput: `${GENERATED_DIR}/instance.generated.ts`,
|
|
15
|
+
paramsTypeName: typeNames.paramsTypeName,
|
|
16
|
+
schemaTypeName: typeNames.schemaTypeName,
|
|
17
|
+
localeTypeName: typeNames.localeTypeName,
|
|
18
|
+
factoryName: "createI18n",
|
|
19
|
+
} satisfies CodegenConfigInput;
|
|
20
|
+
|
|
21
|
+
if (mode === "single") {
|
|
22
|
+
return {
|
|
23
|
+
dictionary: `${TRANSLATIONS_DIR}/translations.json`,
|
|
24
|
+
...base,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
namespaces: {
|
|
30
|
+
default: `${TRANSLATIONS_DIR}/default.json`,
|
|
31
|
+
},
|
|
32
|
+
...base,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } 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 { buildCodegenConfig, type CodegenConfigInput, writeCodegenConfig } from "./index.js";
|
|
6
|
+
|
|
7
|
+
describe("codegen-config", () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (tempDir) {
|
|
12
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("buildCodegenConfig returns a valid single-mode input shape", () => {
|
|
17
|
+
const config = buildCodegenConfig("single", "MyApp");
|
|
18
|
+
expect(config.dictionary).toBe("translations/translations.json");
|
|
19
|
+
expect(config.paramsTypeName).toBe("MyAppParams");
|
|
20
|
+
expect(config).not.toHaveProperty("namespaces");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("writeCodegenConfig writes formatted JSON", () => {
|
|
24
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-codegen-config-"));
|
|
25
|
+
const configPath = join(tempDir, "i18n/i18n.codegen.json");
|
|
26
|
+
const config: CodegenConfigInput = buildCodegenConfig("multi", "Demo");
|
|
27
|
+
|
|
28
|
+
writeCodegenConfig(configPath, config);
|
|
29
|
+
|
|
30
|
+
expect(readFileSync(configPath, "utf8")).toBe(`${JSON.stringify(config, null, 2)}\n`);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
CodegenConfig,
|
|
3
|
+
CodegenConfigInput,
|
|
4
|
+
DeliveryMode,
|
|
5
|
+
} from "../codegen/codegen-config-schema.js";
|
|
6
|
+
export {
|
|
7
|
+
codegenConfigKeys,
|
|
8
|
+
DELIVERY_MODES,
|
|
9
|
+
resolveDeliveryOutputDir,
|
|
10
|
+
} from "../codegen/codegen-config-schema.js";
|
|
11
|
+
|
|
12
|
+
export type { DeliveryArtifactsMap } from "../codegen/delivery-artifacts.js";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
SUPPORTED_IMPORT_EXTENSIONS,
|
|
16
|
+
type SupportedImportExtension,
|
|
17
|
+
} from "../codegen/constants.js";
|
|
18
|
+
|
|
19
|
+
export { buildCodegenConfig, type SetupMode } from "./build-config.js";
|
|
20
|
+
export { inferProjectName, typeNamesForProject } from "./type-names.js";
|
|
21
|
+
export { writeCodegenConfig } from "./write-config.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { CodegenConfigInput } from "../codegen/codegen-config-schema.js";
|
|
4
|
+
|
|
5
|
+
export function writeCodegenConfig(configPath: string, config: CodegenConfigInput): void {
|
|
6
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
7
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
8
|
+
}
|
package/src/deep-freeze.test.ts
CHANGED
|
@@ -12,6 +12,19 @@ describe("deepFreeze", () => {
|
|
|
12
12
|
value.welcome.en = "Hacked";
|
|
13
13
|
}).toThrow(TypeError);
|
|
14
14
|
});
|
|
15
|
+
|
|
16
|
+
it("does not recurse forever on cyclic structures", () => {
|
|
17
|
+
const value: { name: string; self?: unknown; child: { parent?: unknown } } = {
|
|
18
|
+
name: "root",
|
|
19
|
+
child: {},
|
|
20
|
+
};
|
|
21
|
+
value.self = value;
|
|
22
|
+
value.child.parent = value;
|
|
23
|
+
|
|
24
|
+
expect(() => deepFreeze(value)).not.toThrow();
|
|
25
|
+
expect(Object.isFrozen(value)).toBe(true);
|
|
26
|
+
expect(Object.isFrozen(value.child)).toBe(true);
|
|
27
|
+
});
|
|
15
28
|
});
|
|
16
29
|
|
|
17
30
|
describe("cloneAndFreeze", () => {
|
package/src/deep-freeze.ts
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { IntlMessageFormat } from "intl-messageformat";
|
|
2
|
+
import { formatLocaleFallbackChain, resolveLocaleTemplate } from "./resolve-locale.js";
|
|
3
|
+
import type { LocaleCache, LocaleFallbackMap, OnMissingTranslation } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type FormatCoreContext = {
|
|
6
|
+
key: string;
|
|
7
|
+
namespace?: string | undefined;
|
|
8
|
+
locale: string;
|
|
9
|
+
localeFallback?: LocaleFallbackMap | undefined;
|
|
10
|
+
onMissing: OnMissingTranslation;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ResolveAndFormatOptions = {
|
|
14
|
+
localeByKey: Record<string, string | undefined> | undefined;
|
|
15
|
+
locale: string;
|
|
16
|
+
params?: Record<string, unknown> | undefined;
|
|
17
|
+
getCache: (resolvedLocale: string) => LocaleCache;
|
|
18
|
+
context: FormatCoreContext;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function formatErrorLabel(context: Pick<FormatCoreContext, "key" | "namespace">): string {
|
|
22
|
+
if (context.namespace !== undefined) {
|
|
23
|
+
return `namespace "${context.namespace}", key "${context.key}"`;
|
|
24
|
+
}
|
|
25
|
+
return `key "${context.key}"`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatMissingError(context: FormatCoreContext): string {
|
|
29
|
+
const chain = formatLocaleFallbackChain(context.locale, context.localeFallback);
|
|
30
|
+
if (context.namespace !== undefined) {
|
|
31
|
+
return `[i18n] Missing key or locale: namespace "${context.namespace}", key "${context.key}" [${context.locale}] (fallback chain: ${chain})`;
|
|
32
|
+
}
|
|
33
|
+
return `[i18n] Missing key or locale: "${context.key}" [${context.locale}] (fallback chain: ${chain})`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveMissing(context: FormatCoreContext): string {
|
|
37
|
+
const chain = formatLocaleFallbackChain(context.locale, context.localeFallback);
|
|
38
|
+
if (context.onMissing === "key") {
|
|
39
|
+
if (context.namespace !== undefined) {
|
|
40
|
+
return `${context.namespace}.${context.key}`;
|
|
41
|
+
}
|
|
42
|
+
return context.key;
|
|
43
|
+
}
|
|
44
|
+
if (typeof context.onMissing === "function") {
|
|
45
|
+
return context.onMissing({
|
|
46
|
+
...(context.namespace !== undefined ? { namespace: context.namespace } : {}),
|
|
47
|
+
key: context.key,
|
|
48
|
+
locale: context.locale,
|
|
49
|
+
fallbackChain: chain,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
throw new Error(formatMissingError(context));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Shared runtime path for both ICU providers: resolve locale (with fallback) →
|
|
57
|
+
* compile template (cached) → format with params; handles missing keys via `onMissing`.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveAndFormat(options: ResolveAndFormatOptions): string {
|
|
60
|
+
const { localeByKey, locale, params, getCache, context } = options;
|
|
61
|
+
const resolved = resolveLocaleTemplate(localeByKey, locale, context.localeFallback);
|
|
62
|
+
|
|
63
|
+
if (!resolved) {
|
|
64
|
+
return resolveMissing(context);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { template, resolvedLocale } = resolved;
|
|
68
|
+
const cache = getCache(resolvedLocale);
|
|
69
|
+
const cacheKey = context.key;
|
|
70
|
+
const label = formatErrorLabel(context);
|
|
71
|
+
|
|
72
|
+
if (!cache[cacheKey]) {
|
|
73
|
+
try {
|
|
74
|
+
cache[cacheKey] = new IntlMessageFormat(template, resolvedLocale);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
77
|
+
throw new Error(
|
|
78
|
+
`[i18n ICU Syntax Error] Dictionary error for ${label} [${resolvedLocale}]: ${message}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
return cache[cacheKey].format(params) as string;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
87
|
+
throw new Error(
|
|
88
|
+
`[i18n Formatting Error] Invalid or missing parameters for ${label} [${resolvedLocale}]: ${message}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,8 +17,9 @@ export type {
|
|
|
17
17
|
LocaleFallbackMap,
|
|
18
18
|
LocaleOfMulti,
|
|
19
19
|
LocaleOfSingle,
|
|
20
|
+
MissingTranslationContext,
|
|
20
21
|
MultiDictionary,
|
|
21
|
-
|
|
22
|
+
OnMissingTranslation,
|
|
22
23
|
} from "./types.js";
|
|
23
24
|
|
|
24
25
|
export {
|
|
@@ -28,7 +29,9 @@ export {
|
|
|
28
29
|
} from "./resolve-locale.js";
|
|
29
30
|
export type { ResolvedLocaleTemplate } from "./resolve-locale.js";
|
|
30
31
|
|
|
31
|
-
export {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
export {
|
|
33
|
+
projectDictionaryForDeliveryAreaCore,
|
|
34
|
+
projectDictionaryLocalesCore,
|
|
35
|
+
projectNamespaceForDeliveryAreaCore,
|
|
36
|
+
projectNamespaceLocalesCore,
|
|
37
|
+
} from "./project-locales.js";
|