@xndrjs/i18n 0.2.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 +572 -0
- package/bin/codegen.mjs +17 -0
- package/bin/setup.mjs +14 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +320 -0
- package/dist/index.js.map +1 -0
- package/dist/types-CFWVT2PP.d.ts +77 -0
- package/dist/validation/index.d.ts +44 -0
- package/dist/validation/index.js +490 -0
- package/dist/validation/index.js.map +1 -0
- package/package.json +65 -0
- package/src/IcuTranslationProviderMulti.test.ts +290 -0
- package/src/IcuTranslationProviderMulti.ts +195 -0
- package/src/IcuTranslationProviderSingle.test.ts +314 -0
- package/src/IcuTranslationProviderSingle.ts +155 -0
- package/src/codegen/config.ts +63 -0
- package/src/codegen/emit/dictionary-file.ts +73 -0
- package/src/codegen/emit/dictionary-schema-file.ts +129 -0
- package/src/codegen/emit/instance-file.ts +60 -0
- package/src/codegen/emit/namespace-loaders-file.ts +44 -0
- package/src/codegen/emit/types-file.ts +118 -0
- package/src/codegen/generate-i18n-types.test.ts +482 -0
- package/src/codegen/generate-i18n-types.ts +173 -0
- package/src/codegen/icu-analysis.ts +88 -0
- package/src/codegen/locale-fallback.ts +64 -0
- package/src/codegen/paths.ts +27 -0
- package/src/codegen/types.ts +30 -0
- package/src/deep-freeze.test.ts +27 -0
- package/src/deep-freeze.ts +17 -0
- package/src/ensure-namespace.integration.test.ts +66 -0
- package/src/ensure-namespace.test.ts +125 -0
- package/src/ensure-namespace.ts +70 -0
- package/src/icu/extract-variables.ts +87 -0
- package/src/icu/parse-template.ts +24 -0
- package/src/index.ts +32 -0
- package/src/resolve-locale.test.ts +91 -0
- package/src/resolve-locale.ts +88 -0
- package/src/setup/setup-i18n.test.ts +86 -0
- package/src/setup/setup-i18n.ts +179 -0
- package/src/setup/type-names.ts +20 -0
- package/src/types.ts +34 -0
- package/src/validation/create-args-schema.ts +76 -0
- package/src/validation/create-normalized-schema.ts +52 -0
- package/src/validation/errors.ts +41 -0
- package/src/validation/index.ts +90 -0
- package/src/validation/normalize.ts +158 -0
- package/src/validation/to-dictionary.ts +67 -0
- package/src/validation/types.ts +78 -0
- package/src/validation/validate-normalized.ts +91 -0
- package/src/validation/validation.test.ts +265 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
formatLocaleFallbackChain,
|
|
4
|
+
resolveLocaleTemplate,
|
|
5
|
+
validateLocaleFallback,
|
|
6
|
+
} from "./resolve-locale.js";
|
|
7
|
+
|
|
8
|
+
const fallbackMap = {
|
|
9
|
+
en: null,
|
|
10
|
+
"de-DE": "en",
|
|
11
|
+
"de-CH": "de-DE",
|
|
12
|
+
it: "en",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
const localeByKey = {
|
|
16
|
+
en: "Hello",
|
|
17
|
+
it: "Ciao",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("resolveLocaleTemplate", () => {
|
|
21
|
+
it("returns the template for the requested locale when present", () => {
|
|
22
|
+
expect(resolveLocaleTemplate(localeByKey, "en")).toEqual({
|
|
23
|
+
template: "Hello",
|
|
24
|
+
resolvedLocale: "en",
|
|
25
|
+
fallbackChain: ["en"],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("follows the fallback chain until a template is found", () => {
|
|
30
|
+
expect(resolveLocaleTemplate(localeByKey, "de-CH", fallbackMap)).toEqual({
|
|
31
|
+
template: "Hello",
|
|
32
|
+
resolvedLocale: "en",
|
|
33
|
+
fallbackChain: ["de-CH", "de-DE", "en"],
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("uses an intermediate locale when it has a template", () => {
|
|
38
|
+
const withGerman = { ...localeByKey, "de-DE": "Hallo" };
|
|
39
|
+
expect(resolveLocaleTemplate(withGerman, "de-CH", fallbackMap)).toEqual({
|
|
40
|
+
template: "Hallo",
|
|
41
|
+
resolvedLocale: "de-DE",
|
|
42
|
+
fallbackChain: ["de-CH", "de-DE"],
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("treats an empty string template as present", () => {
|
|
47
|
+
expect(resolveLocaleTemplate({ en: "" }, "en")).toEqual({
|
|
48
|
+
template: "",
|
|
49
|
+
resolvedLocale: "en",
|
|
50
|
+
fallbackChain: ["en"],
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns undefined when the chain ends with null fallback", () => {
|
|
55
|
+
expect(resolveLocaleTemplate(localeByKey, "de-CH", { "de-CH": null })).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns undefined when the locale is unknown and has no fallback map entry", () => {
|
|
59
|
+
expect(resolveLocaleTemplate(localeByKey, "fr", fallbackMap)).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("throws on circular fallback chains", () => {
|
|
63
|
+
expect(() =>
|
|
64
|
+
resolveLocaleTemplate(localeByKey, "de-CH", {
|
|
65
|
+
"de-CH": "de-DE",
|
|
66
|
+
"de-DE": "de-CH",
|
|
67
|
+
})
|
|
68
|
+
).toThrow("[i18n] Circular locale fallback detected");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("validateLocaleFallback", () => {
|
|
73
|
+
it("accepts acyclic fallback maps", () => {
|
|
74
|
+
expect(() => validateLocaleFallback(fallbackMap)).not.toThrow();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("rejects circular fallback maps at construction time", () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
validateLocaleFallback({
|
|
80
|
+
a: "b",
|
|
81
|
+
b: "a",
|
|
82
|
+
})
|
|
83
|
+
).toThrow("[i18n] Circular locale fallback detected");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("formatLocaleFallbackChain", () => {
|
|
88
|
+
it("formats the fallback chain for error messages", () => {
|
|
89
|
+
expect(formatLocaleFallbackChain("de-CH", fallbackMap)).toBe("de-CH → de-DE → en");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export type LocaleFallbackMap = Record<string, string | null>;
|
|
2
|
+
|
|
3
|
+
export interface ResolvedLocaleTemplate {
|
|
4
|
+
template: string;
|
|
5
|
+
resolvedLocale: string;
|
|
6
|
+
fallbackChain: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function validateLocaleFallback(map: LocaleFallbackMap): void {
|
|
10
|
+
for (const startLocale of Object.keys(map)) {
|
|
11
|
+
const visited = new Set<string>();
|
|
12
|
+
let current: string | undefined = startLocale;
|
|
13
|
+
|
|
14
|
+
while (current !== undefined) {
|
|
15
|
+
if (visited.has(current)) {
|
|
16
|
+
throw new Error(`[i18n] Circular locale fallback detected involving "${current}"`);
|
|
17
|
+
}
|
|
18
|
+
visited.add(current);
|
|
19
|
+
|
|
20
|
+
const next: string | null | undefined = map[current];
|
|
21
|
+
if (next === undefined || next === null) {
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
current = next;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveLocaleTemplate(
|
|
30
|
+
localeByKey: Record<string, string | undefined> | undefined,
|
|
31
|
+
requestedLocale: string,
|
|
32
|
+
fallbackMap?: LocaleFallbackMap
|
|
33
|
+
): ResolvedLocaleTemplate | undefined {
|
|
34
|
+
const chain: string[] = [requestedLocale];
|
|
35
|
+
let currentLocale = requestedLocale;
|
|
36
|
+
|
|
37
|
+
while (true) {
|
|
38
|
+
const template = localeByKey?.[currentLocale];
|
|
39
|
+
if (template !== undefined) {
|
|
40
|
+
return { template, resolvedLocale: currentLocale, fallbackChain: chain };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!fallbackMap) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fallback = fallbackMap[currentLocale];
|
|
48
|
+
if (fallback === undefined || fallback === null) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (chain.includes(fallback)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`[i18n] Circular locale fallback detected: ${[...chain, fallback].join(" → ")}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
chain.push(fallback);
|
|
59
|
+
currentLocale = fallback;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatLocaleFallbackChain(
|
|
64
|
+
requestedLocale: string,
|
|
65
|
+
fallbackMap?: LocaleFallbackMap
|
|
66
|
+
): string {
|
|
67
|
+
if (!fallbackMap) {
|
|
68
|
+
return requestedLocale;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const chain: string[] = [requestedLocale];
|
|
72
|
+
let current = requestedLocale;
|
|
73
|
+
|
|
74
|
+
while (true) {
|
|
75
|
+
const fallback = fallbackMap[current];
|
|
76
|
+
if (fallback === undefined || fallback === null) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (chain.includes(fallback)) {
|
|
80
|
+
chain.push(fallback);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
chain.push(fallback);
|
|
84
|
+
current = fallback;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return chain.join(" → ");
|
|
88
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, existsSync } 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, parseSetupArgs, runSetup } from "./setup-i18n.js";
|
|
6
|
+
import { inferProjectName, typeNamesForProject } from "./type-names.js";
|
|
7
|
+
|
|
8
|
+
describe("type-names", () => {
|
|
9
|
+
it("derives PascalCase project names from directory names", () => {
|
|
10
|
+
expect(inferProjectName("myapp")).toBe("Myapp");
|
|
11
|
+
expect(inferProjectName("my-app")).toBe("MyApp");
|
|
12
|
+
expect(inferProjectName("apps")).toBe("Apps");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("builds type names from project prefix", () => {
|
|
16
|
+
expect(typeNamesForProject("MyApp")).toEqual({
|
|
17
|
+
paramsTypeName: "MyAppParams",
|
|
18
|
+
schemaTypeName: "MyAppSchema",
|
|
19
|
+
localeTypeName: "MyAppLocale",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("setup-i18n", () => {
|
|
25
|
+
let tempDir: string;
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (tempDir) {
|
|
29
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("parses single and multi CLI args", () => {
|
|
34
|
+
expect(parseSetupArgs(["single", ".", "--project", "MyApp"])).toEqual({
|
|
35
|
+
mode: "single",
|
|
36
|
+
targetDir: ".",
|
|
37
|
+
project: "MyApp",
|
|
38
|
+
force: false,
|
|
39
|
+
});
|
|
40
|
+
expect(parseSetupArgs(["multi", "apps/myapp", "--project", "MyApp", "--force"])).toEqual({
|
|
41
|
+
mode: "multi",
|
|
42
|
+
targetDir: "apps/myapp",
|
|
43
|
+
project: "MyApp",
|
|
44
|
+
force: true,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("scaffolds single mode with MyApp type names", () => {
|
|
49
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
|
|
50
|
+
const result = runSetup({ mode: "single", targetDir: tempDir, project: "MyApp" });
|
|
51
|
+
|
|
52
|
+
const config = JSON.parse(readFileSync(join(tempDir, "i18n.codegen.json"), "utf8"));
|
|
53
|
+
expect(config.dictionary).toBe("src/i18n/translations/translations.json");
|
|
54
|
+
expect(config.paramsTypeName).toBe("MyAppParams");
|
|
55
|
+
expect(config.schemaTypeName).toBe("MyAppSchema");
|
|
56
|
+
expect(config.localeTypeName).toBe("MyAppLocale");
|
|
57
|
+
expect(existsSync(join(tempDir, "src/i18n/translations/translations.json"))).toBe(true);
|
|
58
|
+
expect(existsSync(join(tempDir, "src/i18n/index.ts"))).toBe(true);
|
|
59
|
+
expect(result.created).toContain("i18n.codegen.json");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("scaffolds multi mode with default namespace only", () => {
|
|
63
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
|
|
64
|
+
runSetup({ mode: "multi", targetDir: tempDir, project: "MyApp" });
|
|
65
|
+
|
|
66
|
+
const config = JSON.parse(readFileSync(join(tempDir, "i18n.codegen.json"), "utf8"));
|
|
67
|
+
expect(config.namespaces).toEqual({
|
|
68
|
+
default: "src/i18n/translations/default.json",
|
|
69
|
+
});
|
|
70
|
+
expect(existsSync(join(tempDir, "src/i18n/translations/default.json"))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("refuses to overwrite an existing config without --force", () => {
|
|
74
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-setup-"));
|
|
75
|
+
runSetup({ mode: "single", targetDir: tempDir, project: "MyApp" });
|
|
76
|
+
|
|
77
|
+
expect(() => runSetup({ mode: "single", targetDir: tempDir, project: "MyApp" })).toThrow(
|
|
78
|
+
"already exists"
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("buildCodegenConfig omits lazy and validation keys by default", () => {
|
|
83
|
+
expect(buildCodegenConfig("multi", "MyApp")).not.toHaveProperty("loadOnInit");
|
|
84
|
+
expect(buildCodegenConfig("multi", "MyApp")).not.toHaveProperty("dictionarySchemaOutput");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { inferProjectName, typeNamesForProject } from "./type-names.js";
|
|
5
|
+
|
|
6
|
+
export type SetupMode = "single" | "multi";
|
|
7
|
+
|
|
8
|
+
export interface SetupOptions {
|
|
9
|
+
mode: SetupMode;
|
|
10
|
+
targetDir: string;
|
|
11
|
+
project?: string | undefined;
|
|
12
|
+
force?: boolean | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SetupResult {
|
|
16
|
+
targetDir: string;
|
|
17
|
+
project: string;
|
|
18
|
+
created: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const I18N_ROOT = "src/i18n";
|
|
22
|
+
const GENERATED_DIR = `${I18N_ROOT}/generated`;
|
|
23
|
+
const TRANSLATIONS_DIR = `${I18N_ROOT}/translations`;
|
|
24
|
+
|
|
25
|
+
const DEFAULT_STARTER = {
|
|
26
|
+
welcome: { en: "Welcome {name}!" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const INDEX_TS =
|
|
30
|
+
`import { createI18n } from "./generated/instance.generated.js";\n\n` +
|
|
31
|
+
`export * from "./generated/instance.generated.js";\n` +
|
|
32
|
+
`export * from "./generated/dictionary.generated.js";\n` +
|
|
33
|
+
`export * from "./generated/i18n-types.generated.js";\n\n` +
|
|
34
|
+
`export const i18n = createI18n();\n`;
|
|
35
|
+
|
|
36
|
+
function writeJson(filePath: string, value: unknown): void {
|
|
37
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
38
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeText(filePath: string, content: string): void {
|
|
42
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
43
|
+
fs.writeFileSync(filePath, content);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function relative(fromRoot: string, filePath: string): string {
|
|
47
|
+
return path.relative(fromRoot, filePath).replace(/\\/g, "/");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildCodegenConfig(mode: SetupMode, project: string): Record<string, unknown> {
|
|
51
|
+
const typeNames = typeNamesForProject(project);
|
|
52
|
+
const base = {
|
|
53
|
+
typesOutput: `${GENERATED_DIR}/i18n-types.generated.ts`,
|
|
54
|
+
dictionaryOutput: `${GENERATED_DIR}/dictionary.generated.ts`,
|
|
55
|
+
instanceOutput: `${GENERATED_DIR}/instance.generated.ts`,
|
|
56
|
+
paramsTypeName: typeNames.paramsTypeName,
|
|
57
|
+
schemaTypeName: typeNames.schemaTypeName,
|
|
58
|
+
localeTypeName: typeNames.localeTypeName,
|
|
59
|
+
factoryName: "createI18n",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (mode === "single") {
|
|
63
|
+
return {
|
|
64
|
+
dictionary: `${TRANSLATIONS_DIR}/translations.json`,
|
|
65
|
+
...base,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
namespaces: {
|
|
71
|
+
default: `${TRANSLATIONS_DIR}/default.json`,
|
|
72
|
+
},
|
|
73
|
+
...base,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function runSetup(options: SetupOptions): SetupResult {
|
|
78
|
+
const targetDir = path.resolve(options.targetDir);
|
|
79
|
+
const project =
|
|
80
|
+
options.project ?? inferProjectName(path.basename(targetDir.replace(/[/\\]$/, "") || "app"));
|
|
81
|
+
|
|
82
|
+
if (!/^[A-Z][a-zA-Z0-9]*$/.test(project)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`[Setup Error] Invalid project name "${project}". Use PascalCase (e.g. MyApp) via --project.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const configPath = path.join(targetDir, "i18n.codegen.json");
|
|
89
|
+
if (fs.existsSync(configPath) && !options.force) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`[Setup Error] ${relative(process.cwd(), configPath)} already exists. Use --force to overwrite.`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const created: string[] = [];
|
|
96
|
+
|
|
97
|
+
writeJson(configPath, buildCodegenConfig(options.mode, project));
|
|
98
|
+
created.push(relative(targetDir, configPath));
|
|
99
|
+
|
|
100
|
+
if (options.mode === "single") {
|
|
101
|
+
const translationsPath = path.join(targetDir, TRANSLATIONS_DIR, "translations.json");
|
|
102
|
+
writeJson(translationsPath, DEFAULT_STARTER);
|
|
103
|
+
created.push(relative(targetDir, translationsPath));
|
|
104
|
+
} else {
|
|
105
|
+
const defaultPath = path.join(targetDir, TRANSLATIONS_DIR, "default.json");
|
|
106
|
+
writeJson(defaultPath, DEFAULT_STARTER);
|
|
107
|
+
created.push(relative(targetDir, defaultPath));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const indexPath = path.join(targetDir, I18N_ROOT, "index.ts");
|
|
111
|
+
writeText(indexPath, INDEX_TS);
|
|
112
|
+
created.push(relative(targetDir, indexPath));
|
|
113
|
+
|
|
114
|
+
fs.mkdirSync(path.join(targetDir, GENERATED_DIR), { recursive: true });
|
|
115
|
+
|
|
116
|
+
return { targetDir, project, created };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseSetupArgs(argv: string[]): SetupOptions {
|
|
120
|
+
const mode = argv[0];
|
|
121
|
+
if (mode !== "single" && mode !== "multi") {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`[Setup Error] Usage: xndrjs-i18n-setup <single|multi> [targetDir] [--project MyApp] [--force]`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let targetDir = ".";
|
|
128
|
+
let project: string | undefined;
|
|
129
|
+
let force = false;
|
|
130
|
+
|
|
131
|
+
for (let index = 1; index < argv.length; index++) {
|
|
132
|
+
const arg = argv[index]!;
|
|
133
|
+
if (arg === "--project") {
|
|
134
|
+
project = argv[++index];
|
|
135
|
+
if (!project) {
|
|
136
|
+
throw new Error("[Setup Error] --project requires a value.");
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (arg === "--force") {
|
|
141
|
+
force = true;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (arg.startsWith("-")) {
|
|
145
|
+
throw new Error(`[Setup Error] Unknown flag: ${arg}`);
|
|
146
|
+
}
|
|
147
|
+
targetDir = arg;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { mode, targetDir, project, force };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function main() {
|
|
154
|
+
try {
|
|
155
|
+
const options = parseSetupArgs(process.argv.slice(2));
|
|
156
|
+
const result = runSetup(options);
|
|
157
|
+
|
|
158
|
+
console.log(`✅ Setup ${options.mode} i18n in ${result.targetDir}`);
|
|
159
|
+
console.log(
|
|
160
|
+
` Project types: ${result.project}Params, ${result.project}Schema, ${result.project}Locale`
|
|
161
|
+
);
|
|
162
|
+
console.log(` Created: ${result.created.join(", ")}`);
|
|
163
|
+
console.log("");
|
|
164
|
+
console.log("Next:");
|
|
165
|
+
console.log(` cd ${path.relative(process.cwd(), result.targetDir) || "."}`);
|
|
166
|
+
console.log(
|
|
167
|
+
' npm pkg set scripts.i18n:codegen="xndrjs-i18n-codegen --config i18n.codegen.json"'
|
|
168
|
+
);
|
|
169
|
+
console.log(" npm run i18n:codegen");
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
+
console.error(message);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
178
|
+
main();
|
|
179
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function inferProjectName(dirName: string): string {
|
|
2
|
+
const parts = dirName.split(/[-_]+/).filter(Boolean);
|
|
3
|
+
if (parts.length === 0) {
|
|
4
|
+
return "App";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function typeNamesForProject(project: string): {
|
|
11
|
+
paramsTypeName: string;
|
|
12
|
+
schemaTypeName: string;
|
|
13
|
+
localeTypeName: string;
|
|
14
|
+
} {
|
|
15
|
+
return {
|
|
16
|
+
paramsTypeName: `${project}Params`,
|
|
17
|
+
schemaTypeName: `${project}Schema`,
|
|
18
|
+
localeTypeName: `${project}Locale`,
|
|
19
|
+
};
|
|
20
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { IntlMessageFormat } from "intl-messageformat";
|
|
2
|
+
|
|
3
|
+
export type LocaleDictionary = Record<string, string>;
|
|
4
|
+
export type KeyDictionary = Record<string, LocaleDictionary>;
|
|
5
|
+
export type MultiDictionary = Record<string, KeyDictionary>;
|
|
6
|
+
|
|
7
|
+
export type LocaleOfSingle<Schema extends KeyDictionary> = {
|
|
8
|
+
[K in keyof Schema]: keyof Schema[K];
|
|
9
|
+
}[keyof Schema] &
|
|
10
|
+
string;
|
|
11
|
+
|
|
12
|
+
export type LocaleOfMulti<Schema extends MultiDictionary> = {
|
|
13
|
+
[NS in keyof Schema]: {
|
|
14
|
+
[K in keyof Schema[NS]]: keyof Schema[NS][K];
|
|
15
|
+
}[keyof Schema[NS]];
|
|
16
|
+
}[keyof Schema] &
|
|
17
|
+
string;
|
|
18
|
+
|
|
19
|
+
export type LocaleFallbackMap = Record<string, string | null>;
|
|
20
|
+
|
|
21
|
+
export type RequestLocale<
|
|
22
|
+
DictionaryLocale extends string,
|
|
23
|
+
Fallback extends LocaleFallbackMap | undefined = undefined,
|
|
24
|
+
> = DictionaryLocale | (Fallback extends LocaleFallbackMap ? keyof Fallback & string : never);
|
|
25
|
+
|
|
26
|
+
export type IcuTranslationProviderOptions<
|
|
27
|
+
Fallback extends LocaleFallbackMap | undefined = undefined,
|
|
28
|
+
> = {
|
|
29
|
+
localeFallback?: Fallback;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type LocaleCache = Record<string, IntlMessageFormat>;
|
|
33
|
+
export type SingleCompiledCache = Record<string, LocaleCache>;
|
|
34
|
+
export type MultiCompiledCache = Record<string, Record<string, LocaleCache>>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { VariableSpec, VariableType } from "../icu/extract-variables.js";
|
|
3
|
+
|
|
4
|
+
export const variableTypeSchema = z.enum(["string", "number", "date"]);
|
|
5
|
+
|
|
6
|
+
function formatVariableMismatch(expected: VariableSpec, found: VariableSpec): string {
|
|
7
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
8
|
+
const foundKeys = Object.keys(found).sort();
|
|
9
|
+
return `Expected variables [${expectedKeys.join(", ")}], found [${foundKeys.join(", ")}]`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createArgsSchema(expected: VariableSpec): z.ZodType<VariableSpec> {
|
|
13
|
+
return z.record(z.string(), variableTypeSchema).superRefine((found, ctx) => {
|
|
14
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
15
|
+
const foundKeys = Object.keys(found).sort();
|
|
16
|
+
|
|
17
|
+
if (expectedKeys.join() !== foundKeys.join()) {
|
|
18
|
+
ctx.addIssue({
|
|
19
|
+
code: "custom",
|
|
20
|
+
message: formatVariableMismatch(expected, found),
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const [name, type] of Object.entries(expected)) {
|
|
26
|
+
if (found[name] !== type) {
|
|
27
|
+
ctx.addIssue({
|
|
28
|
+
code: "custom",
|
|
29
|
+
message: `Variable "${name}": expected ${type}, found ${found[name]}`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function mapArgsSchemaError(
|
|
37
|
+
expected: VariableSpec,
|
|
38
|
+
found: VariableSpec,
|
|
39
|
+
message: string,
|
|
40
|
+
path: readonly string[]
|
|
41
|
+
) {
|
|
42
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
43
|
+
const foundKeys = Object.keys(found).sort();
|
|
44
|
+
|
|
45
|
+
if (expectedKeys.join() !== foundKeys.join()) {
|
|
46
|
+
return {
|
|
47
|
+
kind: "variable_mismatch" as const,
|
|
48
|
+
path,
|
|
49
|
+
expected,
|
|
50
|
+
found,
|
|
51
|
+
message,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const [name, type] of Object.entries(expected)) {
|
|
56
|
+
const foundType = found[name];
|
|
57
|
+
if (foundType !== undefined && foundType !== type) {
|
|
58
|
+
return {
|
|
59
|
+
kind: "variable_type_mismatch" as const,
|
|
60
|
+
path,
|
|
61
|
+
variable: name,
|
|
62
|
+
expected: type,
|
|
63
|
+
found: foundType as VariableType,
|
|
64
|
+
message,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
kind: "variable_mismatch" as const,
|
|
71
|
+
path,
|
|
72
|
+
expected,
|
|
73
|
+
found,
|
|
74
|
+
message,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { VariableSpec } from "../icu/extract-variables.js";
|
|
3
|
+
import { createArgsSchema, variableTypeSchema } from "./create-args-schema.js";
|
|
4
|
+
import type { DictionarySpec, NormalizedDictionary, ParsedKeyEntry } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const parsedLocaleEntrySchema = z.object({
|
|
7
|
+
template: z.string(),
|
|
8
|
+
args: z.record(z.string(), variableTypeSchema),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const parsedKeyEntrySchema = z.object({
|
|
12
|
+
locales: z.record(z.string(), parsedLocaleEntrySchema),
|
|
13
|
+
mergedArgs: z.record(z.string(), variableTypeSchema),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function createKeyDictionarySchema(
|
|
17
|
+
argsByKey: Readonly<Record<string, VariableSpec>>
|
|
18
|
+
): z.ZodType<Record<string, ParsedKeyEntry>> {
|
|
19
|
+
const keySchemas = Object.fromEntries(
|
|
20
|
+
Object.entries(argsByKey).map(([key, expectedArgs]) => [
|
|
21
|
+
key,
|
|
22
|
+
parsedKeyEntrySchema.extend({
|
|
23
|
+
mergedArgs: createArgsSchema(expectedArgs),
|
|
24
|
+
}),
|
|
25
|
+
])
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return z.object(keySchemas);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createNormalizedDictionarySchema(
|
|
32
|
+
spec: DictionarySpec
|
|
33
|
+
): z.ZodType<NormalizedDictionary> {
|
|
34
|
+
if (spec.mode === "single") {
|
|
35
|
+
return z.object({
|
|
36
|
+
mode: z.literal("single"),
|
|
37
|
+
keys: createKeyDictionarySchema(spec.argsByKey),
|
|
38
|
+
}) as z.ZodType<NormalizedDictionary>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const namespaceSchemas = Object.fromEntries(
|
|
42
|
+
Object.entries(spec.argsByKey).map(([namespace, argsByKey]) => [
|
|
43
|
+
namespace,
|
|
44
|
+
createKeyDictionarySchema(argsByKey),
|
|
45
|
+
])
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return z.object({
|
|
49
|
+
mode: z.literal("multi"),
|
|
50
|
+
namespaces: z.object(namespaceSchemas),
|
|
51
|
+
}) as z.ZodType<NormalizedDictionary>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ValidationIssue, ValidationResult } from "./types.js";
|
|
2
|
+
|
|
3
|
+
function issueMessage(issue: ValidationIssue): string {
|
|
4
|
+
switch (issue.kind) {
|
|
5
|
+
case "missing_key":
|
|
6
|
+
return `Missing required key at ${issue.path.join(".")}`;
|
|
7
|
+
case "invalid_input":
|
|
8
|
+
case "invalid_locale_value":
|
|
9
|
+
case "icu_syntax_error":
|
|
10
|
+
case "locale_args_mismatch":
|
|
11
|
+
case "variable_mismatch":
|
|
12
|
+
case "variable_type_mismatch":
|
|
13
|
+
return issue.message;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatIssues(issues: readonly ValidationIssue[]): string {
|
|
18
|
+
return issues
|
|
19
|
+
.map((issue) => {
|
|
20
|
+
if ("path" in issue && issue.path.length > 0) {
|
|
21
|
+
return `[${issue.path.join(".")}] ${issue.kind}: ${issueMessage(issue)}`;
|
|
22
|
+
}
|
|
23
|
+
return `${issue.kind}: ${issueMessage(issue)}`;
|
|
24
|
+
})
|
|
25
|
+
.join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DictionaryValidationError extends Error {
|
|
29
|
+
constructor(public readonly issues: readonly ValidationIssue[]) {
|
|
30
|
+
super(formatIssues(issues));
|
|
31
|
+
this.name = "DictionaryValidationError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertValidDictionary<T>(
|
|
36
|
+
result: ValidationResult<T>
|
|
37
|
+
): asserts result is { ok: true; data: T } {
|
|
38
|
+
if (!result.ok) {
|
|
39
|
+
throw new DictionaryValidationError(result.issues);
|
|
40
|
+
}
|
|
41
|
+
}
|