@xndrjs/i18n 0.2.1 → 0.3.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.
@@ -0,0 +1,104 @@
1
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it, afterEach } from "vitest";
5
+ import {
6
+ getDictionaryFormat,
7
+ prepareDictionaryEntries,
8
+ readDictionaryFile,
9
+ resolveCompiledJsonPath,
10
+ } from "./read-dictionary.js";
11
+
12
+ describe("read-dictionary", () => {
13
+ let tempDir: string;
14
+
15
+ afterEach(() => {
16
+ if (tempDir) {
17
+ rmSync(tempDir, { recursive: true, force: true });
18
+ }
19
+ });
20
+
21
+ it("detects dictionary formats from file extension", () => {
22
+ expect(getDictionaryFormat("translations/default.json")).toBe("json");
23
+ expect(getDictionaryFormat("translations/default.yaml")).toBe("yaml");
24
+ expect(getDictionaryFormat("translations/default.yml")).toBe("yaml");
25
+ expect(getDictionaryFormat("translations/default.toml")).toBeNull();
26
+ });
27
+
28
+ it("resolves compiled json path under the generated output directory", () => {
29
+ expect(
30
+ resolveCompiledJsonPath("src/i18n/translations/billing.yaml", "src/i18n/generated")
31
+ ).toBe("src/i18n/generated/translations/billing.json");
32
+ expect(resolveCompiledJsonPath("translations/billing.yml", "generated")).toBe(
33
+ "generated/translations/billing.json"
34
+ );
35
+ });
36
+
37
+ it("reads multiline yaml dictionaries", () => {
38
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-read-dict-"));
39
+ const yamlPath = join(tempDir, "welcome.yaml");
40
+ writeFileSync(
41
+ yamlPath,
42
+ `welcome:
43
+ en: |
44
+ Line one
45
+ Line two
46
+ it: Ciao {name}
47
+ `
48
+ );
49
+
50
+ const dictionary = readDictionaryFile(yamlPath);
51
+ expect(dictionary.welcome?.en).toBe("Line one\nLine two\n");
52
+ expect(dictionary.welcome?.it).toBe("Ciao {name}");
53
+ });
54
+
55
+ it("compiles yaml sources to json under the generated output directory", () => {
56
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-read-dict-"));
57
+ mkdirSync(join(tempDir, "src/i18n/translations"), { recursive: true });
58
+ mkdirSync(join(tempDir, "src/i18n/generated"), { recursive: true });
59
+ writeFileSync(
60
+ join(tempDir, "src/i18n/translations/billing.yaml"),
61
+ `invoice_summary:
62
+ en: You have {count} invoices
63
+ `
64
+ );
65
+
66
+ const result = prepareDictionaryEntries(
67
+ tempDir,
68
+ [{ namespace: "billing", filePath: "src/i18n/translations/billing.yaml" }],
69
+ "src/i18n/generated"
70
+ );
71
+
72
+ expect(result.resolvedEntries).toEqual([
73
+ { namespace: "billing", filePath: "src/i18n/generated/translations/billing.json" },
74
+ ]);
75
+ expect(result.compiledFiles).toEqual([
76
+ "src/i18n/translations/billing.yaml → src/i18n/generated/translations/billing.json",
77
+ ]);
78
+
79
+ const compiled = JSON.parse(
80
+ readFileSync(join(tempDir, "src/i18n/generated/translations/billing.json"), "utf8")
81
+ ) as Record<string, Record<string, string>>;
82
+ expect(compiled.invoice_summary?.en).toBe("You have {count} invoices");
83
+ });
84
+
85
+ it("keeps json sources unchanged", () => {
86
+ tempDir = mkdtempSync(join(tmpdir(), "xndrjs-i18n-read-dict-"));
87
+ mkdirSync(join(tempDir, "translations"), { recursive: true });
88
+ writeFileSync(
89
+ join(tempDir, "translations/default.json"),
90
+ JSON.stringify({ welcome: { en: "Welcome {name}!" } })
91
+ );
92
+
93
+ const result = prepareDictionaryEntries(
94
+ tempDir,
95
+ [{ namespace: "default", filePath: "translations/default.json" }],
96
+ "generated"
97
+ );
98
+
99
+ expect(result.resolvedEntries).toEqual([
100
+ { namespace: "default", filePath: "translations/default.json" },
101
+ ]);
102
+ expect(result.compiledFiles).toEqual([]);
103
+ });
104
+ });
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import type { DictionaryJson, NamespaceEntry } from "./types.js";
5
+
6
+ export const SUPPORTED_DICTIONARY_EXTENSIONS = [".json", ".yaml", ".yml"] as const;
7
+ export type DictionaryFormat = "json" | "yaml";
8
+
9
+ export function getDictionaryFormat(filePath: string): DictionaryFormat | null {
10
+ const extension = path.extname(filePath).toLowerCase();
11
+ if (extension === ".json") {
12
+ return "json";
13
+ }
14
+ if (extension === ".yaml" || extension === ".yml") {
15
+ return "yaml";
16
+ }
17
+ return null;
18
+ }
19
+
20
+ export function resolveCompiledJsonPath(sourcePath: string, generatedDirRelative: string): string {
21
+ const baseName = `${path.basename(sourcePath, path.extname(sourcePath))}.json`;
22
+ return path.join(generatedDirRelative, "translations", baseName).replace(/\\/g, "/");
23
+ }
24
+
25
+ function assertDictionaryShape(value: unknown, context: string): asserts value is DictionaryJson {
26
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
27
+ throw new Error(`[Codegen Error] ${context} must be a plain object.`);
28
+ }
29
+
30
+ for (const [key, localesByKey] of Object.entries(value)) {
31
+ if (localesByKey === null || typeof localesByKey !== "object" || Array.isArray(localesByKey)) {
32
+ throw new Error(`[Codegen Error] ${context}: key "${key}" must map locales to strings.`);
33
+ }
34
+
35
+ for (const [locale, template] of Object.entries(localesByKey)) {
36
+ if (typeof template !== "string") {
37
+ throw new Error(
38
+ `[Codegen Error] ${context}: key "${key}", locale "${locale}" must be a string.`
39
+ );
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ export function readDictionaryFile(absolutePath: string): DictionaryJson {
46
+ const format = getDictionaryFormat(absolutePath);
47
+ if (!format) {
48
+ throw new Error(
49
+ `[Codegen Error] Unsupported dictionary format "${path.extname(absolutePath)}" for ${absolutePath}. Use .json, .yaml, or .yml.`
50
+ );
51
+ }
52
+
53
+ const source = fs.readFileSync(absolutePath, "utf8");
54
+ let parsed: unknown;
55
+
56
+ try {
57
+ parsed = format === "json" ? JSON.parse(source) : parseYaml(source);
58
+ } catch (error) {
59
+ const message = error instanceof Error ? error.message : String(error);
60
+ throw new Error(`[Codegen Error] Failed to parse dictionary ${absolutePath}: ${message}`);
61
+ }
62
+
63
+ assertDictionaryShape(parsed, `Dictionary file ${absolutePath}`);
64
+ return parsed;
65
+ }
66
+
67
+ function writeCompiledJson(absoluteJsonPath: string, dictionary: DictionaryJson): boolean {
68
+ const nextContent = `${JSON.stringify(dictionary, null, 2)}\n`;
69
+ if (fs.existsSync(absoluteJsonPath)) {
70
+ const currentContent = fs.readFileSync(absoluteJsonPath, "utf8");
71
+ if (currentContent === nextContent) {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ fs.mkdirSync(path.dirname(absoluteJsonPath), { recursive: true });
77
+ fs.writeFileSync(absoluteJsonPath, nextContent);
78
+ return true;
79
+ }
80
+
81
+ export interface PrepareDictionariesResult {
82
+ resolvedEntries: NamespaceEntry[];
83
+ compiledFiles: string[];
84
+ }
85
+
86
+ export function prepareDictionaryEntries(
87
+ projectRoot: string,
88
+ entries: NamespaceEntry[],
89
+ generatedDirRelative: string
90
+ ): PrepareDictionariesResult {
91
+ const resolvedEntries: NamespaceEntry[] = [];
92
+ const compiledFiles: string[] = [];
93
+
94
+ for (const entry of entries) {
95
+ const sourceAbsolutePath = path.resolve(projectRoot, entry.filePath);
96
+ const format = getDictionaryFormat(entry.filePath);
97
+
98
+ if (!format) {
99
+ throw new Error(
100
+ `[Codegen Error] Namespace "${entry.namespace}" uses unsupported dictionary extension in "${entry.filePath}". Use .json, .yaml, or .yml.`
101
+ );
102
+ }
103
+
104
+ if (!fs.existsSync(sourceAbsolutePath)) {
105
+ throw new Error(
106
+ `[Codegen Error] Dictionary file not found for namespace "${entry.namespace}": ${sourceAbsolutePath}`
107
+ );
108
+ }
109
+
110
+ const dictionary = readDictionaryFile(sourceAbsolutePath);
111
+
112
+ if (format === "yaml") {
113
+ const compiledRelativePath = resolveCompiledJsonPath(entry.filePath, generatedDirRelative);
114
+ const compiledAbsolutePath = path.resolve(projectRoot, compiledRelativePath);
115
+ const wroteFile = writeCompiledJson(compiledAbsolutePath, dictionary);
116
+
117
+ if (wroteFile) {
118
+ compiledFiles.push(
119
+ `${path.relative(projectRoot, sourceAbsolutePath)} → ${compiledRelativePath}`
120
+ );
121
+ }
122
+ resolvedEntries.push({
123
+ namespace: entry.namespace,
124
+ filePath: compiledRelativePath,
125
+ });
126
+ continue;
127
+ }
128
+
129
+ resolvedEntries.push(entry);
130
+ }
131
+
132
+ return { resolvedEntries, compiledFiles };
133
+ }
@@ -1,3 +1,6 @@
1
+ export const SUPPORTED_IMPORT_EXTENSIONS = ["none", ".ts", ".js"] as const;
2
+ export type ImportExtension = (typeof SUPPORTED_IMPORT_EXTENSIONS)[number];
3
+
1
4
  export interface CodegenConfig {
2
5
  dictionary?: string;
3
6
  namespaces?: Record<string, string>;
@@ -8,6 +11,8 @@ export interface CodegenConfig {
8
11
  dictionarySchemaOutput?: string;
9
12
  loadOnInit?: string[];
10
13
  namespaceLoadersOutput?: string;
14
+ /** Extension used in relative imports between generated modules. Default: "none" */
15
+ importExtension?: ImportExtension;
11
16
  paramsTypeName: string;
12
17
  schemaTypeName: string;
13
18
  localeTypeName?: string;
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { DEFAULT_IMPORT_EXTENSION, importExtensionSuffix } from "../codegen/paths.js";
4
5
  import { inferProjectName, typeNamesForProject } from "./type-names.js";
5
6
 
6
7
  export type SetupMode = "single" | "multi";
@@ -28,11 +29,13 @@ const DEFAULT_STARTER = {
28
29
  welcome: { en: "Welcome {name}!" },
29
30
  };
30
31
 
32
+ const DEFAULT_IMPORT_SUFFIX = importExtensionSuffix(DEFAULT_IMPORT_EXTENSION);
33
+
31
34
  const INDEX_TS =
32
- `import { createI18n } from "./generated/instance.generated.js";\n\n` +
33
- `export * from "./generated/instance.generated.js";\n` +
34
- `export * from "./generated/dictionary.generated.js";\n` +
35
- `export * from "./generated/i18n-types.generated.js";\n\n` +
35
+ `import { createI18n } from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
36
+ `export * from "./generated/instance.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
37
+ `export * from "./generated/dictionary.generated${DEFAULT_IMPORT_SUFFIX}";\n` +
38
+ `export * from "./generated/i18n-types.generated${DEFAULT_IMPORT_SUFFIX}";\n\n` +
36
39
  `export const i18n = createI18n();\n`;
37
40
 
38
41
  function writeJson(filePath: string, value: unknown): void {