@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 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 tsx
25
- # optional external dictionary validation
26
- npm install zod
24
+ npm install @xndrjs/i18n zod
25
+ npm install -D tsx
27
26
  ```
28
27
 
29
- `tsx` is a **peer dependency** required by `xndrjs-i18n-codegen`: the CLI runs the TypeScript codegen script directly (no precompiled JS bundle). Add it to your app or monorepo root.
28
+ `tsx` and `zod` are **peer dependencies** of `@xndrjs/i18n`:
30
29
 
31
- `zod` is optional only needed when you use `dictionarySchemaOutput` and the generated `validateExternalDictionary()` helpers.
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 (optional peer of `@xndrjs/i18n`).
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)** — optional peer for external dictionary validation
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.2",
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 { CodegenConfig, DictionaryJson } from "../codegen/types.js";
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
- treatEmptyAsMissing: options.treatEmptyAsMissing,
166
+ ...(options.treatEmptyAsMissing !== undefined
167
+ ? { treatEmptyAsMissing: options.treatEmptyAsMissing }
168
+ : {}),
166
169
  });
167
170
 
168
171
  const exitCode =
@@ -37,7 +37,12 @@ export function parseAuditArgs(argv: string[]): AuditCliOptions {
37
37
 
38
38
  const treatEmptyAsMissing = !argv.includes("--allow-empty");
39
39
 
40
- return { configPath, outPath, failOn, treatEmptyAsMissing };
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
+ });
@@ -1,10 +1,28 @@
1
1
  import fs from "node:fs";
2
- import type { CodegenConfig, LoadOnInitResolution, NamespaceEntry } from "./types.js";
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
- const raw = fs.readFileSync(configPath, "utf8");
7
- return JSON.parse(raw) as CodegenConfig;
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('importExtension must be "none", ".ts", or ".js"');
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", () => {
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
- import type { CodegenConfig, ImportExtension } from "./types.js";
3
- import { SUPPORTED_IMPORT_EXTENSIONS } from "./types.js";
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";
@@ -1,25 +1,6 @@
1
- export const SUPPORTED_IMPORT_EXTENSIONS = ["none", ".ts", ".js"] as const;
2
- export type ImportExtension = (typeof SUPPORTED_IMPORT_EXTENSIONS)[number];
1
+ import type { SUPPORTED_IMPORT_EXTENSIONS } from "./constants.js";
3
2
 
4
- export interface CodegenConfig {
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;