@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xndrjs/i18n",
3
- "version": "0.3.2",
3
+ "version": "0.3.3-alpha.0",
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",
@@ -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;