@xndrjs/i18n 0.3.2 → 0.3.3
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 +7 -7
- package/package.json +1 -6
- 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/README.md
CHANGED
|
@@ -21,14 +21,14 @@ The core idea: your ICU strings live in local JSON files that act as **type-safe
|
|
|
21
21
|
### Install
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npm install @xndrjs/i18n
|
|
25
|
-
|
|
26
|
-
npm install zod
|
|
24
|
+
npm install @xndrjs/i18n zod
|
|
25
|
+
npm install -D tsx
|
|
27
26
|
```
|
|
28
27
|
|
|
29
|
-
`tsx`
|
|
28
|
+
`tsx` and `zod` are **peer dependencies** of `@xndrjs/i18n`:
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
- **`tsx`** — required by `xndrjs-i18n-codegen` and `xndrjs-i18n-audit` (CLIs run TypeScript directly). Install as a **devDependency** — only needed at build/CI time.
|
|
31
|
+
- **`zod`** — required by codegen/audit config validation (`i18n.codegen.json`) and by `@xndrjs/i18n/validation` when using `dictionarySchemaOutput` and `validateExternalDictionary()`. Use a **devDependency** if validation runs only at build time; a regular **dependency** if you validate CMS payloads in production.
|
|
32
32
|
|
|
33
33
|
### Quick setup (single file)
|
|
34
34
|
|
|
@@ -533,7 +533,7 @@ Enable validation by adding `dictionarySchemaOutput` to `i18n/i18n.codegen.json`
|
|
|
533
533
|
}
|
|
534
534
|
```
|
|
535
535
|
|
|
536
|
-
Add `zod` as a dependency in the consumer app (
|
|
536
|
+
Add `zod` as a dependency in the consumer app (required peer of `@xndrjs/i18n`).
|
|
537
537
|
|
|
538
538
|
Codegen emits `DICTIONARY_SPEC` and `validateExternalDictionary()`. Validation runs in two phases:
|
|
539
539
|
|
|
@@ -641,5 +641,5 @@ pnpm run demo:multi # tsx multi/src/index.ts
|
|
|
641
641
|
- **TypeScript 6** (`strict`, `resolveJsonModule`, `moduleResolution: "bundler"`)
|
|
642
642
|
- **[intl-messageformat](https://www.npmjs.com/package/intl-messageformat)** — runtime ICU formatting
|
|
643
643
|
- **[@formatjs/icu-messageformat-parser](https://www.npmjs.com/package/@formatjs/icu-messageformat-parser)** — build-time AST parsing
|
|
644
|
-
- **[zod](https://www.npmjs.com/package/zod)** —
|
|
644
|
+
- **[zod](https://www.npmjs.com/package/zod)** — peer dependency (config validation, external dictionary validation)
|
|
645
645
|
- **[tsx](https://www.npmjs.com/package/tsx)** — runs TypeScript scripts directly
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xndrjs/i18n",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Compiler-first, type-safe ICU MessageFormat i18n with runtime dictionary override from external sources.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,11 +45,6 @@
|
|
|
45
45
|
"tsx": ">=4",
|
|
46
46
|
"zod": "^4.0.0"
|
|
47
47
|
},
|
|
48
|
-
"peerDependenciesMeta": {
|
|
49
|
-
"zod": {
|
|
50
|
-
"optional": true
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
48
|
"devDependencies": {
|
|
54
49
|
"tsup": "^8.5.1",
|
|
55
50
|
"tsx": "^4.22.4",
|
|
@@ -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;
|