@xndrjs/i18n 0.4.0 → 0.5.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 +156 -29
- 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 -5
- package/dist/index.js +175 -74
- package/dist/index.js.map +1 -1
- package/dist/validation/index.d.ts +4 -0
- package/dist/validation/index.js.map +1 -1
- package/package.json +6 -1
- package/src/IcuTranslationProviderMulti.test.ts +47 -0
- 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 +163 -0
- package/src/codegen/emit/dictionary-schema-file.ts +5 -0
- package/src/codegen/emit/instance-file.ts +114 -28
- package/src/codegen/emit/namespace-loaders-file.test.ts +176 -0
- package/src/codegen/emit/namespace-loaders-file.ts +114 -6
- 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 +754 -16
- package/src/codegen/generate-i18n-types.ts +111 -47
- 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 +5 -5
- 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 -2
- 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 +6 -30
- package/src/types.ts +19 -4
- package/src/validation/normalize.ts +4 -0
- /package/src/{setup → codegen-config}/type-names.ts +0 -0
|
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { dirname } from "node:path";
|
|
7
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
8
8
|
import { parseAuditArgs, runAuditCli } from "./run-audit.js";
|
|
9
9
|
|
|
10
10
|
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
@@ -119,6 +119,40 @@ describe("runAuditCli", () => {
|
|
|
119
119
|
}
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
it("exits 2 with an error message when the config is invalid", async () => {
|
|
123
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-audit-"));
|
|
124
|
+
mkdirSync(join(tempDir, "i18n"), { recursive: true });
|
|
125
|
+
writeFileSync(
|
|
126
|
+
join(tempDir, "i18n/i18n.codegen.json"),
|
|
127
|
+
JSON.stringify({ namespaces: { default: "translations/default.json" } })
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => void 0);
|
|
131
|
+
const previousCwd = process.cwd();
|
|
132
|
+
process.chdir(tempDir);
|
|
133
|
+
try {
|
|
134
|
+
const exitCode = await runAuditCli(["--config", "i18n/i18n.codegen.json"]);
|
|
135
|
+
expect(exitCode).toBe(2);
|
|
136
|
+
expect(String(errorSpy.mock.calls[0]?.[0])).toContain("Invalid i18n.codegen.json");
|
|
137
|
+
} finally {
|
|
138
|
+
process.chdir(previousCwd);
|
|
139
|
+
errorSpy.mockRestore();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("exits 2 with an error message when --fail-on has an invalid value", async () => {
|
|
144
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => void 0);
|
|
145
|
+
try {
|
|
146
|
+
const exitCode = await runAuditCli(["--fail-on", "bogus"]);
|
|
147
|
+
expect(exitCode).toBe(2);
|
|
148
|
+
expect(String(errorSpy.mock.calls[0]?.[0])).toContain(
|
|
149
|
+
"[Audit Error] --fail-on must be one of: effective, direct, any"
|
|
150
|
+
);
|
|
151
|
+
} finally {
|
|
152
|
+
errorSpy.mockRestore();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
122
156
|
it("runs through the bin wrapper", () => {
|
|
123
157
|
writeFixture({
|
|
124
158
|
login_button: { en: "Login" },
|
package/src/audit/run-audit.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import type { CodegenConfig } from "../codegen/codegen-config-schema.js";
|
|
3
4
|
import { loadConfig } from "../codegen/config.js";
|
|
4
|
-
import { fail } from "../codegen/paths.js";
|
|
5
5
|
import { type FailOnCriterion, runAuditFromConfig } from "./audit-dictionaries.js";
|
|
6
6
|
|
|
7
7
|
const FAIL_ON_VALUES = new Set<FailOnCriterion>(["effective", "direct", "any"]);
|
|
@@ -30,7 +30,7 @@ export function parseAuditArgs(argv: string[]): AuditCliOptions {
|
|
|
30
30
|
if (failOnArgIndex >= 0) {
|
|
31
31
|
const value = argv[failOnArgIndex + 1];
|
|
32
32
|
if (!value || !FAIL_ON_VALUES.has(value as FailOnCriterion)) {
|
|
33
|
-
|
|
33
|
+
throw new Error(`[Audit Error] --fail-on must be one of: effective, direct, any`);
|
|
34
34
|
}
|
|
35
35
|
failOn = value as FailOnCriterion;
|
|
36
36
|
}
|
|
@@ -46,20 +46,28 @@ export function parseAuditArgs(argv: string[]): AuditCliOptions {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export async function runAuditCli(argv = process.argv.slice(2)): Promise<number> {
|
|
49
|
-
|
|
49
|
+
let options: AuditCliOptions;
|
|
50
|
+
let config: CodegenConfig;
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
try {
|
|
53
|
+
options = parseAuditArgs(argv);
|
|
54
|
+
|
|
55
|
+
if (!fs.existsSync(options.configPath)) {
|
|
56
|
+
console.error(`[Audit Error] Config file not found: ${options.configPath}`);
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
config = loadConfig(options.configPath);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
53
63
|
return 2;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
const projectRoot = path.dirname(options.configPath);
|
|
57
|
-
const config = loadConfig(options.configPath);
|
|
58
67
|
|
|
59
68
|
const { report, exitCode } = await runAuditFromConfig({
|
|
60
69
|
projectRoot,
|
|
61
70
|
config,
|
|
62
|
-
configPath: options.configPath,
|
|
63
71
|
treatEmptyAsMissing: options.treatEmptyAsMissing,
|
|
64
72
|
...(options.failOn !== undefined ? { failOn: options.failOn } : {}),
|
|
65
73
|
});
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
IDENTIFIER_NAME_PATTERN,
|
|
5
|
+
IDENTIFIER_NAME_REQUIREMENT,
|
|
6
|
+
SUPPORTED_IMPORT_EXTENSIONS,
|
|
7
|
+
} from "./constants.js";
|
|
8
|
+
import { getDeliveryArtifactsStructureIssues } from "./delivery-artifacts.js";
|
|
3
9
|
|
|
4
10
|
const localeFallbackSchema = z.record(z.string(), z.union([z.string(), z.null()]));
|
|
11
|
+
const deliveryArtifactsSchema = z.record(z.string(), z.array(z.string().min(1)));
|
|
12
|
+
|
|
13
|
+
export const DELIVERY_MODES = ["canonical", "split-by-locale", "custom"] as const;
|
|
14
|
+
export type DeliveryMode = (typeof DELIVERY_MODES)[number];
|
|
5
15
|
|
|
6
16
|
const codegenConfigShape = {
|
|
7
17
|
dictionary: z.string().min(1).optional(),
|
|
@@ -20,6 +30,9 @@ const codegenConfigShape = {
|
|
|
20
30
|
localeFallbackConstName: z.string().min(1).optional(),
|
|
21
31
|
localeFallback: localeFallbackSchema.optional(),
|
|
22
32
|
factoryName: z.string().min(1).optional(),
|
|
33
|
+
delivery: z.enum(DELIVERY_MODES).optional().default("canonical"),
|
|
34
|
+
deliveryArtifacts: deliveryArtifactsSchema.optional(),
|
|
35
|
+
deliveryOutput: z.string().min(1).optional(),
|
|
23
36
|
};
|
|
24
37
|
|
|
25
38
|
export const codegenConfigKeys = Object.keys(
|
|
@@ -39,10 +52,64 @@ export const codegenConfigSchema = z
|
|
|
39
52
|
message: 'Specify exactly one of "dictionary" or "namespaces".',
|
|
40
53
|
});
|
|
41
54
|
}
|
|
55
|
+
|
|
56
|
+
if (config.namespaces) {
|
|
57
|
+
for (const namespace of Object.keys(config.namespaces)) {
|
|
58
|
+
if (!IDENTIFIER_NAME_PATTERN.test(namespace)) {
|
|
59
|
+
ctx.addIssue({
|
|
60
|
+
code: "custom",
|
|
61
|
+
path: ["namespaces", namespace],
|
|
62
|
+
message: `Invalid namespace name "${namespace}" (${IDENTIFIER_NAME_REQUIREMENT}).`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
config.defaultNamespace !== undefined &&
|
|
70
|
+
!IDENTIFIER_NAME_PATTERN.test(config.defaultNamespace)
|
|
71
|
+
) {
|
|
72
|
+
ctx.addIssue({
|
|
73
|
+
code: "custom",
|
|
74
|
+
path: ["defaultNamespace"],
|
|
75
|
+
message: `Invalid namespace name "${config.defaultNamespace}" (${IDENTIFIER_NAME_REQUIREMENT}).`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (config.delivery === "custom") {
|
|
80
|
+
if (!config.deliveryArtifacts) {
|
|
81
|
+
ctx.addIssue({
|
|
82
|
+
code: "custom",
|
|
83
|
+
path: ["deliveryArtifacts"],
|
|
84
|
+
message: 'deliveryArtifacts is required when delivery is "custom".',
|
|
85
|
+
});
|
|
86
|
+
} else {
|
|
87
|
+
for (const issue of getDeliveryArtifactsStructureIssues(config.deliveryArtifacts)) {
|
|
88
|
+
ctx.addIssue({
|
|
89
|
+
code: "custom",
|
|
90
|
+
path: issue.path,
|
|
91
|
+
message: issue.message,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} else if (config.deliveryArtifacts !== undefined) {
|
|
96
|
+
ctx.addIssue({
|
|
97
|
+
code: "custom",
|
|
98
|
+
path: ["deliveryArtifacts"],
|
|
99
|
+
message: 'deliveryArtifacts is only allowed when delivery is "custom".',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
42
102
|
});
|
|
43
103
|
|
|
104
|
+
export type CodegenConfigInput = z.input<typeof codegenConfigSchema>;
|
|
44
105
|
export type CodegenConfig = z.infer<typeof codegenConfigSchema>;
|
|
45
106
|
|
|
107
|
+
export function resolveDeliveryOutputDir(
|
|
108
|
+
config: Pick<CodegenConfig, "typesOutput" | "deliveryOutput">
|
|
109
|
+
): string {
|
|
110
|
+
return config.deliveryOutput ?? path.dirname(config.typesOutput);
|
|
111
|
+
}
|
|
112
|
+
|
|
46
113
|
export function formatCodegenConfigIssues(error: z.ZodError): string {
|
|
47
114
|
const issueLines = error.issues.map((issue) => {
|
|
48
115
|
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { afterEach, describe, expect, it
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
5
|
import { codegenConfigKeys } from "./codegen-config-schema.js";
|
|
6
|
-
import { loadConfig } from "./config.js";
|
|
6
|
+
import { loadConfig, resolveDeliveryOutputDir, resolveLoadOnInit } from "./config.js";
|
|
7
|
+
import { resolveImportExtension } from "./paths.js";
|
|
7
8
|
|
|
8
9
|
function writeConfig(dir: string, config: Record<string, unknown>) {
|
|
9
10
|
const configPath = join(dir, "i18n.codegen.json");
|
|
@@ -35,72 +36,206 @@ describe("loadConfig", () => {
|
|
|
35
36
|
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
36
37
|
const configPath = writeConfig(tempDir, validMultiConfig);
|
|
37
38
|
|
|
38
|
-
expect(loadConfig(configPath)).toEqual(
|
|
39
|
+
expect(loadConfig(configPath)).toEqual({
|
|
40
|
+
...validMultiConfig,
|
|
41
|
+
delivery: "canonical",
|
|
42
|
+
});
|
|
39
43
|
});
|
|
40
44
|
|
|
41
|
-
it("
|
|
45
|
+
it("throws on invalid config JSON", () => {
|
|
46
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
47
|
+
const configPath = join(tempDir, "i18n.codegen.json");
|
|
48
|
+
writeFileSync(configPath, "{ not json");
|
|
49
|
+
|
|
50
|
+
expect(() => loadConfig(configPath)).toThrow(
|
|
51
|
+
`[Codegen Error] Failed to parse config JSON (${configPath})`
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("throws on unknown top-level keys and reports allowed keys", () => {
|
|
42
56
|
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
43
57
|
const configPath = writeConfig(tempDir, {
|
|
44
58
|
...validMultiConfig,
|
|
45
59
|
typoKey: true,
|
|
46
60
|
});
|
|
47
61
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
const act = () => loadConfig(configPath);
|
|
63
|
+
expect(act).toThrow("Invalid i18n.codegen.json");
|
|
64
|
+
expect(act).toThrow("typoKey");
|
|
65
|
+
expect(act).toThrow(`Allowed keys: ${codegenConfigKeys.join(", ")}`);
|
|
66
|
+
});
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
it("throws when both dictionary and namespaces are present", () => {
|
|
69
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
70
|
+
const configPath = writeConfig(tempDir, {
|
|
71
|
+
...validMultiConfig,
|
|
72
|
+
dictionary: "translations/default.json",
|
|
73
|
+
});
|
|
55
74
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
expect(() => loadConfig(configPath)).toThrow(
|
|
76
|
+
'Specify exactly one of "dictionary" or "namespaces".'
|
|
77
|
+
);
|
|
78
|
+
});
|
|
60
79
|
|
|
61
|
-
|
|
62
|
-
|
|
80
|
+
it("throws when required fields are missing", () => {
|
|
81
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
82
|
+
const configPath = writeConfig(tempDir, {
|
|
83
|
+
namespaces: validMultiConfig.namespaces,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const act = () => loadConfig(configPath);
|
|
87
|
+
expect(act).toThrow("typesOutput");
|
|
88
|
+
expect(act).toThrow("Allowed keys:");
|
|
63
89
|
});
|
|
64
90
|
|
|
65
|
-
it("
|
|
91
|
+
it("throws when a namespace name is not a valid identifier", () => {
|
|
66
92
|
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
67
93
|
const configPath = writeConfig(tempDir, {
|
|
68
94
|
...validMultiConfig,
|
|
69
|
-
|
|
95
|
+
namespaces: {
|
|
96
|
+
default: "translations/default.json",
|
|
97
|
+
"user-area": "translations/user-area.json",
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(() => loadConfig(configPath)).toThrow(
|
|
102
|
+
'Invalid namespace name "user-area" (allowed: letters, digits, underscore; must not start with a digit).'
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("throws when defaultNamespace is not a valid identifier", () => {
|
|
107
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
108
|
+
const configPath = writeConfig(tempDir, {
|
|
109
|
+
...validMultiConfig,
|
|
110
|
+
defaultNamespace: "1default",
|
|
70
111
|
});
|
|
71
112
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
113
|
+
expect(() => loadConfig(configPath)).toThrow(
|
|
114
|
+
'defaultNamespace: Invalid namespace name "1default"'
|
|
115
|
+
);
|
|
116
|
+
});
|
|
76
117
|
|
|
77
|
-
|
|
118
|
+
it("accepts deliveryOutput for custom json artifact directory", () => {
|
|
119
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
120
|
+
const configPath = writeConfig(tempDir, {
|
|
121
|
+
...validMultiConfig,
|
|
122
|
+
deliveryOutput: "public/i18n",
|
|
123
|
+
});
|
|
78
124
|
|
|
79
|
-
|
|
80
|
-
|
|
125
|
+
expect(loadConfig(configPath)).toMatchObject({
|
|
126
|
+
deliveryOutput: "public/i18n",
|
|
127
|
+
delivery: "canonical",
|
|
128
|
+
});
|
|
129
|
+
});
|
|
81
130
|
|
|
82
|
-
|
|
83
|
-
|
|
131
|
+
it("resolveDeliveryOutputDir defaults to dirname(typesOutput)", () => {
|
|
132
|
+
expect(
|
|
133
|
+
resolveDeliveryOutputDir({
|
|
134
|
+
typesOutput: "generated/i18n-types.generated.ts",
|
|
135
|
+
})
|
|
136
|
+
).toBe("generated");
|
|
137
|
+
expect(
|
|
138
|
+
resolveDeliveryOutputDir({
|
|
139
|
+
typesOutput: "generated/i18n-types.generated.ts",
|
|
140
|
+
deliveryOutput: "public/i18n",
|
|
141
|
+
})
|
|
142
|
+
).toBe("public/i18n");
|
|
84
143
|
});
|
|
85
144
|
|
|
86
|
-
it("
|
|
145
|
+
it("accepts custom delivery with deliveryArtifacts", () => {
|
|
87
146
|
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
88
147
|
const configPath = writeConfig(tempDir, {
|
|
89
|
-
|
|
148
|
+
...validMultiConfig,
|
|
149
|
+
delivery: "custom",
|
|
150
|
+
deliveryArtifacts: {
|
|
151
|
+
eu: ["it", "fr"],
|
|
152
|
+
us: ["en-US"],
|
|
153
|
+
},
|
|
154
|
+
localeFallback: {
|
|
155
|
+
"en-US": null,
|
|
156
|
+
it: "en-US",
|
|
157
|
+
fr: null,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(loadConfig(configPath)).toMatchObject({
|
|
162
|
+
delivery: "custom",
|
|
163
|
+
deliveryArtifacts: {
|
|
164
|
+
eu: ["it", "fr"],
|
|
165
|
+
us: ["en-US"],
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("throws when delivery is custom without deliveryArtifacts", () => {
|
|
171
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
172
|
+
const configPath = writeConfig(tempDir, {
|
|
173
|
+
...validMultiConfig,
|
|
174
|
+
delivery: "custom",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(() => loadConfig(configPath)).toThrow(
|
|
178
|
+
'deliveryArtifacts is required when delivery is "custom"'
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("throws when deliveryArtifacts is set for non-custom delivery", () => {
|
|
183
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
184
|
+
const configPath = writeConfig(tempDir, {
|
|
185
|
+
...validMultiConfig,
|
|
186
|
+
delivery: "split-by-locale",
|
|
187
|
+
deliveryArtifacts: {
|
|
188
|
+
eu: ["it"],
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(() => loadConfig(configPath)).toThrow(
|
|
193
|
+
'deliveryArtifacts is only allowed when delivery is "custom"'
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("throws when deliveryArtifacts has duplicate locales across areas", () => {
|
|
198
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
199
|
+
const configPath = writeConfig(tempDir, {
|
|
200
|
+
...validMultiConfig,
|
|
201
|
+
delivery: "custom",
|
|
202
|
+
deliveryArtifacts: {
|
|
203
|
+
eu: ["it", "fr"],
|
|
204
|
+
us: ["fr"],
|
|
205
|
+
},
|
|
90
206
|
});
|
|
91
207
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
208
|
+
expect(() => loadConfig(configPath)).toThrow('Locale "fr" appears in both "eu" and "us"');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("resolveLoadOnInit", () => {
|
|
213
|
+
const entries = [
|
|
214
|
+
{ namespace: "default", filePath: "translations/default.json" },
|
|
215
|
+
{ namespace: "billing", filePath: "translations/billing.json" },
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
it("throws when loadOnInit is used in single mode", () => {
|
|
219
|
+
const config = { ...validMultiConfig, delivery: "canonical" as const, loadOnInit: ["default"] };
|
|
96
220
|
|
|
97
|
-
expect(() =>
|
|
221
|
+
expect(() => resolveLoadOnInit(config, entries, true)).toThrow(
|
|
222
|
+
'[Codegen Error] "loadOnInit" is only supported in multi mode (namespaces config).'
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("throws when loadOnInit references an unknown namespace", () => {
|
|
227
|
+
const config = { ...validMultiConfig, delivery: "canonical" as const, loadOnInit: ["missing"] };
|
|
98
228
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
229
|
+
expect(() => resolveLoadOnInit(config, entries, false)).toThrow(
|
|
230
|
+
'[Codegen Error] loadOnInit: namespace "missing" is not defined in namespaces config.'
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
102
234
|
|
|
103
|
-
|
|
104
|
-
|
|
235
|
+
describe("resolveImportExtension", () => {
|
|
236
|
+
it("throws on unsupported import extensions", () => {
|
|
237
|
+
expect(() => resolveImportExtension({ importExtension: ".mjs" as never })).toThrow(
|
|
238
|
+
'[Codegen Error] importExtension must be "none", ".ts", or ".js", got ".mjs".'
|
|
239
|
+
);
|
|
105
240
|
});
|
|
106
241
|
});
|
package/src/codegen/config.ts
CHANGED
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
codegenConfigSchema,
|
|
6
6
|
formatCodegenConfigIssues,
|
|
7
7
|
} from "./codegen-config-schema.js";
|
|
8
|
-
import { fail } from "./paths.js";
|
|
9
8
|
|
|
9
|
+
export { resolveDeliveryOutputDir } from "./codegen-config-schema.js";
|
|
10
|
+
|
|
11
|
+
/** Parses and validates `i18n.codegen.json`; first step of the codegen pipeline. */
|
|
10
12
|
export function loadConfig(configPath: string): CodegenConfig {
|
|
11
13
|
let raw: unknown;
|
|
12
14
|
|
|
@@ -14,17 +16,18 @@ export function loadConfig(configPath: string): CodegenConfig {
|
|
|
14
16
|
raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
15
17
|
} catch (error) {
|
|
16
18
|
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
-
|
|
19
|
+
throw new Error(`[Codegen Error] Failed to parse config JSON (${configPath}): ${message}`);
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
const result = codegenConfigSchema.safeParse(raw);
|
|
21
23
|
if (!result.success) {
|
|
22
|
-
|
|
24
|
+
throw new Error(formatCodegenConfigIssues(result.error));
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
return result.data;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/** Maps config to namespace entries (`dictionary` single vs `namespaces` multi). */
|
|
28
31
|
export function resolveNamespaces(config: CodegenConfig): NamespaceEntry[] {
|
|
29
32
|
const hasDictionary = Boolean(config.dictionary);
|
|
30
33
|
const hasNamespaces = Boolean(config.namespaces);
|
|
@@ -50,6 +53,10 @@ export function resolveNamespaces(config: CodegenConfig): NamespaceEntry[] {
|
|
|
50
53
|
}));
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Splits namespaces into eager (`loadOnInit`) and lazy sets.
|
|
58
|
+
* Drives `InitialSchema`, `defaultDictionary`, and `namespace-loaders.generated.ts`.
|
|
59
|
+
*/
|
|
53
60
|
export function resolveLoadOnInit(
|
|
54
61
|
config: CodegenConfig,
|
|
55
62
|
entries: NamespaceEntry[],
|
|
@@ -57,7 +64,9 @@ export function resolveLoadOnInit(
|
|
|
57
64
|
): LoadOnInitResolution {
|
|
58
65
|
if (isSingle) {
|
|
59
66
|
if (config.loadOnInit) {
|
|
60
|
-
|
|
67
|
+
throw new Error(
|
|
68
|
+
'[Codegen Error] "loadOnInit" is only supported in multi mode (namespaces config).'
|
|
69
|
+
);
|
|
61
70
|
}
|
|
62
71
|
const all = new Set(entries.map((entry) => entry.namespace));
|
|
63
72
|
return { loadOnInitSet: all, lazyEntries: [], hasLazy: false };
|
|
@@ -70,7 +79,7 @@ export function resolveLoadOnInit(
|
|
|
70
79
|
|
|
71
80
|
for (const namespace of loadOnInitSet) {
|
|
72
81
|
if (!allNamespaces.has(namespace)) {
|
|
73
|
-
|
|
82
|
+
throw new Error(
|
|
74
83
|
`[Codegen Error] loadOnInit: namespace "${namespace}" is not defined in namespaces config.`
|
|
75
84
|
);
|
|
76
85
|
}
|
package/src/codegen/constants.ts
CHANGED
|
@@ -1 +1,9 @@
|
|
|
1
1
|
export const SUPPORTED_IMPORT_EXTENSIONS = ["none", ".ts", ".js"] as const;
|
|
2
|
+
export type SupportedImportExtension = (typeof SUPPORTED_IMPORT_EXTENSIONS)[number];
|
|
3
|
+
|
|
4
|
+
// Translation keys and namespace names become TypeScript identifiers and
|
|
5
|
+
// template-literal type segments in the generated code, so they must be valid
|
|
6
|
+
// identifiers (e.g. "app.title" would emit the broken type `"app.title:..."`).
|
|
7
|
+
export const IDENTIFIER_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
8
|
+
export const IDENTIFIER_NAME_REQUIREMENT =
|
|
9
|
+
"allowed: letters, digits, underscore; must not start with a digit";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
formatLocaleDeliveryAreaBlock,
|
|
4
|
+
getDeliveryAreaNames,
|
|
5
|
+
getDeliveryArtifactsIssues,
|
|
6
|
+
getDeliveryArtifactsPartitionIssues,
|
|
7
|
+
getDeliveryArtifactsStructureIssues,
|
|
8
|
+
getLocaleDeliveryAreaMap,
|
|
9
|
+
} from "./delivery-artifacts.js";
|
|
10
|
+
|
|
11
|
+
describe("delivery-artifacts", () => {
|
|
12
|
+
const validArtifacts = {
|
|
13
|
+
eu: ["it", "fr"],
|
|
14
|
+
us: ["en-US"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
it("accepts a valid structure", () => {
|
|
18
|
+
expect(getDeliveryArtifactsStructureIssues(validArtifacts)).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("rejects invalid area names", () => {
|
|
22
|
+
const issues = getDeliveryArtifactsStructureIssues({
|
|
23
|
+
"1eu": ["it"],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(issues).toHaveLength(1);
|
|
27
|
+
expect(issues[0]?.message).toContain('Invalid delivery area name "1eu"');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects empty area locale lists", () => {
|
|
31
|
+
const issues = getDeliveryArtifactsStructureIssues({
|
|
32
|
+
eu: [],
|
|
33
|
+
us: ["en-US"],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(issues).toHaveLength(1);
|
|
37
|
+
expect(issues[0]?.message).toContain('Delivery area "eu" must include at least one locale');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects duplicate locales across areas", () => {
|
|
41
|
+
const issues = getDeliveryArtifactsStructureIssues({
|
|
42
|
+
eu: ["it", "fr"],
|
|
43
|
+
us: ["fr", "en-US"],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(issues).toHaveLength(1);
|
|
47
|
+
expect(issues[0]?.message).toContain('Locale "fr" appears in both "eu" and "us"');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("requires an exhaustive partition of request locales", () => {
|
|
51
|
+
const requestLocales = new Set(["it", "fr", "en-US"]);
|
|
52
|
+
|
|
53
|
+
expect(getDeliveryArtifactsPartitionIssues(validArtifacts, requestLocales)).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reports missing request locales", () => {
|
|
57
|
+
const issues = getDeliveryArtifactsPartitionIssues(
|
|
58
|
+
{ eu: ["it"] },
|
|
59
|
+
new Set(["it", "fr", "en-US"])
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(issues).toHaveLength(1);
|
|
63
|
+
expect(issues[0]?.message).toContain("missing locales");
|
|
64
|
+
expect(issues[0]?.message).toContain("fr");
|
|
65
|
+
expect(issues[0]?.message).toContain("en-US");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("reports excess locales not in request set", () => {
|
|
69
|
+
const issues = getDeliveryArtifactsPartitionIssues(validArtifacts, new Set(["it"]));
|
|
70
|
+
|
|
71
|
+
expect(issues).toHaveLength(1);
|
|
72
|
+
expect(issues[0]?.message).toContain("includes locales not required");
|
|
73
|
+
expect(issues[0]?.message).toContain("fr");
|
|
74
|
+
expect(issues[0]?.message).toContain("en-US");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("getDeliveryArtifactsIssues merges structure and partition errors", () => {
|
|
78
|
+
const issues = getDeliveryArtifactsIssues(
|
|
79
|
+
{
|
|
80
|
+
eu: [],
|
|
81
|
+
"1bad": ["it"],
|
|
82
|
+
us: ["it"],
|
|
83
|
+
},
|
|
84
|
+
new Set(["it", "en-US"])
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(issues.map((issue) => issue.message)).toEqual(
|
|
88
|
+
expect.arrayContaining([
|
|
89
|
+
expect.stringContaining('Delivery area "eu" must include at least one locale'),
|
|
90
|
+
expect.stringContaining('Invalid delivery area name "1bad"'),
|
|
91
|
+
expect.stringContaining('Locale "it" appears in both "1bad" and "us"'),
|
|
92
|
+
expect.stringContaining("missing locales"),
|
|
93
|
+
expect.stringContaining("en-US"),
|
|
94
|
+
])
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns sorted delivery area names", () => {
|
|
99
|
+
expect(
|
|
100
|
+
getDeliveryAreaNames({
|
|
101
|
+
us: ["en-US"],
|
|
102
|
+
eu: ["it", "fr"],
|
|
103
|
+
})
|
|
104
|
+
).toEqual(["eu", "us"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("builds locale to delivery area map", () => {
|
|
108
|
+
expect(
|
|
109
|
+
getLocaleDeliveryAreaMap({
|
|
110
|
+
amer: ["en-US", "es-AR"],
|
|
111
|
+
eu: ["en", "it", "fr", "es"],
|
|
112
|
+
})
|
|
113
|
+
).toEqual({
|
|
114
|
+
en: "eu",
|
|
115
|
+
"en-US": "amer",
|
|
116
|
+
"es-AR": "amer",
|
|
117
|
+
es: "eu",
|
|
118
|
+
fr: "eu",
|
|
119
|
+
it: "eu",
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("formats locale delivery area block for generated types", () => {
|
|
124
|
+
expect(
|
|
125
|
+
formatLocaleDeliveryAreaBlock(
|
|
126
|
+
{
|
|
127
|
+
eu: ["it", "fr"],
|
|
128
|
+
us: ["en-US"],
|
|
129
|
+
},
|
|
130
|
+
"LOCALE_DELIVERY_AREA",
|
|
131
|
+
"AppLocale",
|
|
132
|
+
"AppDeliveryArea"
|
|
133
|
+
)
|
|
134
|
+
).toBe(
|
|
135
|
+
"export const LOCALE_DELIVERY_AREA = {\n" +
|
|
136
|
+
' "en-US": "us",\n' +
|
|
137
|
+
' "fr": "eu",\n' +
|
|
138
|
+
' "it": "eu",\n' +
|
|
139
|
+
"} as const satisfies Record<AppLocale, AppDeliveryArea>;\n\n"
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|