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