@xndrjs/i18n 0.3.2 → 0.3.3-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/package.json +1 -1
- package/src/audit/audit-dictionaries.ts +6 -3
- package/src/audit/run-audit.ts +7 -2
- package/src/codegen/codegen-config-schema.ts +58 -0
- package/src/codegen/config.test.ts +106 -0
- package/src/codegen/config.ts +21 -3
- package/src/codegen/constants.ts +1 -0
- package/src/codegen/generate-i18n-types.test.ts +4 -1
- package/src/codegen/paths.ts +3 -2
- package/src/codegen/types.ts +2 -21
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import type {
|
|
2
|
+
import type { DictionaryJson } from "../codegen/types.js";
|
|
3
3
|
import { buildRequiredLocales, enrichLocaleFallback } from "../codegen/locale-policy.js";
|
|
4
4
|
import { resolveLocaleTemplate } from "../resolve-locale.js";
|
|
5
|
+
import { CodegenConfig } from "../codegen/codegen-config-schema.js";
|
|
5
6
|
|
|
6
7
|
export type FailOnCriterion = "effective" | "direct" | "any";
|
|
7
8
|
|
|
@@ -105,7 +106,7 @@ export function auditDictionaries(options: AuditDictionariesOptions): I18nAuditR
|
|
|
105
106
|
|
|
106
107
|
return {
|
|
107
108
|
requiredLocales,
|
|
108
|
-
localeFallback: localeFallbackForAudit,
|
|
109
|
+
...(localeFallbackForAudit !== undefined ? { localeFallback: localeFallbackForAudit } : {}),
|
|
109
110
|
summary,
|
|
110
111
|
missingDirectByLocale,
|
|
111
112
|
missingEffectiveByLocale,
|
|
@@ -162,7 +163,9 @@ export async function runAuditFromConfig(options: RunAuditOptions): Promise<RunA
|
|
|
162
163
|
const report = auditDictionaries({
|
|
163
164
|
namespaces,
|
|
164
165
|
config,
|
|
165
|
-
|
|
166
|
+
...(options.treatEmptyAsMissing !== undefined
|
|
167
|
+
? { treatEmptyAsMissing: options.treatEmptyAsMissing }
|
|
168
|
+
: {}),
|
|
166
169
|
});
|
|
167
170
|
|
|
168
171
|
const exitCode =
|
package/src/audit/run-audit.ts
CHANGED
|
@@ -37,7 +37,12 @@ export function parseAuditArgs(argv: string[]): AuditCliOptions {
|
|
|
37
37
|
|
|
38
38
|
const treatEmptyAsMissing = !argv.includes("--allow-empty");
|
|
39
39
|
|
|
40
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
configPath,
|
|
42
|
+
treatEmptyAsMissing,
|
|
43
|
+
...(outPath !== undefined ? { outPath } : {}),
|
|
44
|
+
...(failOn !== undefined ? { failOn } : {}),
|
|
45
|
+
};
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export async function runAuditCli(argv = process.argv.slice(2)): Promise<number> {
|
|
@@ -56,7 +61,7 @@ export async function runAuditCli(argv = process.argv.slice(2)): Promise<number>
|
|
|
56
61
|
config,
|
|
57
62
|
configPath: options.configPath,
|
|
58
63
|
treatEmptyAsMissing: options.treatEmptyAsMissing,
|
|
59
|
-
failOn: options.failOn,
|
|
64
|
+
...(options.failOn !== undefined ? { failOn: options.failOn } : {}),
|
|
60
65
|
});
|
|
61
66
|
|
|
62
67
|
const json = `${JSON.stringify(report, null, 2)}\n`;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SUPPORTED_IMPORT_EXTENSIONS } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
const localeFallbackSchema = z.record(z.string(), z.union([z.string(), z.null()]));
|
|
5
|
+
|
|
6
|
+
const codegenConfigShape = {
|
|
7
|
+
dictionary: z.string().min(1).optional(),
|
|
8
|
+
namespaces: z.record(z.string(), z.string().min(1)).optional(),
|
|
9
|
+
defaultNamespace: z.string().min(1).optional(),
|
|
10
|
+
typesOutput: z.string().min(1),
|
|
11
|
+
dictionaryOutput: z.string().min(1),
|
|
12
|
+
instanceOutput: z.string().min(1),
|
|
13
|
+
dictionarySchemaOutput: z.string().min(1).optional(),
|
|
14
|
+
loadOnInit: z.array(z.string().min(1)).optional(),
|
|
15
|
+
namespaceLoadersOutput: z.string().min(1).optional(),
|
|
16
|
+
importExtension: z.enum(SUPPORTED_IMPORT_EXTENSIONS).optional(),
|
|
17
|
+
paramsTypeName: z.string().min(1),
|
|
18
|
+
schemaTypeName: z.string().min(1),
|
|
19
|
+
localeTypeName: z.string().min(1).optional(),
|
|
20
|
+
localeFallbackConstName: z.string().min(1).optional(),
|
|
21
|
+
localeFallback: localeFallbackSchema.optional(),
|
|
22
|
+
factoryName: z.string().min(1).optional(),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const codegenConfigKeys = Object.keys(
|
|
26
|
+
codegenConfigShape
|
|
27
|
+
) as (keyof typeof codegenConfigShape)[];
|
|
28
|
+
|
|
29
|
+
export const codegenConfigSchema = z
|
|
30
|
+
.object(codegenConfigShape)
|
|
31
|
+
.strict()
|
|
32
|
+
.superRefine((config, ctx) => {
|
|
33
|
+
const hasDictionary = config.dictionary !== undefined;
|
|
34
|
+
const hasNamespaces = config.namespaces !== undefined;
|
|
35
|
+
|
|
36
|
+
if (hasDictionary === hasNamespaces) {
|
|
37
|
+
ctx.addIssue({
|
|
38
|
+
code: "custom",
|
|
39
|
+
message: 'Specify exactly one of "dictionary" or "namespaces".',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type CodegenConfig = z.infer<typeof codegenConfigSchema>;
|
|
45
|
+
|
|
46
|
+
export function formatCodegenConfigIssues(error: z.ZodError): string {
|
|
47
|
+
const issueLines = error.issues.map((issue) => {
|
|
48
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
|
|
49
|
+
return ` - ${path}: ${issue.message}`;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return [
|
|
53
|
+
"[Codegen Error] Invalid i18n.codegen.json:",
|
|
54
|
+
...issueLines,
|
|
55
|
+
"",
|
|
56
|
+
`Allowed keys: ${codegenConfigKeys.join(", ")}`,
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { codegenConfigKeys } from "./codegen-config-schema.js";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
|
|
8
|
+
function writeConfig(dir: string, config: Record<string, unknown>) {
|
|
9
|
+
const configPath = join(dir, "i18n.codegen.json");
|
|
10
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
11
|
+
return configPath;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const validMultiConfig = {
|
|
15
|
+
namespaces: {
|
|
16
|
+
default: "translations/default.json",
|
|
17
|
+
},
|
|
18
|
+
typesOutput: "generated/i18n-types.generated.ts",
|
|
19
|
+
dictionaryOutput: "generated/dictionary.generated.ts",
|
|
20
|
+
instanceOutput: "generated/instance.generated.ts",
|
|
21
|
+
paramsTypeName: "AppParams",
|
|
22
|
+
schemaTypeName: "AppSchema",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("loadConfig", () => {
|
|
26
|
+
let tempDir: string;
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
if (tempDir) {
|
|
30
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("accepts a valid multi-namespace config", () => {
|
|
35
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
36
|
+
const configPath = writeConfig(tempDir, validMultiConfig);
|
|
37
|
+
|
|
38
|
+
expect(loadConfig(configPath)).toEqual(validMultiConfig);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("fails on unknown top-level keys and reports allowed keys", () => {
|
|
42
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
43
|
+
const configPath = writeConfig(tempDir, {
|
|
44
|
+
...validMultiConfig,
|
|
45
|
+
typoKey: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
49
|
+
throw new Error("process.exit");
|
|
50
|
+
}) as typeof process.exit);
|
|
51
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => void 0);
|
|
52
|
+
|
|
53
|
+
expect(() => loadConfig(configPath)).toThrow("process.exit");
|
|
54
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
55
|
+
|
|
56
|
+
const message = String(errorSpy.mock.calls[0]?.[0]);
|
|
57
|
+
expect(message).toContain("Invalid i18n.codegen.json");
|
|
58
|
+
expect(message).toContain("typoKey");
|
|
59
|
+
expect(message).toContain(`Allowed keys: ${codegenConfigKeys.join(", ")}`);
|
|
60
|
+
|
|
61
|
+
exitSpy.mockRestore();
|
|
62
|
+
errorSpy.mockRestore();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("fails when both dictionary and namespaces are present", () => {
|
|
66
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
67
|
+
const configPath = writeConfig(tempDir, {
|
|
68
|
+
...validMultiConfig,
|
|
69
|
+
dictionary: "translations/default.json",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
73
|
+
throw new Error("process.exit");
|
|
74
|
+
}) as typeof process.exit);
|
|
75
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => void 0);
|
|
76
|
+
|
|
77
|
+
expect(() => loadConfig(configPath)).toThrow("process.exit");
|
|
78
|
+
|
|
79
|
+
const message = String(errorSpy.mock.calls[0]?.[0]);
|
|
80
|
+
expect(message).toContain('Specify exactly one of "dictionary" or "namespaces".');
|
|
81
|
+
|
|
82
|
+
exitSpy.mockRestore();
|
|
83
|
+
errorSpy.mockRestore();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("fails when required fields are missing", () => {
|
|
87
|
+
tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-config-"));
|
|
88
|
+
const configPath = writeConfig(tempDir, {
|
|
89
|
+
namespaces: validMultiConfig.namespaces,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
93
|
+
throw new Error("process.exit");
|
|
94
|
+
}) as typeof process.exit);
|
|
95
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => void 0);
|
|
96
|
+
|
|
97
|
+
expect(() => loadConfig(configPath)).toThrow("process.exit");
|
|
98
|
+
|
|
99
|
+
const message = String(errorSpy.mock.calls[0]?.[0]);
|
|
100
|
+
expect(message).toContain("typesOutput");
|
|
101
|
+
expect(message).toContain("Allowed keys:");
|
|
102
|
+
|
|
103
|
+
exitSpy.mockRestore();
|
|
104
|
+
errorSpy.mockRestore();
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/codegen/config.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import type {
|
|
2
|
+
import type { LoadOnInitResolution, NamespaceEntry } from "./types.js";
|
|
3
|
+
import {
|
|
4
|
+
CodegenConfig,
|
|
5
|
+
codegenConfigSchema,
|
|
6
|
+
formatCodegenConfigIssues,
|
|
7
|
+
} from "./codegen-config-schema.js";
|
|
3
8
|
import { fail } from "./paths.js";
|
|
4
9
|
|
|
5
10
|
export function loadConfig(configPath: string): CodegenConfig {
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
let raw: unknown;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
fail(`[Codegen Error] Failed to parse config JSON (${configPath}): ${message}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const result = codegenConfigSchema.safeParse(raw);
|
|
21
|
+
if (!result.success) {
|
|
22
|
+
fail(formatCodegenConfigIssues(result.error));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return result.data;
|
|
8
26
|
}
|
|
9
27
|
|
|
10
28
|
export function resolveNamespaces(config: CodegenConfig): NamespaceEntry[] {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SUPPORTED_IMPORT_EXTENSIONS = ["none", ".ts", ".js"] as const;
|
|
@@ -795,7 +795,10 @@ describe("generate-i18n-types", () => {
|
|
|
795
795
|
|
|
796
796
|
const result = runCodegen(tempDir);
|
|
797
797
|
expect(result.status).not.toBe(0);
|
|
798
|
-
expect(result.stderr).toContain(
|
|
798
|
+
expect(result.stderr).toContain("Invalid i18n.codegen.json");
|
|
799
|
+
expect(result.stderr).toContain("importExtension");
|
|
800
|
+
expect(result.stderr).toContain('one of "none"|".ts"|".js"');
|
|
801
|
+
expect(result.stderr).toContain("Allowed keys:");
|
|
799
802
|
});
|
|
800
803
|
|
|
801
804
|
it("compiles yaml dictionaries to json and generates json imports", () => {
|
package/src/codegen/paths.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import type {
|
|
3
|
-
import { SUPPORTED_IMPORT_EXTENSIONS } from "./
|
|
2
|
+
import type { ImportExtension } from "./types.js";
|
|
3
|
+
import { SUPPORTED_IMPORT_EXTENSIONS } from "./constants.js";
|
|
4
|
+
import { CodegenConfig } from "./codegen-config-schema.js";
|
|
4
5
|
|
|
5
6
|
export const GENERATED_FILE_BANNER = "// Automatically generated code. Do not edit manually.\n";
|
|
6
7
|
export const DEFAULT_IMPORT_EXTENSION: ImportExtension = "none";
|
package/src/codegen/types.ts
CHANGED
|
@@ -1,25 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
export type ImportExtension = (typeof SUPPORTED_IMPORT_EXTENSIONS)[number];
|
|
1
|
+
import type { SUPPORTED_IMPORT_EXTENSIONS } from "./constants.js";
|
|
3
2
|
|
|
4
|
-
export
|
|
5
|
-
dictionary?: string;
|
|
6
|
-
namespaces?: Record<string, string>;
|
|
7
|
-
defaultNamespace?: string;
|
|
8
|
-
typesOutput: string;
|
|
9
|
-
dictionaryOutput: string;
|
|
10
|
-
instanceOutput: string;
|
|
11
|
-
dictionarySchemaOutput?: string;
|
|
12
|
-
loadOnInit?: string[];
|
|
13
|
-
namespaceLoadersOutput?: string;
|
|
14
|
-
/** Extension used in relative imports between generated modules. Default: "none" */
|
|
15
|
-
importExtension?: ImportExtension;
|
|
16
|
-
paramsTypeName: string;
|
|
17
|
-
schemaTypeName: string;
|
|
18
|
-
localeTypeName?: string;
|
|
19
|
-
localeFallbackConstName?: string;
|
|
20
|
-
localeFallback?: Record<string, string | null>;
|
|
21
|
-
factoryName?: string;
|
|
22
|
-
}
|
|
3
|
+
export type ImportExtension = (typeof SUPPORTED_IMPORT_EXTENSIONS)[number];
|
|
23
4
|
|
|
24
5
|
export interface NamespaceEntry {
|
|
25
6
|
namespace: string;
|