@dialexjs/cli 0.2.3 → 0.5.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/dist/index.mjs CHANGED
@@ -1,913 +1,1125 @@
1
1
  #!/usr/bin/env node
2
- import { C as saveDictionaryFile, D as logger, E as runGenerate, T as generateDictionaries, _ as listLeaves, c as analyzeProject, d as TODO_PREFIX, f as copyLeaf, g as hasPath, h as getString, r as toKey, t as loadProject, v as listLocales, w as setString, y as loadDictionaryFile } from "./project-BnG6V2I0.mjs";
2
+ import { A as listLocales, C as TODO_PREFIX, D as getString, F as saveDictionaryFile, I as setString, O as hasPath, S as writeLock, _ as isStale, a as renderGenerated, b as readLock, f as analyzeProject, h as LOCK_FILE, i as generateDictionaries, j as loadDictionaryFile, k as listLeaves, o as runGenerate, r as toKey, t as loadProject, u as logger, v as lockFor, w as copyLeaf, x as record, y as lockPath } from "./project-BJYmnKiU.mjs";
3
3
  import { Command } from "commander";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
+ import { resolveDialexConfig } from "dialexjs/scanner";
6
7
  import fg from "fast-glob";
7
- import { resolveI18nConfig } from "dialexjs/scanner";
8
8
  import pc from "picocolors";
9
- import * as p from "@clack/prompts";
9
+ import { fileURLToPath } from "node:url";
10
10
  import { loadFile, parseModule, writeFile } from "magicast";
11
- import { addNuxtModule, addVitePlugin } from "magicast/helpers";
12
11
  import { argumentSignature, getArguments, isIcuStructured, parseMessage } from "dialexjs/icu";
13
- //#region src/utils/detector.ts
12
+ import * as p from "@clack/prompts";
13
+ import { addNuxtModule } from "magicast/helpers";
14
+ //#region package.json
15
+ var version = "0.5.0";
16
+ //#endregion
17
+ //#region src/commands/check.ts
18
+ const DICTIONARY_IGNORE = [
19
+ "**/node_modules/**",
20
+ "**/dist/**",
21
+ "**/.next/**",
22
+ "**/dialex.locales/**"
23
+ ];
24
+ function addIssue(diag, issue) {
25
+ const level = issue.level === "error" ? "error" : "warning";
26
+ (level === "error" ? diag.errors : diag.warnings).push(issue.message);
27
+ diag.annotations.push({
28
+ level,
29
+ message: issue.message,
30
+ line: issue.range ? issue.range.start.line + 1 : void 0
31
+ });
32
+ }
14
33
  /**
15
- * Automatically inspects package.json dependencies and configuration files
16
- * to detect the active framework in the target project.
34
+ * Inserts keys that exist in one locale but not in another, copying the default-locale value
35
+ * (strings get a `[TODO]` prefix). Returns how many keys were inserted.
17
36
  */
18
- function detectFramework(root) {
19
- const pkgPath = path.join(root, "package.json");
20
- if (fs.existsSync(pkgPath)) try {
21
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
22
- const allDeps = {
23
- ...pkg.dependencies,
24
- ...pkg.devDependencies
25
- };
26
- if ("nuxt" in allDeps) return {
27
- framework: "nuxt",
28
- confidence: "high",
29
- matchedRule: "package.json dependencies (nuxt)"
30
- };
31
- if ("@sveltejs/kit" in allDeps) return {
32
- framework: "sveltekit",
33
- confidence: "high",
34
- matchedRule: "package.json dependencies (@sveltejs/kit)"
35
- };
36
- if ("astro" in allDeps) return {
37
- framework: "astro",
38
- confidence: "high",
39
- matchedRule: "package.json dependencies (astro)"
40
- };
41
- if ("next" in allDeps) return {
42
- framework: "next",
43
- confidence: "high",
44
- matchedRule: "package.json dependencies (next)"
45
- };
46
- if ("@nestjs/core" in allDeps || "@nestjs/common" in allDeps) return {
47
- framework: "nestjs",
48
- confidence: "high",
49
- matchedRule: "package.json dependencies (@nestjs/core)"
50
- };
51
- if ("fastify" in allDeps) return {
52
- framework: "fastify",
53
- confidence: "high",
54
- matchedRule: "package.json dependencies (fastify)"
55
- };
56
- if ("koa" in allDeps) return {
57
- framework: "koa",
58
- confidence: "high",
59
- matchedRule: "package.json dependencies (koa)"
60
- };
61
- if ("express" in allDeps) return {
62
- framework: "express",
63
- confidence: "high",
64
- matchedRule: "package.json dependencies (express)"
65
- };
66
- if ("elysia" in allDeps) return {
67
- framework: "elysia",
68
- confidence: "high",
69
- matchedRule: "package.json dependencies (elysia)"
70
- };
71
- if ("hono" in allDeps) return {
72
- framework: "hono",
73
- confidence: "high",
74
- matchedRule: "package.json dependencies (hono)"
75
- };
76
- if ("vue" in allDeps && !("react" in allDeps)) return {
77
- framework: "vue",
78
- confidence: "high",
79
- matchedRule: "package.json dependencies (vue)"
80
- };
81
- if ("react" in allDeps || "vite" in allDeps) return {
82
- framework: "react",
83
- confidence: "high",
84
- matchedRule: "package.json dependencies (react/vite)"
37
+ async function applyFixes(files, configuredLocales, defaultLocale) {
38
+ let fixed = 0;
39
+ for (const file of files) {
40
+ const df = await loadDictionaryFile(file);
41
+ if (!df?.complete) continue;
42
+ const present = listLocales(df);
43
+ const targets = Array.from(/* @__PURE__ */ new Set([...present, ...configuredLocales]));
44
+ const sourceOrder = [defaultLocale, ...present.filter((l) => l !== defaultLocale)].filter((l) => present.includes(l));
45
+ const allPaths = /* @__PURE__ */ new Map();
46
+ for (const locale of present) for (const leaf of listLeaves(df, locale)) allPaths.set(leaf.path.join("\0"), leaf.path);
47
+ let changed = false;
48
+ for (const target of targets) for (const leafPath of allPaths.values()) {
49
+ if (hasPath(df, target, leafPath)) continue;
50
+ const source = sourceOrder.find((l) => l !== target && hasPath(df, l, leafPath));
51
+ if (source && copyLeaf(df, source, target, leafPath, (v) => `[TODO] ${v}`)) {
52
+ fixed++;
53
+ changed = true;
54
+ }
55
+ }
56
+ if (changed) await saveDictionaryFile(df);
57
+ }
58
+ return fixed;
59
+ }
60
+ function escapeAnnotation(value) {
61
+ return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
62
+ }
63
+ /**
64
+ * What a formatter (Prettier, Biome, oxfmt) can change without changing the code: whitespace,
65
+ * quote style, quoted object keys and trailing commas. Comparing this form keeps `check` quiet
66
+ * when your pre-commit hook formats the generated files.
67
+ */
68
+ function normalizeGenerated(text) {
69
+ return text.replace(/'/g, "\"").replace(/"([A-Za-z_$][\w$]*)"\s*:/g, "$1:").replace(/\s+/g, "").replace(/,(?=[\]})])/g, "");
70
+ }
71
+ /**
72
+ * Generated files that exist but no longer match the dictionaries and config. Nothing builds them
73
+ * for you any more, so a stale `dialex.generated.ts` would otherwise fail silently at runtime.
74
+ * With `fix` they are rewritten.
75
+ */
76
+ function staleGeneratedFiles(root, options) {
77
+ let rendered;
78
+ try {
79
+ rendered = renderGenerated(root, { config: options.config });
80
+ } catch {
81
+ return {
82
+ stale: [],
83
+ rewritten: 0
85
84
  };
86
- } catch {}
87
- if (fs.existsSync(path.join(root, "next.config.js")) || fs.existsSync(path.join(root, "next.config.mjs")) || fs.existsSync(path.join(root, "next.config.ts"))) return {
88
- framework: "next",
89
- confidence: "medium",
90
- matchedRule: "next.config file presence"
91
- };
92
- for (const [framework, base] of [
93
- ["nuxt", "nuxt.config"],
94
- ["sveltekit", "svelte.config"],
95
- ["astro", "astro.config"]
96
- ]) if ([
97
- "js",
98
- "mjs",
99
- "ts"
100
- ].some((ext) => fs.existsSync(path.join(root, `${base}.${ext}`)))) return {
101
- framework,
102
- confidence: "medium",
103
- matchedRule: `${base} file presence`
104
- };
105
- if (fs.existsSync(path.join(root, "vite.config.js")) || fs.existsSync(path.join(root, "vite.config.mjs")) || fs.existsSync(path.join(root, "vite.config.ts"))) return {
106
- framework: "react",
107
- confidence: "medium",
108
- matchedRule: "vite.config file presence"
109
- };
85
+ }
86
+ const stale = [];
87
+ let rewritten = 0;
88
+ const targets = [[rendered.outputPath, rendered.content], [rendered.dtsPath, rendered.dtsContent]];
89
+ if (fs.existsSync(rendered.outputPath)) for (const extra of rendered.extraFiles) targets.push([extra.path, extra.content]);
90
+ for (const [file, content] of targets) {
91
+ if (content === void 0) continue;
92
+ const isExtra = rendered.extraFiles.some((f) => f.path === file);
93
+ if (!fs.existsSync(file)) {
94
+ if (!isExtra) continue;
95
+ if (options.fix) {
96
+ fs.mkdirSync(path.dirname(file), { recursive: true });
97
+ fs.writeFileSync(file, content, "utf-8");
98
+ rewritten++;
99
+ } else stale.push({
100
+ file: path.relative(root, file),
101
+ message: "Missing. Run `dx generate` (or `dx check --fix`)."
102
+ });
103
+ continue;
104
+ }
105
+ if (normalizeGenerated(fs.readFileSync(file, "utf-8")) === normalizeGenerated(content)) continue;
106
+ if (options.fix) {
107
+ fs.writeFileSync(file, content, "utf-8");
108
+ rewritten++;
109
+ } else stale.push({
110
+ file: path.relative(root, file),
111
+ message: "Out of date with your dictionaries or config. Run `dx generate` (or `dx check --fix`)."
112
+ });
113
+ }
110
114
  return {
111
- framework: "hono",
112
- confidence: "fallback",
113
- matchedRule: "default fallback"
115
+ stale,
116
+ rewritten
114
117
  };
115
118
  }
116
- //#endregion
117
- //#region src/commands/init.ts
118
- async function runInit(options = {}) {
119
+ async function runCheck(options = {}) {
119
120
  const root = options.cwd || process.cwd();
120
- const detected = detectFramework(root);
121
- const isInteractive = !options.yes && process.stdin.isTTY;
122
- let framework = options.framework || detected.framework;
123
- let defaultLocale = options.defaultLocale || "en";
124
- let localesInput = options.locales || "en,tr";
125
- if (isInteractive) {
126
- p.intro(pc.bgCyan(pc.black(" dialex init ")));
127
- const frameworkOptions = [
128
- {
129
- value: "hono",
130
- label: "Hono",
131
- hint: detected.framework === "hono" ? "detected" : void 0
132
- },
133
- {
134
- value: "express",
135
- label: "Express",
136
- hint: detected.framework === "express" ? "detected" : void 0
137
- },
138
- {
139
- value: "fastify",
140
- label: "Fastify",
141
- hint: detected.framework === "fastify" ? "detected" : void 0
142
- },
143
- {
144
- value: "koa",
145
- label: "Koa",
146
- hint: detected.framework === "koa" ? "detected" : void 0
147
- },
148
- {
149
- value: "nestjs",
150
- label: "NestJS",
151
- hint: detected.framework === "nestjs" ? "detected" : void 0
152
- },
153
- {
154
- value: "next",
155
- label: "Next.js",
156
- hint: detected.framework === "next" ? "detected" : void 0
157
- },
158
- {
159
- value: "react",
160
- label: "React / Vite",
161
- hint: detected.framework === "react" ? "detected" : void 0
162
- },
163
- {
164
- value: "elysia",
165
- label: "Elysia",
166
- hint: detected.framework === "elysia" ? "detected" : void 0
167
- },
168
- {
169
- value: "sveltekit",
170
- label: "SvelteKit",
171
- hint: detected.framework === "sveltekit" ? "detected" : void 0
172
- },
173
- {
174
- value: "astro",
175
- label: "Astro",
176
- hint: detected.framework === "astro" ? "detected" : void 0
177
- },
178
- {
179
- value: "vue",
180
- label: "Vue / Vite",
181
- hint: detected.framework === "vue" ? "detected" : void 0
182
- },
183
- {
184
- value: "nuxt",
185
- label: "Nuxt",
186
- hint: detected.framework === "nuxt" ? "detected" : void 0
187
- }
188
- ];
189
- const selectedFramework = await p.select({
190
- message: `Framework detected as ${pc.bold(detected.framework)} (${detected.matchedRule}). Select framework:`,
191
- options: frameworkOptions,
192
- initialValue: detected.framework
193
- });
194
- if (p.isCancel(selectedFramework)) {
195
- p.cancel("Operation cancelled.");
196
- return;
121
+ const quiet = options.json === true;
122
+ const inlineConfig = options.config ? { configFile: options.config } : {};
123
+ const config = resolveDialexConfig(root, inlineConfig);
124
+ const include = config.include || "**/*.content.ts";
125
+ const configuredLocales = config.locales || [];
126
+ const defaultLocale = config.defaultLocale || "en";
127
+ const files = fg.sync(include, {
128
+ cwd: root,
129
+ absolute: true,
130
+ ignore: DICTIONARY_IGNORE
131
+ });
132
+ if (files.length === 0) {
133
+ if (!quiet) {
134
+ const patternStr = Array.isArray(include) ? include.join(", ") : include;
135
+ logger.warn(`No dictionary files found matching "${patternStr}".`);
197
136
  }
198
- framework = selectedFramework;
199
- const inputDefault = await p.text({
200
- message: "What is your default locale?",
201
- initialValue: "en",
202
- validate: (v) => !v || !v.trim() ? "Default locale cannot be empty" : void 0
203
- });
204
- if (p.isCancel(inputDefault)) {
205
- p.cancel("Operation cancelled.");
206
- return;
207
- }
208
- defaultLocale = inputDefault.trim();
209
- const inputLocales = await p.text({
210
- message: "Comma-separated list of supported locales:",
211
- initialValue: `${defaultLocale},tr`,
212
- validate: (v) => !v || !v.trim() ? "Locales list cannot be empty" : void 0
213
- });
214
- if (p.isCancel(inputLocales)) {
215
- p.cancel("Operation cancelled.");
216
- return;
137
+ const empty = {
138
+ success: true,
139
+ diagnostics: [],
140
+ totalIssues: 0,
141
+ totalWarnings: 0,
142
+ fixed: 0
143
+ };
144
+ if (quiet) console.log(JSON.stringify(empty, null, 2));
145
+ return empty;
146
+ }
147
+ let fixed = 0;
148
+ if (options.fix) {
149
+ fixed = await applyFixes(files, configuredLocales, defaultLocale);
150
+ if (fixed > 0 && !quiet) logger.success(`Inserted ${fixed} missing key${fixed === 1 ? "" : "s"} marked ${TODO_PREFIX.trim()}`);
151
+ }
152
+ const generated = staleGeneratedFiles(root, options);
153
+ fixed += generated.rewritten;
154
+ if (generated.rewritten > 0 && !quiet) logger.success(`Regenerated ${generated.rewritten} out-of-date generated file${generated.rewritten === 1 ? "" : "s"}`);
155
+ const analysis = await analyzeProject({
156
+ root,
157
+ config,
158
+ src: options.src && options.src.length > 0 ? options.src : void 0,
159
+ runtime: true
160
+ });
161
+ const byFile = /* @__PURE__ */ new Map();
162
+ const dictionaryFiles = new Set(analysis.files);
163
+ for (const issue of analysis.issues) {
164
+ if (options.failOnStale && issue.code === "stale-translation") issue.level = "error";
165
+ if (issue.level === "info") continue;
166
+ const rel = path.relative(root, issue.file);
167
+ let diag = byFile.get(rel);
168
+ if (!diag) {
169
+ diag = {
170
+ file: rel,
171
+ errors: [],
172
+ warnings: [],
173
+ annotations: []
174
+ };
175
+ byFile.set(rel, diag);
217
176
  }
218
- localesInput = inputLocales.trim();
219
- if (options.ai === void 0 && options.noAi === void 0) {
220
- const confirmAi = await p.confirm({
221
- message: "Configure AI agent support (Claude/Cursor skills, .mcp.json, AGENTS.md)?",
222
- initialValue: true
223
- });
224
- if (!p.isCancel(confirmAi)) options.ai = confirmAi;
177
+ if (dictionaryFiles.has(issue.file) && issue.dictionary && !diag.name) diag.name = issue.dictionary;
178
+ addIssue(diag, issue);
179
+ }
180
+ for (const { file, message } of generated.stale) {
181
+ let diag = byFile.get(file);
182
+ if (!diag) {
183
+ diag = {
184
+ file,
185
+ errors: [],
186
+ warnings: [],
187
+ annotations: []
188
+ };
189
+ byFile.set(file, diag);
225
190
  }
226
- } else logger.info(`Auto-detected framework: ${pc.bold(framework)} (${detected.matchedRule})`);
227
- const localeList = Array.from(/* @__PURE__ */ new Set([defaultLocale, ...localesInput.split(",").map((s) => s.trim()).filter(Boolean)]));
228
- const configPath = fs.existsSync(path.join(root, "dialex.config.ts")) ? path.join(root, "dialex.config.ts") : fs.existsSync(path.join(root, "i18n.config.ts")) ? path.join(root, "i18n.config.ts") : path.join(root, "dialex.config.ts");
229
- const configFileName = path.basename(configPath);
230
- try {
231
- if (fs.existsSync(configPath)) {
232
- const mod = await loadFile(configPath);
233
- if (mod && mod.exports.default) {
234
- if (mod.exports.default.$args && mod.exports.default.$args[0]) {
235
- mod.exports.default.$args[0].defaultLocale = defaultLocale;
236
- mod.exports.default.$args[0].locales = localeList;
237
- }
238
- await writeFile(mod, configPath);
239
- logger.success(`Updated ${pc.bold(configFileName)} via Magicast`);
191
+ diag.errors.push(message);
192
+ diag.annotations.push({
193
+ level: "error",
194
+ message
195
+ });
196
+ }
197
+ const diagnostics = [...byFile.values()];
198
+ const totalIssues = diagnostics.reduce((n, d) => n + d.errors.length, 0);
199
+ const totalWarnings = diagnostics.reduce((n, d) => n + d.warnings.length, 0);
200
+ const reported = diagnostics.filter((d) => d.errors.length > 0 || d.warnings.length > 0);
201
+ const result = {
202
+ success: totalIssues === 0,
203
+ diagnostics: reported,
204
+ totalIssues,
205
+ totalWarnings,
206
+ fixed
207
+ };
208
+ if (options.github) for (const d of reported) for (const a of d.annotations) {
209
+ const location = `file=${d.file}${a.line ? `,line=${a.line}` : ""}`;
210
+ console.log(`::${a.level} ${location},title=dialex::${escapeAnnotation(a.message)}`);
211
+ }
212
+ if (quiet) console.log(JSON.stringify(result, null, 2));
213
+ else if (reported.length > 0) {
214
+ logger.log("");
215
+ if (totalIssues > 0) logger.error(`Found ${totalIssues} issue${totalIssues === 1 ? "" : "s"} across ${reported.filter((d) => d.errors.length > 0).length} file${reported.length === 1 ? "" : "s"}:`);
216
+ logger.log("");
217
+ for (const d of reported) {
218
+ logger.log(` ${pc.underline(pc.bold(d.file))} ${d.name ? pc.dim(`(${d.name})`) : ""}:`);
219
+ for (const a of d.annotations) {
220
+ const where = a.line ? pc.dim(`:${a.line}`) : "";
221
+ const mark = a.level === "error" ? pc.red("✖") : pc.yellow("⚠");
222
+ logger.log(` ${mark} ${a.message}${where}`);
240
223
  }
241
- } else {
242
- const mod = parseModule(`import { defineConfig } from "dialexjs";
243
-
244
- export default defineConfig({});
245
- `);
246
- mod.exports.default.$args[0].defaultLocale = defaultLocale;
247
- mod.exports.default.$args[0].locales = localeList;
248
- await writeFile(mod, configPath);
249
- logger.success(`Created ${pc.bold(configFileName)} via Magicast`);
224
+ logger.log("");
250
225
  }
251
- } catch (err) {
252
- logger.warn(`Magicast config update notice: ${err.message || String(err)}`);
253
- const fallback = `import { defineConfig } from "dialexjs";
254
-
255
- export default defineConfig({
256
- defaultLocale: "${defaultLocale}",
257
- locales: [${localeList.map((l) => `"${l}"`).join(", ")}],
258
- });
259
- `;
260
- fs.writeFileSync(configPath, fallback, "utf-8");
261
226
  }
262
- if (framework === "react" || framework === "vue") {
263
- const possibleViteConfigs = [
264
- path.join(root, "vite.config.ts"),
265
- path.join(root, "vite.config.js"),
266
- path.join(root, "vite.config.mjs")
267
- ];
268
- for (const vConfig of possibleViteConfigs) if (fs.existsSync(vConfig)) try {
269
- const vMod = await loadFile(vConfig);
270
- addVitePlugin(vMod, {
271
- from: "dialexjs/vite",
272
- imported: "i18nPlugin",
273
- constructor: "i18nPlugin"
274
- });
275
- await writeFile(vMod, vConfig);
276
- logger.success(`Injected i18nPlugin() into ${pc.bold(path.basename(vConfig))} via Magicast`);
277
- break;
278
- } catch {}
227
+ if (totalIssues > 0) {
228
+ if (options.exitOnError !== false) process.exit(1);
229
+ return result;
279
230
  }
280
- if (framework === "nuxt") {
281
- const nuxtConfig = [
282
- "nuxt.config.ts",
283
- "nuxt.config.js",
284
- "nuxt.config.mjs"
285
- ].map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
286
- if (nuxtConfig) try {
287
- const nMod = await loadFile(nuxtConfig);
288
- addNuxtModule(nMod, "dialexjs/nuxt", "dialex", {
289
- defaultLocale,
290
- locales: localeList
231
+ if (!quiet) {
232
+ const warningNote = totalWarnings > 0 ? ` (${totalWarnings} warning${totalWarnings === 1 ? "" : "s"})` : "";
233
+ logger.success(`All ${files.length} dictionary file${files.length === 1 ? "" : "s"} passed checks with 0 issues${warningNote}!`);
234
+ }
235
+ return result;
236
+ }
237
+ //#endregion
238
+ //#region src/commands/export.ts
239
+ const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
240
+ const csvCell = (value) => /[",\r\n]/.test(value) ? `"${value.replace(/"/g, "\"\"")}"` : value;
241
+ async function runExport(options = {}) {
242
+ const root = options.cwd || process.cwd();
243
+ const format = options.format || "json";
244
+ if (![
245
+ "json",
246
+ "csv",
247
+ "xliff"
248
+ ].includes(format)) throw new Error(`Unsupported format "${format}". Use json, csv, or xliff.`);
249
+ const outDir = path.resolve(root, options.out || "dialex-export");
250
+ const project = await loadProject(root, options.config);
251
+ const defaultLocale = project.config.defaultLocale || "en";
252
+ const table = /* @__PURE__ */ new Map();
253
+ const orderedKeys = /* @__PURE__ */ new Set();
254
+ let skippedFunctions = 0;
255
+ const localeSet = new Set(project.config.locales || []);
256
+ for (const { df } of project.dictionaries) listLocales(df).forEach((l) => localeSet.add(l));
257
+ let locales = Array.from(localeSet);
258
+ if (options.locale && options.locale.length > 0) locales = locales.filter((l) => options.locale.includes(l) || l === defaultLocale);
259
+ for (const locale of locales) table.set(locale, /* @__PURE__ */ new Map());
260
+ for (const { name, df } of project.dictionaries) for (const locale of listLocales(df)) {
261
+ if (!table.has(locale)) continue;
262
+ for (const leaf of listLeaves(df, locale)) if (leaf.kind === "string" && leaf.value !== void 0) {
263
+ const key = toKey(name, leaf.path);
264
+ table.get(locale).set(key, leaf.value);
265
+ orderedKeys.add(key);
266
+ } else if (locale === defaultLocale) skippedFunctions++;
267
+ }
268
+ fs.mkdirSync(outDir, { recursive: true });
269
+ const files = [];
270
+ const write = (name, content) => {
271
+ const file = path.join(outDir, name);
272
+ fs.writeFileSync(file, content, "utf-8");
273
+ files.push(path.relative(root, file));
274
+ };
275
+ if (format === "json") for (const [locale, values] of table) write(`${locale}.json`, JSON.stringify(Object.fromEntries(values), null, 2) + "\n");
276
+ else if (format === "csv") write("translations.csv", [["key", ...table.keys()].map(csvCell).join(","), ...[...orderedKeys].map((key) => [key, ...[...table.values()].map((v) => v.get(key) ?? "")].map(csvCell).join(","))].join("\n") + "\n");
277
+ else {
278
+ const source = table.get(defaultLocale);
279
+ for (const [locale, values] of table) {
280
+ if (locale === defaultLocale) continue;
281
+ const units = [...orderedKeys].map((key) => {
282
+ const src = source?.get(key) ?? "";
283
+ const target = values.get(key);
284
+ const targetXml = target === void 0 ? "" : `<target>${escapeXml(target)}</target>`;
285
+ return ` <trans-unit id="${escapeXml(key)}"><source>${escapeXml(src)}</source>${targetXml}</trans-unit>`;
291
286
  });
292
- await writeFile(nMod, nuxtConfig);
293
- logger.success(`Registered dialexjs/nuxt in ${pc.bold(path.basename(nuxtConfig))} via Magicast`);
294
- } catch {
295
- logger.warn("Could not update the Nuxt config; add the dialexjs/nuxt module manually.");
287
+ write(`${locale}.xlf`, `<?xml version="1.0" encoding="UTF-8"?>
288
+ <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
289
+ <file original="dialex" source-language="${escapeXml(defaultLocale)}" target-language="${escapeXml(locale)}" datatype="plaintext">
290
+ <body>
291
+ ${units.join("\n")}
292
+ </body>
293
+ </file>
294
+ </xliff>
295
+ `);
296
296
  }
297
297
  }
298
- const targetDir = fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root;
299
- const dictPath = path.join(targetDir, "home.content.ts");
300
- if (!fs.existsSync(dictPath)) {
301
- const dictContent = `import { defineDictionary } from "dialexjs";
302
-
303
- export default defineDictionary("home", {
304
- ${localeList.map((loc) => ` ${loc}: {
305
- title: "Welcome to Dialex (${loc})!",
306
- description: "Type-safe internationalization.",
307
- greeting: (name: string) => "Hello, " + name + "!",
308
- },`).join("\n")}
309
- });
310
- `;
311
- fs.writeFileSync(dictPath, dictContent, "utf-8");
312
- logger.success(`Created starter dictionary at ${pc.bold(path.relative(root, dictPath))}`);
313
- } else logger.info("home.content.ts already exists, skipping creation.");
314
- generateDictionaries(root);
315
- logger.success("Generated types and standalone dictionary registry!");
316
- const pkgPath = path.join(root, "package.json");
317
- if (fs.existsSync(pkgPath)) try {
318
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
319
- pkg.scripts = pkg.scripts || {};
320
- if (!pkg.scripts["dx:generate"]) {
321
- pkg.scripts["dx:generate"] = "dx generate";
322
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
323
- logger.success("Added \"dx:generate\" script to package.json");
324
- }
325
- } catch {}
326
- if (options.ai === true || options.yes && options.ai !== false && options.noAi !== true) try {
327
- const skillDir = path.join(root, ".agents/skills/dialex");
328
- fs.mkdirSync(skillDir, { recursive: true });
329
- fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
330
- name: dialex
331
- description: Guide for internationalizing JavaScript and TypeScript apps with Dialex. Use when adding translations, creating content dictionaries, configuring locales, or using Dialex with frameworks like Next.js, React, Express, Hono, Fastify, SvelteKit, and Nuxt.
332
- ---
333
-
334
- # Dialex Internationalization
335
-
336
- Dialex is a high-performance, type-safe internationalization toolchain for JavaScript and TypeScript.
337
-
338
- ## Core Concepts
339
- - Dictionaries are defined with \`defineDictionary("name", { ... })\` in \`*.content.ts\` files.
340
- - Keys must match across all locales.
341
- - ICU message syntax is supported for plurals and selects.
342
- - Parity is validated at compile time with \`dialex check\`.
343
-
344
- ## Useful Commands
345
- - \`dialex check --json\`: Check dictionary parity and references.
346
- - \`dialex check --fix\`: Automatically insert missing keys marked with [TODO].
347
- - \`dialex generate\`: Rebuild standalone dictionary bundle and types.
348
- - \`dialex translate --locale <locale>\`: Automatically translate missing keys.
349
- `, "utf-8");
350
- const mcpConfigPath = path.join(root, ".mcp.json");
351
- let mcpConfig = {};
352
- if (fs.existsSync(mcpConfigPath)) try {
353
- mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, "utf-8"));
298
+ const quiet = options.json === true;
299
+ const result = {
300
+ success: true,
301
+ files,
302
+ keys: orderedKeys.size,
303
+ skippedFunctions,
304
+ skippedFiles: project.unsupported
305
+ };
306
+ if (quiet) console.log(JSON.stringify(result, null, 2));
307
+ else {
308
+ logger.success(`Exported ${orderedKeys.size} key${orderedKeys.size === 1 ? "" : "s"} to ${files.length} ${format} file${files.length === 1 ? "" : "s"} in ${path.relative(root, outDir) || "."}`);
309
+ if (skippedFunctions > 0) logger.warn(`${skippedFunctions} function value${skippedFunctions === 1 ? "" : "s"} skipped (not translatable as text)`);
310
+ for (const file of project.unsupported) logger.warn(`Skipped ${file}: unsupported dictionary shape`);
311
+ }
312
+ return result;
313
+ }
314
+ //#endregion
315
+ //#region src/commands/import.ts
316
+ const unescapeXml = (value) => value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
317
+ function parseCsv(text) {
318
+ const rows = [];
319
+ let row = [];
320
+ let cell = "";
321
+ let quoted = false;
322
+ for (let i = 0; i < text.length; i++) {
323
+ const ch = text[i];
324
+ if (quoted) {
325
+ if (ch === "\"" && text[i + 1] === "\"") {
326
+ cell += "\"";
327
+ i++;
328
+ } else if (ch === "\"") quoted = false;
329
+ else cell += ch;
330
+ } else if (ch === "\"") quoted = true;
331
+ else if (ch === ",") {
332
+ row.push(cell);
333
+ cell = "";
334
+ } else if (ch === "\n" || ch === "\r") {
335
+ if (ch === "\r" && text[i + 1] === "\n") i++;
336
+ row.push(cell);
337
+ rows.push(row);
338
+ row = [];
339
+ cell = "";
340
+ } else cell += ch;
341
+ }
342
+ if (cell !== "" || row.length > 0) {
343
+ row.push(cell);
344
+ rows.push(row);
345
+ }
346
+ return rows.filter((r) => r.some((c) => c !== ""));
347
+ }
348
+ function parseEntries(content, format, fallbackLocale) {
349
+ const entries = [];
350
+ if (format === "json") {
351
+ const data = JSON.parse(content);
352
+ if (Object.keys(data).length > 0 && Object.values(data).every((v) => v && typeof v === "object" && !Array.isArray(v))) {
353
+ for (const [locale, values] of Object.entries(data)) for (const [key, value] of Object.entries(values)) if (typeof value === "string") entries.push({
354
+ locale,
355
+ key,
356
+ value
357
+ });
358
+ } else {
359
+ if (!fallbackLocale) throw new Error("Cannot infer the locale; pass --locale.");
360
+ for (const [key, value] of Object.entries(data)) if (typeof value === "string") entries.push({
361
+ locale: fallbackLocale,
362
+ key,
363
+ value
364
+ });
365
+ }
366
+ } else if (format === "csv") {
367
+ const [header, ...rows] = parseCsv(content);
368
+ if (!header || header[0] !== "key") throw new Error("CSV must start with a \"key\" column.");
369
+ for (const row of rows) header.slice(1).forEach((locale, i) => {
370
+ const value = row[i + 1];
371
+ if (value !== void 0 && value !== "") entries.push({
372
+ locale,
373
+ key: row[0],
374
+ value
375
+ });
376
+ });
377
+ } else {
378
+ const locale = /target-language="([^"]*)"/.exec(content)?.[1] ?? fallbackLocale;
379
+ if (!locale) throw new Error("Cannot infer the locale; pass --locale.");
380
+ for (const match of content.matchAll(/<trans-unit\b[^>]*\bid="([^"]*)"[^>]*>([\s\S]*?)<\/trans-unit>/g)) {
381
+ const target = /<target\b[^>]*>([\s\S]*?)<\/target>/.exec(match[2])?.[1];
382
+ if (target !== void 0 && target !== "") entries.push({
383
+ locale,
384
+ key: unescapeXml(match[1]),
385
+ value: unescapeXml(target)
386
+ });
387
+ }
388
+ }
389
+ return entries;
390
+ }
391
+ function inferFormat(file) {
392
+ const ext = path.extname(file).toLowerCase();
393
+ if (ext === ".json") return "json";
394
+ if (ext === ".csv") return "csv";
395
+ if (ext === ".xlf" || ext === ".xliff") return "xliff";
396
+ throw new Error(`Cannot infer format from "${ext}". Pass --format json|csv|xliff.`);
397
+ }
398
+ /**
399
+ * Why `value` must not replace the current text, if it is an ICU message that is invalid or uses
400
+ * different arguments than the same key in another locale.
401
+ */
402
+ function icuProblem(df, defaultLocale, locale, keyPath, value) {
403
+ if (!value.includes("{")) return void 0;
404
+ let signature;
405
+ try {
406
+ signature = argumentSignature(parseMessage(value));
407
+ } catch (error) {
408
+ return `invalid ICU: ${error.message}`;
409
+ }
410
+ const locales = listLocales(df);
411
+ for (const other of [defaultLocale, ...locales.filter((l) => l !== defaultLocale)]) {
412
+ if (other === locale) continue;
413
+ const reference = getString(df, other, keyPath);
414
+ if (!reference?.includes("{")) continue;
415
+ try {
416
+ const expected = argumentSignature(parseMessage(reference));
417
+ return expected === signature ? void 0 : `arguments differ from "${other}": expected ${expected || "none"}, found ${signature || "none"}`;
354
418
  } catch {
355
- mcpConfig = {};
419
+ continue;
356
420
  }
357
- mcpConfig.mcpServers = mcpConfig.mcpServers || {};
358
- mcpConfig.mcpServers.dialex = mcpConfig.mcpServers.dialex || {
359
- command: "npx",
360
- args: ["@dialexjs/mcp"]
361
- };
362
- fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
363
- const agentsMdPath = path.join(root, "AGENTS.md");
364
- const dialexAgentSnippet = `
365
- ## Internationalization (Dialex)
366
-
367
- This project uses [Dialex](https://github.com/awaiden/dialex) for type-safe internationalization.
368
- - Dictionaries are in \`*.content.ts\` files using \`defineDictionary\`.
369
- - Run \`dialex check --json\` to verify parity.
370
- - Run \`dialex check --fix\` to insert missing keys marked with \`[TODO]\`.
371
- - Run \`dx generate\` (alias of \`dialex\`) or \`npm run dx:generate\` to compile dictionary bundles and update types.
372
- `;
373
- if (!fs.existsSync(agentsMdPath)) fs.writeFileSync(agentsMdPath, dialexAgentSnippet.trimStart(), "utf-8");
421
+ }
422
+ }
423
+ async function runImport(options) {
424
+ const root = options.cwd || process.cwd();
425
+ const file = path.resolve(root, options.file);
426
+ const format = options.format || inferFormat(file);
427
+ const fallbackLocale = options.locale ?? path.basename(file, path.extname(file));
428
+ const entries = parseEntries(fs.readFileSync(file, "utf-8"), format, options.locale ?? fallbackLocale);
429
+ const project = await loadProject(root, options.config);
430
+ const byName = new Map(project.dictionaries.map((d) => [d.name, d]));
431
+ const quiet = options.json === true;
432
+ const result = {
433
+ success: true,
434
+ created: 0,
435
+ updated: 0,
436
+ skipped: [],
437
+ rejected: [],
438
+ unknown: [],
439
+ errors: []
440
+ };
441
+ const touched = /* @__PURE__ */ new Set();
442
+ const lockInUse = readLock(root) !== void 0;
443
+ const sourceLocale = readLock(root)?.sourceLocale ?? project.config.defaultLocale ?? "en";
444
+ const { lock } = lockFor(root, sourceLocale);
445
+ let recorded = 0;
446
+ for (const { locale, key, value } of entries) {
447
+ const [name, ...keyPath] = key.split(".");
448
+ const dict = byName.get(name);
449
+ if (!dict || keyPath.length === 0) {
450
+ result.unknown.push(key);
451
+ continue;
452
+ }
453
+ if (!listLocales(dict.df).some((l) => hasPath(dict.df, l, keyPath)) && !options.allowNew) {
454
+ result.unknown.push(key);
455
+ continue;
456
+ }
457
+ const problem = icuProblem(dict.df, project.config.defaultLocale ?? "en", locale, keyPath, value);
458
+ if (problem) {
459
+ result.rejected.push(`${locale}:${key} (${problem})`);
460
+ continue;
461
+ }
462
+ const outcome = setString(dict.df, locale, keyPath, value);
463
+ if (outcome === "skipped") result.skipped.push(`${locale}:${key}`);
374
464
  else {
375
- const existing = fs.readFileSync(agentsMdPath, "utf-8");
376
- if (!existing.includes("Dialex")) fs.writeFileSync(agentsMdPath, existing.trimEnd() + "\n" + dialexAgentSnippet, "utf-8");
465
+ result[outcome]++;
466
+ touched.add(dict.rel);
467
+ const sourceText = getString(dict.df, sourceLocale, keyPath);
468
+ if (lockInUse && locale !== sourceLocale && sourceText !== void 0) {
469
+ record(lock, locale, key, sourceText);
470
+ recorded++;
471
+ }
377
472
  }
378
- logger.success("Configured AI assistant support (.agents/skills, .mcp.json, AGENTS.md)");
379
- } catch (err) {
380
- logger.warn(`Could not finish AI configuration: ${err.message || String(err)}`);
381
473
  }
382
- logger.log("");
383
- logger.info(pc.bold(`Quick start for ${framework.toUpperCase()}:`));
384
- switch (framework) {
385
- case "hono":
386
- logger.log(`
387
- import { Hono } from "hono";
388
- import { i18n } from "dialexjs/hono";
389
- import dictionaries from "./src/dialex.generated.js";
390
-
391
- const app = new Hono();
392
- app.use("*", i18n({ dictionaries }));
393
-
394
- app.get("/", (c) => {
395
- const dict = c.var.getDictionary("home");
396
- return c.json({ title: dict.title });
397
- });
398
- `);
399
- break;
400
- case "fastify":
401
- logger.log(`
402
- import Fastify from "fastify";
403
- import { i18nPlugin } from "dialexjs/fastify";
404
- import dictionaries from "./src/dialex.generated.js";
405
-
406
- const app = Fastify();
407
- await app.register(i18nPlugin, { dictionaries });
408
-
409
- app.get("/", (req) => {
410
- const dict = req.getDictionary("home");
411
- return { title: dict.title };
412
- });
413
- `);
414
- break;
415
- case "express":
416
- logger.log(`
417
- import express from "express";
418
- import { i18n } from "dialexjs/express";
419
- import dictionaries from "./src/dialex.generated.js";
420
-
421
- const app = express();
422
- app.use(i18n({ dictionaries }));
423
-
424
- app.get("/", (req, res) => {
425
- const dict = req.getDictionary("home");
426
- res.json({ title: dict.title });
427
- });
428
- `);
429
- break;
430
- case "koa":
431
- logger.log(`
432
- import Koa from "koa";
433
- import { i18n } from "dialexjs/koa";
434
- import dictionaries from "./src/dialex.generated.js";
435
-
436
- const app = new Koa();
437
- app.use(i18n({ dictionaries }));
438
-
439
- app.use((ctx) => {
440
- const dict = ctx.getDictionary("home");
441
- ctx.body = { title: dict.title };
442
- });
443
- `);
444
- break;
445
- case "nestjs":
446
- logger.log(`
447
- import { Module } from "@nestjs/common";
448
- import { I18nModule } from "dialexjs/nestjs";
449
- import dictionaries from "./src/dialex.generated.js";
450
-
451
- @Module({
452
- imports: [I18nModule.forRoot({ dictionaries })],
453
- })
454
- export class AppModule {}
455
- `);
456
- break;
457
- case "next":
458
- logger.log(`
459
- import { getDictionary } from "dialexjs/server";
460
-
461
- export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
462
- const { locale = "en" } = await params;
463
- const dict = getDictionary("home", locale);
464
- return <h1>{dict.title}</h1>;
465
- }
466
- `);
467
- break;
468
- case "elysia":
469
- logger.log(`
470
- import { Elysia } from "elysia";
471
- import { i18n } from "dialexjs/elysia";
472
- import dictionaries from "./src/dialex.generated.js";
473
-
474
- new Elysia()
475
- .use(i18n({ dictionaries }))
476
- .get("/", ({ getDictionary }) => ({ title: getDictionary("home").title }))
477
- .listen(3000);
478
- `);
479
- break;
480
- case "sveltekit":
481
- logger.log(`
482
- // src/hooks.server.ts
483
- import { i18nHandle } from "dialexjs/sveltekit";
484
- import dictionaries from "./dialex.generated.js";
485
-
486
- export const handle = i18nHandle({ dictionaries });
487
-
488
- // src/app.html: <html lang="%dialex.lang%">
489
- // In load functions: locals.getDictionary("home")
490
- `);
491
- break;
492
- case "astro":
493
- logger.log(`
494
- // src/middleware.ts
495
- import { i18n } from "dialexjs/astro";
496
- import dictionaries from "./dialex.generated.js";
497
-
498
- export const onRequest = i18n({ dictionaries });
499
-
500
- // In pages: Astro.locals.getDictionary("home")
501
- `);
502
- break;
503
- case "vue":
504
- logger.log(`
505
- // src/main.ts
506
- import { createApp } from "vue";
507
- import { createI18n } from "dialexjs/vue";
508
- import App from "./App.vue";
509
-
510
- createApp(App).use(createI18n()).mount("#app");
511
-
512
- // In components: const dict = useDictionary("home")
513
- `);
514
- break;
515
- case "nuxt":
516
- logger.log(`
517
- // nuxt.config.ts
518
- export default defineNuxtConfig({
519
- modules: ["dialexjs/nuxt"],
520
- });
521
-
522
- // In components: const dict = useDictionary("home")
523
- `);
524
- break;
525
- default: logger.log(`
526
- Import dictionaries and register your framework middleware to begin!
527
- Run \`npm run dx:generate\` whenever you add new dictionary files.
528
- `);
474
+ if (recorded > 0) writeLock(root, lock);
475
+ for (const dict of project.dictionaries) if (touched.has(dict.rel)) await saveDictionaryFile(dict.df);
476
+ if (result.rejected.length > 0) result.success = false;
477
+ if (quiet) console.log(JSON.stringify(result, null, 2));
478
+ else {
479
+ logger.success(`Imported ${result.created + result.updated} value${result.created + result.updated === 1 ? "" : "s"} (${result.created} added, ${result.updated} updated)`);
480
+ if (result.unknown.length > 0) logger.warn(`${result.unknown.length} unknown key${result.unknown.length === 1 ? "" : "s"} ignored (use --allow-new to add): ${result.unknown.slice(0, 5).join(", ")}${result.unknown.length > 5 ? ", ..." : ""}`);
481
+ if (result.rejected.length > 0) logger.warn(`${result.rejected.length} value${result.rejected.length === 1 ? "" : "s"} rejected: ${result.rejected.slice(0, 5).join("; ")}`);
482
+ if (result.skipped.length > 0) logger.warn(`${result.skipped.length} non-text value${result.skipped.length === 1 ? "" : "s"} left unchanged: ${result.skipped.slice(0, 5).join(", ")}`);
529
483
  }
530
- if (isInteractive) p.outro(pc.green("✔ dialex is configured and ready to go!"));
484
+ return result;
485
+ }
486
+ //#endregion
487
+ //#region src/utils/detector.ts
488
+ /**
489
+ * Automatically inspects package.json dependencies and configuration files
490
+ * to detect the active framework in the target project.
491
+ */
492
+ function detectFramework(root) {
493
+ const pkgPath = path.join(root, "package.json");
494
+ if (fs.existsSync(pkgPath)) try {
495
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
496
+ const allDeps = {
497
+ ...pkg.dependencies,
498
+ ...pkg.devDependencies
499
+ };
500
+ if ("nuxt" in allDeps) return {
501
+ framework: "nuxt",
502
+ confidence: "high",
503
+ matchedRule: "package.json dependencies (nuxt)"
504
+ };
505
+ if ("@sveltejs/kit" in allDeps) return {
506
+ framework: "sveltekit",
507
+ confidence: "high",
508
+ matchedRule: "package.json dependencies (@sveltejs/kit)"
509
+ };
510
+ if ("astro" in allDeps) return {
511
+ framework: "astro",
512
+ confidence: "high",
513
+ matchedRule: "package.json dependencies (astro)"
514
+ };
515
+ if ("next" in allDeps) return {
516
+ framework: "next",
517
+ confidence: "high",
518
+ matchedRule: "package.json dependencies (next)"
519
+ };
520
+ if ("@nestjs/core" in allDeps || "@nestjs/common" in allDeps) return {
521
+ framework: "nestjs",
522
+ confidence: "high",
523
+ matchedRule: "package.json dependencies (@nestjs/core)"
524
+ };
525
+ if ("fastify" in allDeps) return {
526
+ framework: "fastify",
527
+ confidence: "high",
528
+ matchedRule: "package.json dependencies (fastify)"
529
+ };
530
+ if ("koa" in allDeps) return {
531
+ framework: "koa",
532
+ confidence: "high",
533
+ matchedRule: "package.json dependencies (koa)"
534
+ };
535
+ if ("express" in allDeps) return {
536
+ framework: "express",
537
+ confidence: "high",
538
+ matchedRule: "package.json dependencies (express)"
539
+ };
540
+ if ("elysia" in allDeps) return {
541
+ framework: "elysia",
542
+ confidence: "high",
543
+ matchedRule: "package.json dependencies (elysia)"
544
+ };
545
+ if ("hono" in allDeps) return {
546
+ framework: "hono",
547
+ confidence: "high",
548
+ matchedRule: "package.json dependencies (hono)"
549
+ };
550
+ if ("vue" in allDeps && !("react" in allDeps)) return {
551
+ framework: "vue",
552
+ confidence: "high",
553
+ matchedRule: "package.json dependencies (vue)"
554
+ };
555
+ if ("react" in allDeps || "vite" in allDeps) return {
556
+ framework: "react",
557
+ confidence: "high",
558
+ matchedRule: "package.json dependencies (react/vite)"
559
+ };
560
+ } catch {}
561
+ if (fs.existsSync(path.join(root, "next.config.js")) || fs.existsSync(path.join(root, "next.config.mjs")) || fs.existsSync(path.join(root, "next.config.ts"))) return {
562
+ framework: "next",
563
+ confidence: "medium",
564
+ matchedRule: "next.config file presence"
565
+ };
566
+ for (const [framework, base] of [
567
+ ["nuxt", "nuxt.config"],
568
+ ["sveltekit", "svelte.config"],
569
+ ["astro", "astro.config"]
570
+ ]) if ([
571
+ "js",
572
+ "mjs",
573
+ "ts"
574
+ ].some((ext) => fs.existsSync(path.join(root, `${base}.${ext}`)))) return {
575
+ framework,
576
+ confidence: "medium",
577
+ matchedRule: `${base} file presence`
578
+ };
579
+ if (fs.existsSync(path.join(root, "vite.config.js")) || fs.existsSync(path.join(root, "vite.config.mjs")) || fs.existsSync(path.join(root, "vite.config.ts"))) return {
580
+ framework: "react",
581
+ confidence: "medium",
582
+ matchedRule: "vite.config file presence"
583
+ };
584
+ return {
585
+ framework: "hono",
586
+ confidence: "fallback",
587
+ matchedRule: "default fallback"
588
+ };
531
589
  }
532
590
  //#endregion
533
- //#region src/commands/check.ts
534
- const DICTIONARY_IGNORE = [
535
- "**/node_modules/**",
536
- "**/dist/**",
537
- "**/.next/**"
538
- ];
539
- function addIssue(diag, issue) {
540
- const level = issue.level === "error" ? "error" : "warning";
541
- (level === "error" ? diag.errors : diag.warnings).push(issue.message);
542
- diag.annotations.push({
543
- level,
544
- message: issue.message,
545
- line: issue.range ? issue.range.start.line + 1 : void 0
546
- });
591
+ //#region src/utils/package-json.ts
592
+ /** Version of this CLI, which the other Dialex packages share. Read from the nearest package.json. */
593
+ function cliVersion() {
594
+ let dir = path.dirname(fileURLToPath(import.meta.url));
595
+ for (let i = 0; i < 4; i++) {
596
+ try {
597
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8"));
598
+ if (pkg.name === "@dialexjs/cli") return pkg.version;
599
+ } catch {}
600
+ dir = path.dirname(dir);
601
+ }
547
602
  }
603
+ /** The install command for the package manager a project uses, judged by its lockfile. */
604
+ function installCommand(root) {
605
+ const has = (file) => fs.existsSync(path.join(root, file));
606
+ if (has("bun.lock") || has("bun.lockb")) return "bun install";
607
+ if (has("pnpm-lock.yaml")) return "pnpm install";
608
+ if (has("yarn.lock")) return "yarn install";
609
+ return "npm install";
610
+ }
611
+ const sorted = (deps) => Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)));
548
612
  /**
549
- * Inserts keys that exist in one locale but not in another, copying the default-locale value
550
- * (strings get a `[TODO]` prefix). Returns how many keys were inserted.
613
+ * Adds the packages a Dialex project needs: `dialexjs` as a dependency and the CLI as a dev
614
+ * dependency (the `dx:generate` script runs it). Packages already listed anywhere are left alone.
615
+ * Returns the names it added.
551
616
  */
552
- async function applyFixes(files, configuredLocales, defaultLocale) {
553
- let fixed = 0;
554
- for (const file of files) {
555
- const df = await loadDictionaryFile(file);
556
- if (!df?.complete) continue;
557
- const present = listLocales(df);
558
- const targets = Array.from(/* @__PURE__ */ new Set([...present, ...configuredLocales]));
559
- const sourceOrder = [defaultLocale, ...present.filter((l) => l !== defaultLocale)].filter((l) => present.includes(l));
560
- const allPaths = /* @__PURE__ */ new Map();
561
- for (const locale of present) for (const leaf of listLeaves(df, locale)) allPaths.set(leaf.path.join("\0"), leaf.path);
562
- let changed = false;
563
- for (const target of targets) for (const leafPath of allPaths.values()) {
564
- if (hasPath(df, target, leafPath)) continue;
565
- const source = sourceOrder.find((l) => l !== target && hasPath(df, l, leafPath));
566
- if (source && copyLeaf(df, source, target, leafPath, (v) => `[TODO] ${v}`)) {
567
- fixed++;
568
- changed = true;
569
- }
570
- }
571
- if (changed) await saveDictionaryFile(df);
617
+ function addRequiredPackages(pkg, version = cliVersion()) {
618
+ const range = version ? `^${version}` : "latest";
619
+ const listed = (name) => [
620
+ pkg.dependencies,
621
+ pkg.devDependencies,
622
+ pkg.peerDependencies
623
+ ].some((deps) => deps && name in deps);
624
+ const added = [];
625
+ if (!listed("dialexjs")) {
626
+ pkg.dependencies = sorted({
627
+ ...pkg.dependencies,
628
+ dialexjs: range
629
+ });
630
+ added.push("dialexjs");
572
631
  }
573
- return fixed;
574
- }
575
- function escapeAnnotation(value) {
576
- return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
632
+ if (!listed("@dialexjs/cli")) {
633
+ pkg.devDependencies = sorted({
634
+ ...pkg.devDependencies,
635
+ "@dialexjs/cli": range
636
+ });
637
+ added.push("@dialexjs/cli");
638
+ }
639
+ return added;
577
640
  }
578
- async function runCheck(options = {}) {
641
+ //#endregion
642
+ //#region src/commands/init.ts
643
+ async function runInit(options = {}) {
579
644
  const root = options.cwd || process.cwd();
580
- const quiet = options.json === true;
581
- const inlineConfig = options.config ? { configFile: options.config } : {};
582
- const config = resolveI18nConfig(root, inlineConfig);
583
- const include = config.include || "**/*.content.ts";
584
- const configuredLocales = config.locales || [];
585
- const defaultLocale = config.defaultLocale || "en";
586
- const files = fg.sync(include, {
587
- cwd: root,
588
- absolute: true,
589
- ignore: DICTIONARY_IGNORE
590
- });
591
- if (files.length === 0) {
592
- if (!quiet) {
593
- const patternStr = Array.isArray(include) ? include.join(", ") : include;
594
- logger.warn(`No dictionary files found matching "${patternStr}".`);
645
+ const detected = detectFramework(root);
646
+ const isInteractive = !options.yes && process.stdin.isTTY;
647
+ let framework = options.framework || detected.framework;
648
+ let defaultLocale = options.defaultLocale || "en";
649
+ let localesInput = options.locales || "en,tr";
650
+ if (isInteractive) {
651
+ p.intro(pc.bgCyan(pc.black(" dialex init ")));
652
+ const frameworkOptions = [
653
+ {
654
+ value: "hono",
655
+ label: "Hono",
656
+ hint: detected.framework === "hono" ? "detected" : void 0
657
+ },
658
+ {
659
+ value: "express",
660
+ label: "Express",
661
+ hint: detected.framework === "express" ? "detected" : void 0
662
+ },
663
+ {
664
+ value: "fastify",
665
+ label: "Fastify",
666
+ hint: detected.framework === "fastify" ? "detected" : void 0
667
+ },
668
+ {
669
+ value: "koa",
670
+ label: "Koa",
671
+ hint: detected.framework === "koa" ? "detected" : void 0
672
+ },
673
+ {
674
+ value: "nestjs",
675
+ label: "NestJS",
676
+ hint: detected.framework === "nestjs" ? "detected" : void 0
677
+ },
678
+ {
679
+ value: "next",
680
+ label: "Next.js",
681
+ hint: detected.framework === "next" ? "detected" : void 0
682
+ },
683
+ {
684
+ value: "react",
685
+ label: "React / Vite",
686
+ hint: detected.framework === "react" ? "detected" : void 0
687
+ },
688
+ {
689
+ value: "elysia",
690
+ label: "Elysia",
691
+ hint: detected.framework === "elysia" ? "detected" : void 0
692
+ },
693
+ {
694
+ value: "sveltekit",
695
+ label: "SvelteKit",
696
+ hint: detected.framework === "sveltekit" ? "detected" : void 0
697
+ },
698
+ {
699
+ value: "astro",
700
+ label: "Astro",
701
+ hint: detected.framework === "astro" ? "detected" : void 0
702
+ },
703
+ {
704
+ value: "vue",
705
+ label: "Vue / Vite",
706
+ hint: detected.framework === "vue" ? "detected" : void 0
707
+ },
708
+ {
709
+ value: "nuxt",
710
+ label: "Nuxt",
711
+ hint: detected.framework === "nuxt" ? "detected" : void 0
712
+ }
713
+ ];
714
+ const selectedFramework = await p.select({
715
+ message: `Framework detected as ${pc.bold(detected.framework)} (${detected.matchedRule}). Select framework:`,
716
+ options: frameworkOptions,
717
+ initialValue: detected.framework
718
+ });
719
+ if (p.isCancel(selectedFramework)) {
720
+ p.cancel("Operation cancelled.");
721
+ return;
595
722
  }
596
- const empty = {
597
- success: true,
598
- diagnostics: [],
599
- totalIssues: 0,
600
- totalWarnings: 0,
601
- fixed: 0
602
- };
603
- if (quiet) console.log(JSON.stringify(empty, null, 2));
604
- return empty;
605
- }
606
- let fixed = 0;
607
- if (options.fix) {
608
- fixed = await applyFixes(files, configuredLocales, defaultLocale);
609
- if (fixed > 0 && !quiet) logger.success(`Inserted ${fixed} missing key${fixed === 1 ? "" : "s"} marked ${TODO_PREFIX.trim()}`);
610
- }
611
- const analysis = await analyzeProject({
612
- root,
613
- config,
614
- src: options.src && options.src.length > 0 ? options.src : void 0,
615
- runtime: true
616
- });
617
- const byFile = /* @__PURE__ */ new Map();
618
- const dictionaryFiles = new Set(analysis.files);
619
- for (const issue of analysis.issues) {
620
- if (issue.level === "info") continue;
621
- const rel = path.relative(root, issue.file);
622
- let diag = byFile.get(rel);
623
- if (!diag) {
624
- diag = {
625
- file: rel,
626
- errors: [],
627
- warnings: [],
628
- annotations: []
629
- };
630
- byFile.set(rel, diag);
723
+ framework = selectedFramework;
724
+ const inputDefault = await p.text({
725
+ message: "What is your default locale?",
726
+ initialValue: "en",
727
+ validate: (v) => !v || !v.trim() ? "Default locale cannot be empty" : void 0
728
+ });
729
+ if (p.isCancel(inputDefault)) {
730
+ p.cancel("Operation cancelled.");
731
+ return;
631
732
  }
632
- if (dictionaryFiles.has(issue.file) && issue.dictionary && !diag.name) diag.name = issue.dictionary;
633
- addIssue(diag, issue);
634
- }
635
- const diagnostics = [...byFile.values()];
636
- const totalIssues = diagnostics.reduce((n, d) => n + d.errors.length, 0);
637
- const totalWarnings = diagnostics.reduce((n, d) => n + d.warnings.length, 0);
638
- const reported = diagnostics.filter((d) => d.errors.length > 0 || d.warnings.length > 0);
639
- const result = {
640
- success: totalIssues === 0,
641
- diagnostics: reported,
642
- totalIssues,
643
- totalWarnings,
644
- fixed
645
- };
646
- if (options.github) for (const d of reported) for (const a of d.annotations) {
647
- const location = `file=${d.file}${a.line ? `,line=${a.line}` : ""}`;
648
- console.log(`::${a.level} ${location},title=dialex::${escapeAnnotation(a.message)}`);
649
- }
650
- if (quiet) console.log(JSON.stringify(result, null, 2));
651
- else if (reported.length > 0) {
652
- logger.log("");
653
- if (totalIssues > 0) logger.error(`Found ${totalIssues} issue${totalIssues === 1 ? "" : "s"} across ${reported.filter((d) => d.errors.length > 0).length} file${reported.length === 1 ? "" : "s"}:`);
654
- logger.log("");
655
- for (const d of reported) {
656
- logger.log(` ${pc.underline(pc.bold(d.file))} ${d.name ? pc.dim(`(${d.name})`) : ""}:`);
657
- for (const a of d.annotations) {
658
- const where = a.line ? pc.dim(`:${a.line}`) : "";
659
- const mark = a.level === "error" ? pc.red("✖") : pc.yellow("⚠");
660
- logger.log(` ${mark} ${a.message}${where}`);
661
- }
662
- logger.log("");
733
+ defaultLocale = inputDefault.trim();
734
+ const inputLocales = await p.text({
735
+ message: "Comma-separated list of supported locales:",
736
+ initialValue: `${defaultLocale},tr`,
737
+ validate: (v) => !v || !v.trim() ? "Locales list cannot be empty" : void 0
738
+ });
739
+ if (p.isCancel(inputLocales)) {
740
+ p.cancel("Operation cancelled.");
741
+ return;
663
742
  }
664
- }
665
- if (totalIssues > 0) {
666
- if (options.exitOnError !== false) process.exit(1);
667
- return result;
668
- }
669
- if (!quiet) {
670
- const warningNote = totalWarnings > 0 ? ` (${totalWarnings} warning${totalWarnings === 1 ? "" : "s"})` : "";
671
- logger.success(`All ${files.length} dictionary file${files.length === 1 ? "" : "s"} passed checks with 0 issues${warningNote}!`);
672
- }
673
- return result;
674
- }
675
- //#endregion
676
- //#region src/commands/export.ts
677
- const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
678
- const csvCell = (value) => /[",\r\n]/.test(value) ? `"${value.replace(/"/g, "\"\"")}"` : value;
679
- async function runExport(options = {}) {
680
- const root = options.cwd || process.cwd();
681
- const format = options.format || "json";
682
- if (![
683
- "json",
684
- "csv",
685
- "xliff"
686
- ].includes(format)) throw new Error(`Unsupported format "${format}". Use json, csv, or xliff.`);
687
- const outDir = path.resolve(root, options.out || "i18n-export");
688
- const project = await loadProject(root, options.config);
689
- const defaultLocale = project.config.defaultLocale || "en";
690
- const table = /* @__PURE__ */ new Map();
691
- const orderedKeys = /* @__PURE__ */ new Set();
692
- let skippedFunctions = 0;
693
- const localeSet = new Set(project.config.locales || []);
694
- for (const { df } of project.dictionaries) listLocales(df).forEach((l) => localeSet.add(l));
695
- let locales = Array.from(localeSet);
696
- if (options.locale && options.locale.length > 0) locales = locales.filter((l) => options.locale.includes(l) || l === defaultLocale);
697
- for (const locale of locales) table.set(locale, /* @__PURE__ */ new Map());
698
- for (const { name, df } of project.dictionaries) for (const locale of listLocales(df)) {
699
- if (!table.has(locale)) continue;
700
- for (const leaf of listLeaves(df, locale)) if (leaf.kind === "string" && leaf.value !== void 0) {
701
- const key = toKey(name, leaf.path);
702
- table.get(locale).set(key, leaf.value);
703
- orderedKeys.add(key);
704
- } else if (locale === defaultLocale) skippedFunctions++;
705
- }
706
- fs.mkdirSync(outDir, { recursive: true });
707
- const files = [];
708
- const write = (name, content) => {
709
- const file = path.join(outDir, name);
710
- fs.writeFileSync(file, content, "utf-8");
711
- files.push(path.relative(root, file));
712
- };
713
- if (format === "json") for (const [locale, values] of table) write(`${locale}.json`, JSON.stringify(Object.fromEntries(values), null, 2) + "\n");
714
- else if (format === "csv") write("translations.csv", [["key", ...table.keys()].map(csvCell).join(","), ...[...orderedKeys].map((key) => [key, ...[...table.values()].map((v) => v.get(key) ?? "")].map(csvCell).join(","))].join("\n") + "\n");
715
- else {
716
- const source = table.get(defaultLocale);
717
- for (const [locale, values] of table) {
718
- if (locale === defaultLocale) continue;
719
- const units = [...orderedKeys].map((key) => {
720
- const src = source?.get(key) ?? "";
721
- const target = values.get(key);
722
- const targetXml = target === void 0 ? "" : `<target>${escapeXml(target)}</target>`;
723
- return ` <trans-unit id="${escapeXml(key)}"><source>${escapeXml(src)}</source>${targetXml}</trans-unit>`;
743
+ localesInput = inputLocales.trim();
744
+ if (options.ai === void 0 && options.noAi === void 0) {
745
+ const confirmAi = await p.confirm({
746
+ message: "Configure AI agent support (Claude/Cursor skills, .mcp.json, AGENTS.md)?",
747
+ initialValue: true
724
748
  });
725
- write(`${locale}.xlf`, `<?xml version="1.0" encoding="UTF-8"?>
726
- <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
727
- <file original="dialex" source-language="${escapeXml(defaultLocale)}" target-language="${escapeXml(locale)}" datatype="plaintext">
728
- <body>
729
- ${units.join("\n")}
730
- </body>
731
- </file>
732
- </xliff>
749
+ if (!p.isCancel(confirmAi)) options.ai = confirmAi;
750
+ }
751
+ } else logger.info(`Auto-detected framework: ${pc.bold(framework)} (${detected.matchedRule})`);
752
+ const localeList = Array.from(/* @__PURE__ */ new Set([defaultLocale, ...localesInput.split(",").map((s) => s.trim()).filter(Boolean)]));
753
+ const configPath = fs.existsSync(path.join(root, "dialex.config.ts")) ? path.join(root, "dialex.config.ts") : fs.existsSync(path.join(root, "i18n.config.ts")) ? path.join(root, "i18n.config.ts") : path.join(root, "dialex.config.ts");
754
+ const configFileName = path.basename(configPath);
755
+ try {
756
+ if (fs.existsSync(configPath)) {
757
+ const mod = await loadFile(configPath);
758
+ if (mod && mod.exports.default) {
759
+ if (mod.exports.default.$args && mod.exports.default.$args[0]) {
760
+ mod.exports.default.$args[0].defaultLocale = defaultLocale;
761
+ mod.exports.default.$args[0].locales = localeList;
762
+ }
763
+ await writeFile(mod, configPath);
764
+ logger.success(`Updated ${pc.bold(configFileName)} via Magicast`);
765
+ }
766
+ } else {
767
+ const mod = parseModule(`import { defineConfig } from "dialexjs";
768
+
769
+ export default defineConfig({});
733
770
  `);
771
+ mod.exports.default.$args[0].defaultLocale = defaultLocale;
772
+ mod.exports.default.$args[0].locales = localeList;
773
+ await writeFile(mod, configPath);
774
+ logger.success(`Created ${pc.bold(configFileName)} via Magicast`);
734
775
  }
776
+ } catch (err) {
777
+ logger.warn(`Magicast config update notice: ${err.message || String(err)}`);
778
+ const fallback = `import { defineConfig } from "dialexjs";
779
+
780
+ export default defineConfig({
781
+ defaultLocale: "${defaultLocale}",
782
+ locales: [${localeList.map((l) => `"${l}"`).join(", ")}],
783
+ });
784
+ `;
785
+ fs.writeFileSync(configPath, fallback, "utf-8");
735
786
  }
736
- const quiet = options.json === true;
737
- const result = {
738
- success: true,
739
- files,
740
- keys: orderedKeys.size,
741
- skippedFunctions,
742
- skippedFiles: project.unsupported
743
- };
744
- if (quiet) console.log(JSON.stringify(result, null, 2));
745
- else {
746
- logger.success(`Exported ${orderedKeys.size} key${orderedKeys.size === 1 ? "" : "s"} to ${files.length} ${format} file${files.length === 1 ? "" : "s"} in ${path.relative(root, outDir) || "."}`);
747
- if (skippedFunctions > 0) logger.warn(`${skippedFunctions} function value${skippedFunctions === 1 ? "" : "s"} skipped (not translatable as text)`);
748
- for (const file of project.unsupported) logger.warn(`Skipped ${file}: unsupported dictionary shape`);
749
- }
750
- return result;
751
- }
752
- //#endregion
753
- //#region src/commands/import.ts
754
- const unescapeXml = (value) => value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
755
- function parseCsv(text) {
756
- const rows = [];
757
- let row = [];
758
- let cell = "";
759
- let quoted = false;
760
- for (let i = 0; i < text.length; i++) {
761
- const ch = text[i];
762
- if (quoted) {
763
- if (ch === "\"" && text[i + 1] === "\"") {
764
- cell += "\"";
765
- i++;
766
- } else if (ch === "\"") quoted = false;
767
- else cell += ch;
768
- } else if (ch === "\"") quoted = true;
769
- else if (ch === ",") {
770
- row.push(cell);
771
- cell = "";
772
- } else if (ch === "\n" || ch === "\r") {
773
- if (ch === "\r" && text[i + 1] === "\n") i++;
774
- row.push(cell);
775
- rows.push(row);
776
- row = [];
777
- cell = "";
778
- } else cell += ch;
779
- }
780
- if (cell !== "" || row.length > 0) {
781
- row.push(cell);
782
- rows.push(row);
787
+ if (framework === "nuxt") {
788
+ const nuxtConfig = [
789
+ "nuxt.config.ts",
790
+ "nuxt.config.js",
791
+ "nuxt.config.mjs"
792
+ ].map((f) => path.join(root, f)).find((f) => fs.existsSync(f));
793
+ if (nuxtConfig) try {
794
+ const nMod = await loadFile(nuxtConfig);
795
+ addNuxtModule(nMod, "dialexjs/nuxt", "dialex", {});
796
+ await writeFile(nMod, nuxtConfig);
797
+ logger.success(`Registered dialexjs/nuxt in ${pc.bold(path.basename(nuxtConfig))} via Magicast`);
798
+ } catch {
799
+ logger.warn("Could not update the Nuxt config; add the dialexjs/nuxt module manually.");
800
+ }
783
801
  }
784
- return rows.filter((r) => r.some((c) => c !== ""));
785
- }
786
- function parseEntries(content, format, fallbackLocale) {
787
- const entries = [];
788
- if (format === "json") {
789
- const data = JSON.parse(content);
790
- if (Object.keys(data).length > 0 && Object.values(data).every((v) => v && typeof v === "object" && !Array.isArray(v))) {
791
- for (const [locale, values] of Object.entries(data)) for (const [key, value] of Object.entries(values)) if (typeof value === "string") entries.push({
792
- locale,
793
- key,
794
- value
795
- });
796
- } else {
797
- if (!fallbackLocale) throw new Error("Cannot infer the locale; pass --locale.");
798
- for (const [key, value] of Object.entries(data)) if (typeof value === "string") entries.push({
799
- locale: fallbackLocale,
800
- key,
801
- value
802
- });
802
+ const targetDir = fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root;
803
+ const dictPath = path.join(targetDir, "home.content.ts");
804
+ if (!fs.existsSync(dictPath)) {
805
+ const dictContent = `import { defineDictionary } from "dialexjs";
806
+
807
+ export default defineDictionary("home", {
808
+ ${localeList.map((loc) => ` ${loc}: {
809
+ title: "Welcome to Dialex (${loc})!",
810
+ description: "Type-safe internationalization.",
811
+ greeting: (name: string) => "Hello, " + name + "!",
812
+ },`).join("\n")}
813
+ });
814
+ `;
815
+ fs.writeFileSync(dictPath, dictContent, "utf-8");
816
+ logger.success(`Created starter dictionary at ${pc.bold(path.relative(root, dictPath))}`);
817
+ } else logger.info("home.content.ts already exists, skipping creation.");
818
+ generateDictionaries(root);
819
+ logger.success("Generated types and standalone dictionary registry!");
820
+ const pkgPath = path.join(root, "package.json");
821
+ if (fs.existsSync(pkgPath)) try {
822
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
823
+ const added = addRequiredPackages(pkg);
824
+ pkg.scripts = pkg.scripts || {};
825
+ const addScript = !pkg.scripts["dx:generate"];
826
+ if (addScript) pkg.scripts["dx:generate"] = "dx generate";
827
+ if (added.length > 0 || addScript) fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
828
+ if (added.length > 0) {
829
+ logger.success(`Added ${added.join(" and ")} to package.json`);
830
+ logger.info(`Run ${pc.bold(installCommand(root))} to install them.`);
803
831
  }
804
- } else if (format === "csv") {
805
- const [header, ...rows] = parseCsv(content);
806
- if (!header || header[0] !== "key") throw new Error("CSV must start with a \"key\" column.");
807
- for (const row of rows) header.slice(1).forEach((locale, i) => {
808
- const value = row[i + 1];
809
- if (value !== void 0 && value !== "") entries.push({
810
- locale,
811
- key: row[0],
812
- value
813
- });
814
- });
815
- } else {
816
- const locale = /target-language="([^"]*)"/.exec(content)?.[1] ?? fallbackLocale;
817
- if (!locale) throw new Error("Cannot infer the locale; pass --locale.");
818
- for (const match of content.matchAll(/<trans-unit\b[^>]*\bid="([^"]*)"[^>]*>([\s\S]*?)<\/trans-unit>/g)) {
819
- const target = /<target\b[^>]*>([\s\S]*?)<\/target>/.exec(match[2])?.[1];
820
- if (target !== void 0 && target !== "") entries.push({
821
- locale,
822
- key: unescapeXml(match[1]),
823
- value: unescapeXml(target)
824
- });
832
+ if (addScript) logger.success("Added \"dx:generate\" script to package.json");
833
+ } catch {
834
+ logger.warn("Could not update package.json; add dialexjs and @dialexjs/cli manually.");
835
+ }
836
+ else logger.warn("No package.json found; install dialexjs and @dialexjs/cli in your project.");
837
+ if (options.ai === true || options.yes && options.ai !== false && options.noAi !== true) try {
838
+ const skillDir = path.join(root, ".agents/skills/dialex");
839
+ fs.mkdirSync(skillDir, { recursive: true });
840
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---
841
+ name: dialex
842
+ description: Guide for internationalizing JavaScript and TypeScript apps with Dialex. Use when adding translations, creating content dictionaries, configuring locales, or using Dialex with frameworks like Next.js, React, Express, Hono, Fastify, SvelteKit, and Nuxt.
843
+ ---
844
+
845
+ # Dialex Internationalization
846
+
847
+ Dialex is a high-performance, type-safe internationalization toolchain for JavaScript and TypeScript.
848
+
849
+ ## Core Concepts
850
+ - Dictionaries are defined with \`defineDictionary("name", { ... })\` in \`*.content.ts\` files.
851
+ - Keys must match across all locales.
852
+ - ICU message syntax is supported for plurals and selects.
853
+ - Parity is validated at compile time with \`dialex check\`.
854
+
855
+ ## Useful Commands
856
+ - \`dialex check --json\`: Check dictionary parity and references.
857
+ - \`dialex check --fix\`: Automatically insert missing keys marked with [TODO].
858
+ - \`dialex generate\`: Rebuild standalone dictionary bundle and types.
859
+ - \`dialex translate --locale <locale>\`: Automatically translate missing keys.
860
+ `, "utf-8");
861
+ const mcpConfigPath = path.join(root, ".mcp.json");
862
+ let mcpConfig = {};
863
+ if (fs.existsSync(mcpConfigPath)) try {
864
+ mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, "utf-8"));
865
+ } catch {
866
+ mcpConfig = {};
867
+ }
868
+ mcpConfig.mcpServers = mcpConfig.mcpServers || {};
869
+ mcpConfig.mcpServers.dialex = mcpConfig.mcpServers.dialex || {
870
+ command: "npx",
871
+ args: ["@dialexjs/mcp"]
872
+ };
873
+ fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
874
+ const agentsMdPath = path.join(root, "AGENTS.md");
875
+ const dialexAgentSnippet = `
876
+ ## Internationalization (Dialex)
877
+
878
+ This project uses [Dialex](https://github.com/awaiden/dialex) for type-safe internationalization.
879
+ - Dictionaries are in \`*.content.ts\` files using \`defineDictionary\`.
880
+ - Run \`dialex check --json\` to verify parity.
881
+ - Run \`dialex check --fix\` to insert missing keys marked with \`[TODO]\`.
882
+ - Run \`dx generate\` (alias of \`dialex\`) or \`npm run dx:generate\` to compile dictionary bundles and update types.
883
+ `;
884
+ if (!fs.existsSync(agentsMdPath)) fs.writeFileSync(agentsMdPath, dialexAgentSnippet.trimStart(), "utf-8");
885
+ else {
886
+ const existing = fs.readFileSync(agentsMdPath, "utf-8");
887
+ if (!existing.includes("Dialex")) fs.writeFileSync(agentsMdPath, existing.trimEnd() + "\n" + dialexAgentSnippet, "utf-8");
825
888
  }
889
+ logger.success("Configured AI assistant support (.agents/skills, .mcp.json, AGENTS.md)");
890
+ } catch (err) {
891
+ logger.warn(`Could not finish AI configuration: ${err.message || String(err)}`);
826
892
  }
827
- return entries;
893
+ logger.log("");
894
+ logger.info(pc.bold(`Quick start for ${framework.toUpperCase()}:`));
895
+ switch (framework) {
896
+ case "hono":
897
+ logger.log(`
898
+ import { Hono } from "hono";
899
+ import { dialex } from "dialexjs/hono";
900
+ import dictionaries from "./src/dialex.generated.js";
901
+
902
+ const app = new Hono();
903
+ app.use("*", dialex({ dictionaries }));
904
+
905
+ app.get("/", (c) => {
906
+ const dict = c.var.getDictionary("home");
907
+ return c.json({ title: dict.title });
908
+ });
909
+ `);
910
+ break;
911
+ case "fastify":
912
+ logger.log(`
913
+ import Fastify from "fastify";
914
+ import { dialexPlugin } from "dialexjs/fastify";
915
+ import dictionaries from "./src/dialex.generated.js";
916
+
917
+ const app = Fastify();
918
+ await app.register(dialexPlugin, { dictionaries });
919
+
920
+ app.get("/", (req) => {
921
+ const dict = req.getDictionary("home");
922
+ return { title: dict.title };
923
+ });
924
+ `);
925
+ break;
926
+ case "express":
927
+ logger.log(`
928
+ import express from "express";
929
+ import { dialex } from "dialexjs/express";
930
+ import dictionaries from "./src/dialex.generated.js";
931
+
932
+ const app = express();
933
+ app.use(dialex({ dictionaries }));
934
+
935
+ app.get("/", (req, res) => {
936
+ const dict = req.getDictionary("home");
937
+ res.json({ title: dict.title });
938
+ });
939
+ `);
940
+ break;
941
+ case "koa":
942
+ logger.log(`
943
+ import Koa from "koa";
944
+ import { dialex } from "dialexjs/koa";
945
+ import dictionaries from "./src/dialex.generated.js";
946
+
947
+ const app = new Koa();
948
+ app.use(dialex({ dictionaries }));
949
+
950
+ app.use((ctx) => {
951
+ const dict = ctx.getDictionary("home");
952
+ ctx.body = { title: dict.title };
953
+ });
954
+ `);
955
+ break;
956
+ case "nestjs":
957
+ logger.log(`
958
+ import { Module } from "@nestjs/common";
959
+ import { DialexModule } from "dialexjs/nestjs";
960
+ import dictionaries from "./src/dialex.generated.js";
961
+
962
+ @Module({
963
+ imports: [DialexModule.forRoot({ dictionaries })],
964
+ })
965
+ export class AppModule {}
966
+ `);
967
+ break;
968
+ case "next":
969
+ logger.log(`
970
+ // src/dialex.ts
971
+ import { createDialexServer } from "dialexjs/server";
972
+ import { dialex } from "./dialex.generated";
973
+
974
+ export const { getDictionary, getT } = createDialexServer(dialex);
975
+
976
+ // app/[locale]/page.tsx (Server Component)
977
+ import { getDictionary } from "../../dialex";
978
+
979
+ export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
980
+ const { locale = "en" } = await params;
981
+ const dict = getDictionary("home", locale);
982
+ return <h1>{dict.title}</h1>;
828
983
  }
829
- function inferFormat(file) {
830
- const ext = path.extname(file).toLowerCase();
831
- if (ext === ".json") return "json";
832
- if (ext === ".csv") return "csv";
833
- if (ext === ".xlf" || ext === ".xliff") return "xliff";
834
- throw new Error(`Cannot infer format from "${ext}". Pass --format json|csv|xliff.`);
984
+
985
+ // For client components, render the provider inside a "use client" file:
986
+ // "use client";
987
+ // import { DialexProvider } from "dialexjs/react";
988
+ // import { dialex } from "./dialex.generated";
989
+ // export const Providers = ({ children }) => <DialexProvider {...dialex}>{children}</DialexProvider>;
990
+ `);
991
+ break;
992
+ case "react":
993
+ logger.log(`
994
+ // src/main.tsx
995
+ import { createRoot } from "react-dom/client";
996
+ import { DialexProvider } from "dialexjs/react";
997
+ import { dialex } from "./dialex.generated";
998
+ import App from "./App";
999
+
1000
+ createRoot(document.getElementById("root")!).render(
1001
+ <DialexProvider {...dialex}>
1002
+ <App />
1003
+ </DialexProvider>,
1004
+ );
1005
+
1006
+ // In components: const dict = useDictionary("home")
1007
+ `);
1008
+ break;
1009
+ case "elysia":
1010
+ logger.log(`
1011
+ import { Elysia } from "elysia";
1012
+ import { dialex } from "dialexjs/elysia";
1013
+ import dictionaries from "./src/dialex.generated.js";
1014
+
1015
+ new Elysia()
1016
+ .use(dialex({ dictionaries }))
1017
+ .get("/", ({ getDictionary }) => ({ title: getDictionary("home").title }))
1018
+ .listen(3000);
1019
+ `);
1020
+ break;
1021
+ case "sveltekit":
1022
+ logger.log(`
1023
+ // src/hooks.server.ts
1024
+ import { dialexHandle } from "dialexjs/sveltekit";
1025
+ import dictionaries from "./dialex.generated.js";
1026
+
1027
+ export const handle = dialexHandle({ dictionaries });
1028
+
1029
+ // src/app.html: <html lang="%dialex.lang%">
1030
+ // In load functions: locals.getDictionary("home")
1031
+ `);
1032
+ break;
1033
+ case "astro":
1034
+ logger.log(`
1035
+ // src/middleware.ts
1036
+ import { dialex } from "dialexjs/astro";
1037
+ import dictionaries from "./dialex.generated.js";
1038
+
1039
+ export const onRequest = dialex({ dictionaries });
1040
+
1041
+ // In pages: Astro.locals.getDictionary("home")
1042
+ `);
1043
+ break;
1044
+ case "vue":
1045
+ logger.log(`
1046
+ // src/main.ts
1047
+ import { createApp } from "vue";
1048
+ import { createDialex } from "dialexjs/vue";
1049
+ import { dialex } from "./dialex.generated";
1050
+ import App from "./App.vue";
1051
+
1052
+ createApp(App).use(createDialex({ ...dialex })).mount("#app");
1053
+
1054
+ // In components: const dict = useDictionary("home")
1055
+ `);
1056
+ break;
1057
+ case "nuxt":
1058
+ logger.log(`
1059
+ // nuxt.config.ts
1060
+ export default defineNuxtConfig({
1061
+ modules: ["dialexjs/nuxt"],
1062
+ });
1063
+
1064
+ // In components: const dict = useDictionary("home")
1065
+ `);
1066
+ break;
1067
+ default: logger.log(`
1068
+ Import dictionaries and register your framework middleware to begin!
1069
+ Run \`npm run dx:generate\` whenever you add new dictionary files.
1070
+ `);
1071
+ }
1072
+ logger.info("Keep dialex.generated.ts current: run `dx generate --watch` while you develop, or use the Dialex VS Code extension, which regenerates on save.");
1073
+ if (isInteractive) p.outro(pc.green("✔ dialex is configured and ready to go!"));
835
1074
  }
1075
+ //#endregion
1076
+ //#region src/commands/lock.ts
836
1077
  /**
837
- * Why `value` must not replace the current text, if it is an ICU message that is invalid or uses
838
- * different arguments than the same key in another locale.
1078
+ * Records the current translations as up to date with the current source text, so later edits to
1079
+ * the source can be noticed. Translations that are already out of date are left alone unless
1080
+ * `--all` is given. Run it once when you adopt the lock file, and after reviewing stale ones.
839
1081
  */
840
- function icuProblem(df, defaultLocale, locale, keyPath, value) {
841
- if (!value.includes("{")) return void 0;
842
- let signature;
843
- try {
844
- signature = argumentSignature(parseMessage(value));
845
- } catch (error) {
846
- return `invalid ICU: ${error.message}`;
847
- }
848
- const locales = listLocales(df);
849
- for (const other of [defaultLocale, ...locales.filter((l) => l !== defaultLocale)]) {
850
- if (other === locale) continue;
851
- const reference = getString(df, other, keyPath);
852
- if (!reference?.includes("{")) continue;
853
- try {
854
- const expected = argumentSignature(parseMessage(reference));
855
- return expected === signature ? void 0 : `arguments differ from "${other}": expected ${expected || "none"}, found ${signature || "none"}`;
856
- } catch {
857
- continue;
858
- }
859
- }
860
- }
861
- async function runImport(options) {
1082
+ async function runLock(options = {}) {
862
1083
  const root = options.cwd || process.cwd();
863
- const file = path.resolve(root, options.file);
864
- const format = options.format || inferFormat(file);
865
- const fallbackLocale = options.locale ?? path.basename(file, path.extname(file));
866
- const entries = parseEntries(fs.readFileSync(file, "utf-8"), format, options.locale ?? fallbackLocale);
867
1084
  const project = await loadProject(root, options.config);
868
- const byName = new Map(project.dictionaries.map((d) => [d.name, d]));
869
- const quiet = options.json === true;
1085
+ const source = options.source ?? project.config.translate?.sourceLocale ?? project.config.defaultLocale ?? "en";
1086
+ const { lock } = lockFor(root, source);
870
1087
  const result = {
871
1088
  success: true,
872
- created: 0,
873
- updated: 0,
874
- skipped: [],
875
- rejected: [],
876
- unknown: [],
877
- errors: []
1089
+ sourceLocale: source,
1090
+ recorded: 0,
1091
+ stale: []
878
1092
  };
879
- const touched = /* @__PURE__ */ new Set();
880
- for (const { locale, key, value } of entries) {
881
- const [name, ...keyPath] = key.split(".");
882
- const dict = byName.get(name);
883
- if (!dict || keyPath.length === 0) {
884
- result.unknown.push(key);
885
- continue;
886
- }
887
- if (!listLocales(dict.df).some((l) => hasPath(dict.df, l, keyPath)) && !options.allowNew) {
888
- result.unknown.push(key);
889
- continue;
890
- }
891
- const problem = icuProblem(dict.df, project.config.defaultLocale ?? "en", locale, keyPath, value);
892
- if (problem) {
893
- result.rejected.push(`${locale}:${key} (${problem})`);
894
- continue;
895
- }
896
- const outcome = setString(dict.df, locale, keyPath, value);
897
- if (outcome === "skipped") result.skipped.push(`${locale}:${key}`);
898
- else {
899
- result[outcome]++;
900
- touched.add(dict.rel);
1093
+ for (const dict of project.dictionaries) {
1094
+ const sourceText = new Map(listLeaves(dict.df, source).filter((leaf) => leaf.kind === "string" && leaf.value !== void 0).map((leaf) => [leaf.path.join("."), leaf.value]));
1095
+ for (const locale of listLocales(dict.df)) {
1096
+ if (locale === source) continue;
1097
+ for (const leaf of listLeaves(dict.df, locale)) {
1098
+ if (leaf.kind !== "string" || leaf.value === void 0) continue;
1099
+ if (leaf.value.startsWith("[TODO] ")) continue;
1100
+ const text = sourceText.get(leaf.path.join("."));
1101
+ if (text === void 0) continue;
1102
+ const key = toKey(dict.name, leaf.path);
1103
+ const known = lock.locales[locale]?.[key] !== void 0;
1104
+ if (known && isStale(lock, locale, key, text)) {
1105
+ if (!options.all) {
1106
+ result.stale.push(`${locale}:${key}`);
1107
+ continue;
1108
+ }
1109
+ } else if (known) continue;
1110
+ record(lock, locale, key, text);
1111
+ result.recorded++;
1112
+ }
901
1113
  }
902
1114
  }
903
- for (const dict of project.dictionaries) if (touched.has(dict.rel)) await saveDictionaryFile(dict.df);
904
- if (result.rejected.length > 0) result.success = false;
905
- if (quiet) console.log(JSON.stringify(result, null, 2));
1115
+ if (result.recorded > 0 || !fs.existsSync(lockPath(root))) writeLock(root, lock);
1116
+ if (options.json) console.log(JSON.stringify(result, null, 2));
906
1117
  else {
907
- logger.success(`Imported ${result.created + result.updated} value${result.created + result.updated === 1 ? "" : "s"} (${result.created} added, ${result.updated} updated)`);
908
- if (result.unknown.length > 0) logger.warn(`${result.unknown.length} unknown key${result.unknown.length === 1 ? "" : "s"} ignored (use --allow-new to add): ${result.unknown.slice(0, 5).join(", ")}${result.unknown.length > 5 ? ", ..." : ""}`);
909
- if (result.rejected.length > 0) logger.warn(`${result.rejected.length} value${result.rejected.length === 1 ? "" : "s"} rejected: ${result.rejected.slice(0, 5).join("; ")}`);
910
- if (result.skipped.length > 0) logger.warn(`${result.skipped.length} non-text value${result.skipped.length === 1 ? "" : "s"} left unchanged: ${result.skipped.slice(0, 5).join(", ")}`);
1118
+ logger.success(`Recorded ${result.recorded} translation${result.recorded === 1 ? "" : "s"} in ${path.basename(LOCK_FILE)} (source "${source}")`);
1119
+ if (result.stale.length > 0) {
1120
+ logger.warn(`${result.stale.length} out of date and left as they are: ${result.stale.slice(0, 5).join(", ")}${result.stale.length > 5 ? ", ..." : ""}`);
1121
+ logger.log(` ${pc.dim("Re-translate them with")} dialex translate --stale${pc.dim(", or accept them with")} dialex lock --all`);
1122
+ }
911
1123
  }
912
1124
  return result;
913
1125
  }
@@ -960,6 +1172,8 @@ async function runTranslate(options = {}) {
960
1172
  const everyLocale = new Set(project.config.locales ?? []);
961
1173
  for (const { df } of project.dictionaries) listLocales(df).forEach((l) => everyLocale.add(l));
962
1174
  const targets = (options.locale?.length ? options.locale : [...everyLocale]).filter((l) => l !== source);
1175
+ const { lock, replaced } = lockFor(root, source);
1176
+ if (replaced && !options.json) logger.warn(`dialex.lock.json was recorded from another source locale; starting a new one from "${source}".`);
963
1177
  const tasks = [];
964
1178
  for (const dict of project.dictionaries) {
965
1179
  const sourceLeaves = listLeaves(dict.df, source).filter((leaf) => leaf.kind === "string" && leaf.value !== void 0);
@@ -967,13 +1181,17 @@ async function runTranslate(options = {}) {
967
1181
  const existing = new Map(listLeaves(dict.df, locale).map((leaf) => [leaf.path.join("\0"), leaf]));
968
1182
  for (const leaf of sourceLeaves) {
969
1183
  const current = existing.get(leaf.path.join("\0"));
970
- if (!current || current.kind === "string" && current.value?.startsWith("[TODO] ")) tasks.push({
1184
+ const key = toKey(dict.name, leaf.path);
1185
+ const placeholder = !current || current.kind === "string" && current.value?.startsWith("[TODO] ");
1186
+ const stale = !placeholder && options.stale === true && current?.kind === "string" && isStale(lock, locale, key, leaf.value);
1187
+ if (placeholder || stale) tasks.push({
971
1188
  dict,
972
1189
  path: leaf.path,
973
1190
  file: dict.rel,
974
1191
  locale,
975
- key: toKey(dict.name, leaf.path),
976
- source: leaf.value
1192
+ key,
1193
+ source: leaf.value,
1194
+ ...stale ? { stale: true } : {}
977
1195
  });
978
1196
  }
979
1197
  }
@@ -995,7 +1213,7 @@ async function runTranslate(options = {}) {
995
1213
  if (quiet) console.log(JSON.stringify(result, null, 2));
996
1214
  else {
997
1215
  logger.info(`Would translate ${tasks.length} string${tasks.length === 1 ? "" : "s"} from "${source}":`);
998
- for (const t of tasks) logger.log(` ${pc.dim(t.locale)} ${t.key} ${pc.dim(JSON.stringify(t.source))}`);
1216
+ for (const t of tasks) logger.log(` ${pc.dim(t.locale)} ${t.key} ${pc.dim(JSON.stringify(t.source))}${t.stale ? pc.yellow(" (out of date)") : ""}`);
999
1217
  }
1000
1218
  return result;
1001
1219
  }
@@ -1029,11 +1247,13 @@ async function runTranslate(options = {}) {
1029
1247
  continue;
1030
1248
  }
1031
1249
  task.translated = translated;
1250
+ record(lock, locale, task.key, task.source);
1032
1251
  result.translated++;
1033
1252
  touched.add(task.dict);
1034
1253
  }
1035
1254
  }
1036
1255
  for (const dict of touched) await saveDictionaryFile(dict.df);
1256
+ if (result.translated > 0) writeLock(root, lock);
1037
1257
  if (result.rejected.length > 0) result.success = false;
1038
1258
  if (quiet) console.log(JSON.stringify(result, null, 2));
1039
1259
  else {
@@ -1045,17 +1265,17 @@ async function runTranslate(options = {}) {
1045
1265
  //#endregion
1046
1266
  //#region src/index.ts
1047
1267
  const program = new Command();
1048
- program.name("dialex").description("Dialex CLI - High-performance, type-safe internationalization compiler and tooling").version("0.2.3");
1268
+ program.name("dialex").description("Dialex CLI - High-performance, type-safe internationalization compiler and tooling").version(version);
1049
1269
  program.command("generate").alias("gen").description("Generate standalone dictionary bundle and TypeScript declarations").option("-w, --watch", "Watch dictionary files for changes").option("-o, --output <path>", "Custom output file path for generated dictionaries").option("-c, --config <path>", "Custom config path").option("--json", "Print machine-readable JSON").action(async (opts) => {
1050
1270
  await runGenerate(opts);
1051
1271
  });
1052
1272
  program.command("init").description("Initialize Dialex in the current project").option("-f, --framework <framework>", "Target framework (hono, express, fastify, koa, nestjs, next, react)").option("-d, --default-locale <locale>", "Default locale (e.g. en)").option("-l, --locales <locales>", "Comma-separated supported locales (e.g. en,tr)").option("-y, --yes", "Skip prompts and use defaults").option("--ai", "Configure AI agent support (.agents/skills, .mcp.json, AGENTS.md)").option("--no-ai", "Skip AI agent configuration").action(async (opts) => {
1053
1273
  await runInit(opts);
1054
1274
  });
1055
- program.command("check").alias("lint").description("Verify dictionary key parity and locale completeness").option("-c, --config <path>", "Custom config path").option("--json", "Print machine-readable JSON").option("--github", "Print GitHub Actions annotations").option("--fix", "Insert missing keys marked with [TODO]").option("--src <globs...>", "Source globs to scan for dictionary references").action(async (opts) => {
1275
+ program.command("check").alias("lint").description("Verify dictionary key parity and locale completeness").option("-c, --config <path>", "Custom config path").option("--json", "Print machine-readable JSON").option("--github", "Print GitHub Actions annotations").option("--fix", "Insert missing keys marked with [TODO]").option("--fail-on-stale", "Treat translations whose source text changed as errors").option("--src <globs...>", "Source globs to scan for dictionary references").action(async (opts) => {
1056
1276
  await runCheck(opts);
1057
1277
  });
1058
- program.command("export").description("Export dictionary strings for translators (json, csv, or xliff)").option("-f, --format <format>", "Output format: json, csv, xliff", "json").option("-o, --out <dir>", "Output directory", "i18n-export").option("-l, --locale <locales...>", "Limit to these locales").option("-c, --config <path>", "Custom config path").option("--json", "Print machine-readable JSON").action(async (opts) => {
1278
+ program.command("export").description("Export dictionary strings for translators (json, csv, or xliff)").option("-f, --format <format>", "Output format: json, csv, xliff", "json").option("-o, --out <dir>", "Output directory", "dialex-export").option("-l, --locale <locales...>", "Limit to these locales").option("-c, --config <path>", "Custom config path").option("--json", "Print machine-readable JSON").action(async (opts) => {
1059
1279
  await runExport(opts);
1060
1280
  });
1061
1281
  program.command("import <file>").description("Write translated strings from a json, csv, or xliff file back into dictionaries").option("-f, --format <format>", "Input format (inferred from the extension)").option("-l, --locale <locale>", "Locale for single-locale files").option("--allow-new", "Add keys that do not exist in any locale yet").option("-c, --config <path>", "Custom config path").option("--json", "Print machine-readable JSON").action(async (file, opts) => {
@@ -1064,7 +1284,7 @@ program.command("import <file>").description("Write translated strings from a js
1064
1284
  ...opts
1065
1285
  });
1066
1286
  });
1067
- program.command("translate").description("Fill missing translations with the provider configured in dialex.config.ts").option("-l, --locale <locales...>", "Target locales (default: all configured)").option("-s, --source <locale>", "Source locale (default: translate.sourceLocale or defaultLocale)").option("--dry-run", "List what would be translated without calling the provider").option("-c, --config <path>", "Custom config path").option("--json", "Print machine-readable JSON").action(async (opts) => {
1287
+ program.command("translate").description("Fill missing translations with the provider configured in dialex.config.ts").option("-l, --locale <locales...>", "Target locales (default: all configured)").option("-s, --source <locale>", "Source locale (default: translate.sourceLocale or defaultLocale)").option("--dry-run", "List what would be translated without calling the provider").option("--stale", "Also re-translate strings whose source text changed since they were translated").option("-c, --config <path>", "Custom config path").option("--json", "Print machine-readable JSON").action(async (opts) => {
1068
1288
  try {
1069
1289
  await runTranslate(opts);
1070
1290
  } catch (err) {
@@ -1076,6 +1296,14 @@ program.command("translate").description("Fill missing translations with the pro
1076
1296
  process.exit(1);
1077
1297
  }
1078
1298
  });
1299
+ program.command("lock").description("Record translations as up to date with the current source text (dialex.lock.json)").option("-s, --source <locale>", "Locale the translations were made from").option("--all", "Also accept translations that are already out of date").option("--json", "Print machine-readable JSON").option("-c, --config <path>", "Custom config path").action(async (opts) => {
1300
+ try {
1301
+ await runLock(opts);
1302
+ } catch (err) {
1303
+ logger.error(err.message || String(err));
1304
+ process.exit(1);
1305
+ }
1306
+ });
1079
1307
  program.parse();
1080
1308
  //#endregion
1081
1309
  export {};