@x18n/cli 0.0.2 → 0.0.5
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/cli.d.mts +4 -0
- package/dist/cli.mjs +705 -0
- package/dist/context-Za_ea1Nk.mjs +148 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +1 -0
- package/dist/presets.d.mts +1 -0
- package/dist/presets.mjs +2 -0
- package/dist/ui-Bp3xyqr_.mjs +74 -0
- package/package.json +9 -9
- package/skills/x18n/SKILL.md +54 -15
- package/skills/x18n-check/SKILL.md +46 -0
- package/skills/x18n-setup/SKILL.md +27 -0
- /package/skills/{x18n → x18n-setup}/references/migrate-i18n-ally.md +0 -0
package/dist/cli.d.mts
ADDED
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { a as openWorkspace, c as readInput, d as stdinArg, f as useColor, i as exitCodeOf, l as runPlan, n as displayPath, o as out, p as wireDiagnostics, r as errorInfo, s as printJson, t as cwd, u as singleProject } from "./context-Za_ea1Nk.mjs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { PluginFileSchema, X18nError, isJsonObject, parseMessageBatch, placeholderProblems, renderPrompt, toWireRange, validate } from "@x18n/core";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import cac from "cac";
|
|
6
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { configFileNames, findConfigFile, loadWorkspace, migrateI18nAlly } from "@x18n/node";
|
|
10
|
+
import { extractItems, findCandidates, toPlanFile } from "@x18n/builtins/extractors";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { namespaceFromPath } from "@x18n/builtins/finders";
|
|
13
|
+
import { parse, stringify } from "yaml";
|
|
14
|
+
//#region package.json
|
|
15
|
+
var version = "0.0.5";
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/commands/change.ts
|
|
18
|
+
async function project(options) {
|
|
19
|
+
return singleProject(await openWorkspace(options));
|
|
20
|
+
}
|
|
21
|
+
async function set(key, options) {
|
|
22
|
+
if (options.value === void 0) throw new X18nError("X18N4008", "pass --value <text> (or --value - to read stdin)");
|
|
23
|
+
const loaded = await project(options);
|
|
24
|
+
const value = options.value === "\0stdin" ? (await readInput(stdinArg)).replace(/\r?\n$/, "") : String(options.value);
|
|
25
|
+
const locale = options.locale ?? loaded.project.sourceLocale;
|
|
26
|
+
await runPlan("set", loaded, loaded.project.plan([{
|
|
27
|
+
type: "set",
|
|
28
|
+
key: loaded.project.parseKey(key),
|
|
29
|
+
locale,
|
|
30
|
+
value
|
|
31
|
+
}]), options);
|
|
32
|
+
}
|
|
33
|
+
async function apply(source, options) {
|
|
34
|
+
const loaded = await project(options);
|
|
35
|
+
const p = loaded.project;
|
|
36
|
+
let data;
|
|
37
|
+
try {
|
|
38
|
+
data = JSON.parse(await readInput(source));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw new X18nError("X18N4008", `${source} is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
41
|
+
}
|
|
42
|
+
const ops = parseMessageBatch(p, data);
|
|
43
|
+
const rejected = placeholderProblems(p, ops);
|
|
44
|
+
if (rejected.length > 0 && !options.force) {
|
|
45
|
+
if (options.json) {
|
|
46
|
+
printJson("apply", {
|
|
47
|
+
applied: false,
|
|
48
|
+
rejected
|
|
49
|
+
}, [], [new X18nError("X18N4007", `${rejected.length} values`).toJSON()]);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
for (const r of rejected) out(`rejected ${r.locale} ${r.key}: ${[r.missing.length ? `missing ${r.missing.join(", ")}` : "", r.extra.length ? `unexpected ${r.extra.join(", ")}` : ""].filter(Boolean).join("; ")}`);
|
|
53
|
+
out(`${rejected.length} values have placeholder problems; nothing was written. Fix them or pass --force.`);
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
await runPlan("apply", loaded, p.plan(ops), options);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
async function rename(from, to, options) {
|
|
60
|
+
const loaded = await project(options);
|
|
61
|
+
const p = loaded.project;
|
|
62
|
+
const op = {
|
|
63
|
+
type: "rename",
|
|
64
|
+
from: p.parseKey(from),
|
|
65
|
+
to: p.parseKey(to),
|
|
66
|
+
usages: options.usages !== false
|
|
67
|
+
};
|
|
68
|
+
await runPlan("rename", loaded, p.plan([op]), options);
|
|
69
|
+
}
|
|
70
|
+
async function remove(key, options) {
|
|
71
|
+
const loaded = await project(options);
|
|
72
|
+
const locales = options.locale === void 0 ? "all" : [options.locale].flat();
|
|
73
|
+
await runPlan("delete", loaded, loaded.project.plan([{
|
|
74
|
+
type: "delete",
|
|
75
|
+
key: loaded.project.parseKey(key),
|
|
76
|
+
locales
|
|
77
|
+
}]), options);
|
|
78
|
+
}
|
|
79
|
+
async function fill(options) {
|
|
80
|
+
const loaded = await project(options);
|
|
81
|
+
const p = loaded.project;
|
|
82
|
+
const targets = options.locale === void 0 ? p.locales.filter((l) => l !== p.sourceLocale) : [options.locale].flat();
|
|
83
|
+
const ops = [];
|
|
84
|
+
for (const locale of targets) for (const key of p.keys({ missing: locale })) {
|
|
85
|
+
if (p.message(key, locale) !== void 0) continue;
|
|
86
|
+
ops.push({
|
|
87
|
+
type: "set",
|
|
88
|
+
key,
|
|
89
|
+
locale,
|
|
90
|
+
value: options.copySource ? p.message(key, p.sourceLocale)?.value ?? "" : ""
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
await runPlan("fill", loaded, p.plan(ops), options);
|
|
94
|
+
}
|
|
95
|
+
async function format(options) {
|
|
96
|
+
const loaded = await project(options);
|
|
97
|
+
await runPlan("format", loaded, loaded.project.plan([{ type: "format" }]), options);
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/commands/check.ts
|
|
101
|
+
function list(value) {
|
|
102
|
+
return value === void 0 ? [] : [value].flat().flatMap((item) => String(item).split(",")).map((item) => item.trim()).filter(Boolean);
|
|
103
|
+
}
|
|
104
|
+
function filterDiagnostics(diagnostics, options) {
|
|
105
|
+
const locales = list(options.locale);
|
|
106
|
+
const only = list(options.only);
|
|
107
|
+
const skip = list(options.skip);
|
|
108
|
+
return diagnostics.filter((d) => locales.length === 0 || d.locale === void 0 || locales.includes(d.locale) || d.code === "missing-translation" && locales.some((l) => d.message.includes(l))).filter((d) => only.length === 0 || only.includes(d.code)).filter((d) => !skip.includes(d.code)).map((d) => options.strict && d.severity === "warning" ? {
|
|
109
|
+
...d,
|
|
110
|
+
severity: "error"
|
|
111
|
+
} : d);
|
|
112
|
+
}
|
|
113
|
+
function reportInputs(workspace, options) {
|
|
114
|
+
return workspace.projects.map((loaded) => {
|
|
115
|
+
const analysis = loaded.project.analyze();
|
|
116
|
+
const diagnostics = filterDiagnostics(analysis.diagnostics, options);
|
|
117
|
+
return {
|
|
118
|
+
project: loaded.name,
|
|
119
|
+
text: (path) => loaded.project.text(path),
|
|
120
|
+
diagnostics,
|
|
121
|
+
coverage: analysis.coverage,
|
|
122
|
+
summary: {
|
|
123
|
+
errors: diagnostics.filter((d) => d.severity === "error").length,
|
|
124
|
+
warnings: diagnostics.filter((d) => d.severity === "warning").length,
|
|
125
|
+
infos: diagnostics.filter((d) => d.severity === "info" || d.severity === "hint").length,
|
|
126
|
+
files: new Set(diagnostics.flatMap((d) => d.location ? [d.location.path] : [])).size
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** History costs a git walk, so it loads only when `stale-translation` runs. */
|
|
132
|
+
function needsHistory(loaded, options) {
|
|
133
|
+
const setting = loaded.project.config.checks["stale-translation"];
|
|
134
|
+
const level = typeof setting === "string" ? setting : setting?.[0];
|
|
135
|
+
const only = list(options.only);
|
|
136
|
+
return level !== "off" && (only.length === 0 || only.includes("stale-translation")) && !list(options.skip).includes("stale-translation");
|
|
137
|
+
}
|
|
138
|
+
async function report(workspace, options) {
|
|
139
|
+
const inputs = reportInputs(workspace, options);
|
|
140
|
+
const reporterIds = list(options.reporter);
|
|
141
|
+
const ids = reporterIds.length ? reporterIds : [options.json ? "json" : "pretty"];
|
|
142
|
+
const outputs = list(options.output);
|
|
143
|
+
const available = workspace.projects[0]?.project.reporters ?? /* @__PURE__ */ new Map();
|
|
144
|
+
const roots = new Map(workspace.projects.map((p) => [p.name, p.root]));
|
|
145
|
+
for (const [i, id] of ids.entries()) {
|
|
146
|
+
const reporter = available.get(id);
|
|
147
|
+
if (!reporter) throw new X18nError("X18N0001", `unknown reporter "${id}" (${[...available.keys()].join(", ")})`);
|
|
148
|
+
const text = await reporter.report(inputs, {
|
|
149
|
+
color: !outputs[i] && useColor(options) && id === "pretty",
|
|
150
|
+
displayPath: (path, project) => displayPath(roots.get(project) ?? "", path)
|
|
151
|
+
});
|
|
152
|
+
if (outputs[i]) await writeFile(outputs[i], text);
|
|
153
|
+
else if (text) process.stdout.write(text);
|
|
154
|
+
}
|
|
155
|
+
const errors = inputs.reduce((sum, input) => sum + input.summary.errors, 0);
|
|
156
|
+
const warnings = inputs.reduce((sum, input) => sum + input.summary.warnings, 0);
|
|
157
|
+
const tooManyWarnings = options.maxWarnings !== void 0 && warnings > Number(options.maxWarnings);
|
|
158
|
+
return errors > 0 || tooManyWarnings ? 1 : 0;
|
|
159
|
+
}
|
|
160
|
+
async function check(options) {
|
|
161
|
+
const workspace = await openWorkspace(options);
|
|
162
|
+
await Promise.all(workspace.projects.filter((loaded) => needsHistory(loaded, options)).map(async (loaded) => {
|
|
163
|
+
await loaded.project.loadHistory({ watch: options.watch });
|
|
164
|
+
await loaded.host.flush();
|
|
165
|
+
}));
|
|
166
|
+
const code = await report(workspace, options);
|
|
167
|
+
if (!options.watch) return code;
|
|
168
|
+
const rerun = async () => {
|
|
169
|
+
if (process.stdout.isTTY) process.stdout.write("\x1Bc");
|
|
170
|
+
out(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] File change detected. Checking...\n`);
|
|
171
|
+
await report(workspace, options);
|
|
172
|
+
};
|
|
173
|
+
for (const loaded of workspace.projects) {
|
|
174
|
+
loaded.project.watch();
|
|
175
|
+
loaded.project.onChange(() => void rerun());
|
|
176
|
+
}
|
|
177
|
+
out("\nWatching for file changes.");
|
|
178
|
+
return new Promise(() => {});
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/commands/extract.ts
|
|
182
|
+
function isPlanItem(value) {
|
|
183
|
+
if (!isJsonObject(value) || !isJsonObject(value.range)) return false;
|
|
184
|
+
return [
|
|
185
|
+
"path",
|
|
186
|
+
"original",
|
|
187
|
+
"key",
|
|
188
|
+
"value",
|
|
189
|
+
"replacement"
|
|
190
|
+
].every((field) => typeof value[field] === "string") && typeof value.range.start === "number" && typeof value.range.end === "number" && Array.isArray(value.imports) && value.imports.every((line) => typeof line === "string");
|
|
191
|
+
}
|
|
192
|
+
async function readPlan(file) {
|
|
193
|
+
const data = JSON.parse(await readFile(file, "utf8"));
|
|
194
|
+
if (!isJsonObject(data) || !Array.isArray(data.items) || !data.items.every(isPlanItem)) throw new X18nError("X18N4008", `${file} is not an extraction plan`);
|
|
195
|
+
return data.items;
|
|
196
|
+
}
|
|
197
|
+
/** One question per string in a TTY: extract with the proposed key, a typed key, or skip. */
|
|
198
|
+
async function review(candidates, root) {
|
|
199
|
+
const rl = createInterface({
|
|
200
|
+
input: process.stdin,
|
|
201
|
+
output: process.stderr
|
|
202
|
+
});
|
|
203
|
+
const picked = [];
|
|
204
|
+
try {
|
|
205
|
+
for (const candidate of candidates) {
|
|
206
|
+
process.stderr.write(`\n${displayPath(root, candidate.path)}:${candidate.line} ${JSON.stringify(candidate.text)}\n → ${candidate.replacement}\n`);
|
|
207
|
+
const reply = (await rl.question(`Extract as "${candidate.key}"? [Y/n/key/q] `)).trim();
|
|
208
|
+
if (reply === "q") break;
|
|
209
|
+
if (reply === "n") continue;
|
|
210
|
+
picked.push(reply && reply !== "y" ? {
|
|
211
|
+
...candidate,
|
|
212
|
+
key: reply,
|
|
213
|
+
replacement: candidate.replacement.replace(candidate.key, reply)
|
|
214
|
+
} : candidate);
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
rl.close();
|
|
218
|
+
}
|
|
219
|
+
return picked;
|
|
220
|
+
}
|
|
221
|
+
async function extract(files, options) {
|
|
222
|
+
const loaded = singleProject(await openWorkspace(options));
|
|
223
|
+
const { project } = loaded;
|
|
224
|
+
if (options.apply) {
|
|
225
|
+
const items = extractItems(project, await readPlan(options.apply));
|
|
226
|
+
await runPlan("extract", loaded, project.plan([{
|
|
227
|
+
type: "extract",
|
|
228
|
+
items
|
|
229
|
+
}]), options);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const candidates = findCandidates(project, { files });
|
|
233
|
+
if (options.plan) {
|
|
234
|
+
await writeFile(options.plan, `${JSON.stringify(toPlanFile(project, candidates), null, 2)}\n`);
|
|
235
|
+
if (options.json) printJson("extract", {
|
|
236
|
+
plan: options.plan,
|
|
237
|
+
items: candidates.length
|
|
238
|
+
});
|
|
239
|
+
else out(`Wrote ${candidates.length} strings to ${options.plan}. Edit keys and replacements, then run \`x18n extract --apply ${options.plan}\`.`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const interactive = !options.list && !options.yes && !options.json && process.stdin.isTTY;
|
|
243
|
+
if (options.list || !interactive && !options.yes) {
|
|
244
|
+
if (options.json) {
|
|
245
|
+
printJson("extract", { items: candidates.map((c) => ({
|
|
246
|
+
...c,
|
|
247
|
+
file: displayPath(loaded.root, c.path)
|
|
248
|
+
})) });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
for (const c of candidates) out(`${displayPath(loaded.root, c.path)}:${c.line}\t${c.kind}\t${JSON.stringify(c.text)}\t→ ${c.key}${c.reused ? " (existing)" : ""}`);
|
|
252
|
+
out(`${candidates.length} strings. Apply with \`x18n extract --yes\`, or review with \`x18n extract --plan plan.json\`.`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const chosen = interactive ? await review(candidates, loaded.root) : candidates;
|
|
256
|
+
if (chosen.length === 0) {
|
|
257
|
+
out("Nothing to extract.");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
await runPlan("extract", loaded, project.plan([{
|
|
261
|
+
type: "extract",
|
|
262
|
+
items: extractItems(project, chosen)
|
|
263
|
+
}]), options);
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/commands/look.ts
|
|
267
|
+
function percent(coverage) {
|
|
268
|
+
return coverage.total === 0 ? 100 : Math.floor(coverage.translated / coverage.total * 1e3) / 10;
|
|
269
|
+
}
|
|
270
|
+
function summaryOf(loaded) {
|
|
271
|
+
const diagnostics = loaded.project.analyze().diagnostics;
|
|
272
|
+
return {
|
|
273
|
+
errors: diagnostics.filter((d) => d.severity === "error").length,
|
|
274
|
+
warnings: diagnostics.filter((d) => d.severity === "warning").length,
|
|
275
|
+
infos: diagnostics.filter((d) => d.severity === "info" || d.severity === "hint").length
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function statusData(loaded) {
|
|
279
|
+
const { project } = loaded;
|
|
280
|
+
return {
|
|
281
|
+
name: loaded.name,
|
|
282
|
+
root: displayPath(loaded.root, ""),
|
|
283
|
+
sourceLocale: project.sourceLocale,
|
|
284
|
+
locales: project.analyze().coverage.map((c) => ({
|
|
285
|
+
locale: c.locale,
|
|
286
|
+
total: c.total,
|
|
287
|
+
translated: c.translated,
|
|
288
|
+
missing: c.missing.length,
|
|
289
|
+
empty: c.empty.length,
|
|
290
|
+
percent: percent(c)
|
|
291
|
+
})),
|
|
292
|
+
resources: project.resources.length,
|
|
293
|
+
keys: project.keys().length,
|
|
294
|
+
usages: project.usages().length,
|
|
295
|
+
summary: summaryOf(loaded)
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
async function status(options) {
|
|
299
|
+
const workspace = await openWorkspace(options);
|
|
300
|
+
if (options.json) {
|
|
301
|
+
printJson("status", { projects: workspace.projects.map(statusData) });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
for (const loaded of workspace.projects) {
|
|
305
|
+
const { project } = loaded;
|
|
306
|
+
const summary = summaryOf(loaded);
|
|
307
|
+
out(`${loaded.name} (${displayPath(loaded.root, "")})`);
|
|
308
|
+
out(` ${project.resources.length} resources, ${project.keys().length} keys, ${project.usages().length} usages`);
|
|
309
|
+
for (const c of project.analyze().coverage) {
|
|
310
|
+
const label = `${c.locale}${c.locale === project.sourceLocale ? " (source)" : ""}`;
|
|
311
|
+
out(` ${label.padEnd(16)} ${String(percent(c)).padStart(5)}% ${c.translated}/${c.total}${c.missing.length ? `, ${c.missing.length} missing` : ""}${c.empty.length ? `, ${c.empty.length} empty` : ""}`);
|
|
312
|
+
}
|
|
313
|
+
out(` ${summary.errors} errors, ${summary.warnings} warnings`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** One page of a list: `--limit` items from `--cursor`, plus the cursor of the next page. */
|
|
317
|
+
function page(items, options) {
|
|
318
|
+
const start = Number(options.cursor ?? 0) || 0;
|
|
319
|
+
const end = options.limit ? start + Number(options.limit) : items.length;
|
|
320
|
+
return {
|
|
321
|
+
items: items.slice(start, end),
|
|
322
|
+
total: items.length,
|
|
323
|
+
nextCursor: end < items.length ? String(end) : void 0
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
async function keys(glob, options) {
|
|
327
|
+
const workspace = await openWorkspace(options);
|
|
328
|
+
const projects = workspace.projects.map((loaded) => {
|
|
329
|
+
const { project } = loaded;
|
|
330
|
+
const { items, total, nextCursor } = page(project.keys({
|
|
331
|
+
glob,
|
|
332
|
+
missing: options.missing,
|
|
333
|
+
unused: options.unused,
|
|
334
|
+
namespace: options.namespace
|
|
335
|
+
}), options);
|
|
336
|
+
const values = (key) => Object.fromEntries(project.locales.map((locale) => [locale, project.message(key, locale)?.value]));
|
|
337
|
+
return {
|
|
338
|
+
name: loaded.name,
|
|
339
|
+
total,
|
|
340
|
+
nextCursor,
|
|
341
|
+
keys: items.map((key) => options.values ? {
|
|
342
|
+
key,
|
|
343
|
+
values: values(key)
|
|
344
|
+
} : { key })
|
|
345
|
+
};
|
|
346
|
+
});
|
|
347
|
+
if (options.json) {
|
|
348
|
+
printJson("keys", { projects });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
for (const p of projects) {
|
|
352
|
+
if (workspace.projects.length > 1) out(`# ${p.name}`);
|
|
353
|
+
for (const item of p.keys) out("values" in item ? `${item.key}\t${JSON.stringify(item.values)}` : item.key);
|
|
354
|
+
if (p.nextCursor) out(`… ${p.total - Number(p.nextCursor)} more (--cursor ${p.nextCursor})`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function locate(loaded, path, start, end) {
|
|
358
|
+
const range = toWireRange(loaded.project.text(path), start, end);
|
|
359
|
+
return {
|
|
360
|
+
file: displayPath(loaded.root, path),
|
|
361
|
+
line: range.line,
|
|
362
|
+
column: range.column
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
async function get(input, options) {
|
|
366
|
+
const workspace = await openWorkspace(options);
|
|
367
|
+
const projects = workspace.projects.flatMap((loaded) => {
|
|
368
|
+
const { project } = loaded;
|
|
369
|
+
const info = project.get(project.parseKey(input));
|
|
370
|
+
if (!info) return [];
|
|
371
|
+
const resourceOf = (id) => project.resources.find((r) => r.id === id)?.path ?? id;
|
|
372
|
+
return [{
|
|
373
|
+
name: loaded.name,
|
|
374
|
+
key: info.key,
|
|
375
|
+
namespace: info.namespace,
|
|
376
|
+
messages: info.messages.map((m) => ({
|
|
377
|
+
locale: m.locale,
|
|
378
|
+
value: m.value,
|
|
379
|
+
...locate(loaded, resourceOf(m.resource), m.range?.key.start ?? 0, m.range?.key.end ?? 0)
|
|
380
|
+
})),
|
|
381
|
+
missing: info.missing,
|
|
382
|
+
usages: info.usages.map((u) => ({
|
|
383
|
+
possible: u.possible,
|
|
384
|
+
raw: u.usage.raw,
|
|
385
|
+
...locate(loaded, u.usage.path, u.usage.range.start, u.usage.range.end)
|
|
386
|
+
})),
|
|
387
|
+
diagnostics: wireDiagnostics(loaded, info.diagnostics)
|
|
388
|
+
}];
|
|
389
|
+
});
|
|
390
|
+
if (options.json) {
|
|
391
|
+
printJson("get", { projects });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (projects.length === 0) out(`"${input}" is not defined.`);
|
|
395
|
+
for (const p of projects) {
|
|
396
|
+
out(`${p.key}${workspace.projects.length > 1 ? ` (${p.name})` : ""}`);
|
|
397
|
+
for (const m of p.messages) out(` ${m.locale.padEnd(8)} ${JSON.stringify(m.value)} ${m.file}:${m.line}:${m.column}`);
|
|
398
|
+
if (p.missing.length) out(` missing in ${p.missing.join(", ")}`);
|
|
399
|
+
out(` ${p.usages.length} usages`);
|
|
400
|
+
for (const u of p.usages) out(` ${u.file}:${u.line}:${u.column}${u.possible ? " (possible)" : ""}`);
|
|
401
|
+
for (const d of p.diagnostics) out(` ${d.severity} ${d.code}: ${d.message}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async function usages(input, options) {
|
|
405
|
+
const projects = (await openWorkspace(options)).projects.map((loaded) => {
|
|
406
|
+
const { project } = loaded;
|
|
407
|
+
const { items, total, nextCursor } = page(project.usages({
|
|
408
|
+
key: input === void 0 ? void 0 : project.parseKey(input),
|
|
409
|
+
path: options.file
|
|
410
|
+
}), options);
|
|
411
|
+
return {
|
|
412
|
+
name: loaded.name,
|
|
413
|
+
total,
|
|
414
|
+
nextCursor,
|
|
415
|
+
usages: items.map((m) => ({
|
|
416
|
+
key: m.usage.key,
|
|
417
|
+
raw: m.usage.raw,
|
|
418
|
+
possible: m.possible,
|
|
419
|
+
dynamic: m.usage.dynamic,
|
|
420
|
+
...locate(loaded, m.usage.path, m.usage.range.start, m.usage.range.end)
|
|
421
|
+
}))
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
if (options.json) {
|
|
425
|
+
printJson("usages", { projects });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
for (const p of projects) for (const u of p.usages) out(`${u.file}:${u.line}:${u.column}\t${u.key ?? u.raw}${u.possible ? " (possible)" : ""}`);
|
|
429
|
+
}
|
|
430
|
+
async function locales(options) {
|
|
431
|
+
const projects = (await openWorkspace(options)).projects.map((loaded) => ({
|
|
432
|
+
name: loaded.name,
|
|
433
|
+
sourceLocale: loaded.project.sourceLocale,
|
|
434
|
+
locales: loaded.project.locales.map((code) => ({
|
|
435
|
+
...loaded.project.localeInfo(code),
|
|
436
|
+
resources: loaded.project.resources.filter((r) => r.locale === code).map((r) => displayPath(loaded.root, r.path))
|
|
437
|
+
}))
|
|
438
|
+
}));
|
|
439
|
+
if (options.json) {
|
|
440
|
+
printJson("locales", { projects });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
for (const p of projects) for (const l of p.locales) out(`${l.code.padEnd(10)} ${(l.displayName ?? "").padEnd(28)} ${l.resources.join(", ")}`);
|
|
444
|
+
}
|
|
445
|
+
async function doctor(options) {
|
|
446
|
+
const projects = (await openWorkspace(options)).projects.map((loaded) => ({
|
|
447
|
+
...loaded.project.explain(),
|
|
448
|
+
root: displayPath(loaded.root, ""),
|
|
449
|
+
configFile: loaded.configFile && displayPath(loaded.configFile, "")
|
|
450
|
+
}));
|
|
451
|
+
if (options.json) {
|
|
452
|
+
printJson("doctor", { projects });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
for (const e of projects) {
|
|
456
|
+
out(`# ${e.name} (${e.root})`);
|
|
457
|
+
out(`config: ${e.configFile ?? "none (detection only)"}`);
|
|
458
|
+
out(`presets:`);
|
|
459
|
+
const applied = e.presets.filter((p) => p.reason === "detected" || p.reason === "listed");
|
|
460
|
+
for (const p of applied) out(` ✓ ${p.name} (${p.reason})`);
|
|
461
|
+
if (applied.length === 0) out(" none");
|
|
462
|
+
if (e.presets.length > applied.length) out(` ${e.presets.length - applied.length} others not applied (--json lists them)`);
|
|
463
|
+
out("finders:");
|
|
464
|
+
for (const f of e.finders) out(` ${f.id}: ${f.resources.length} resources${f.resources.length ? `\n ${f.resources.slice(0, 20).join("\n ")}` : ""}`);
|
|
465
|
+
out("formats:");
|
|
466
|
+
for (const f of e.formats.filter((f) => f.resources > 0)) out(` ${f.id} (${f.extensions.join(", ")}): ${f.resources} resources`);
|
|
467
|
+
out("scanners:");
|
|
468
|
+
for (const s of e.scanners) out(` ${s.id} (${s.extensions.join(", ") || "none"}): ${s.files} files`);
|
|
469
|
+
if (e.conflicts.length) out(`conflicts:\n${e.conflicts.map((c) => ` .${c.extension}: ${c.winner} wins over ${c.loser}`).join("\n")}`);
|
|
470
|
+
if (e.errors.length) out(`errors:\n${e.errors.map((err) => ` ${err.code} ${err.message}`).join("\n")}`);
|
|
471
|
+
out("config origins:");
|
|
472
|
+
for (const [key, origin] of Object.entries(e.config.origins)) out(` ${key}: ${origin}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/commands/migrate.ts
|
|
477
|
+
async function migrate(source, options) {
|
|
478
|
+
if (source !== "i18n-ally") throw new X18nError("X18N0001", `unknown migration source "${source}" (i18n-ally)`);
|
|
479
|
+
const root = resolve(options.root ?? cwd());
|
|
480
|
+
const result = await migrateI18nAlly(root, { format: options.format });
|
|
481
|
+
const existing = findConfigFile(root);
|
|
482
|
+
if (existing && !options.force && !options.dryRun && result.files.length > 0) throw new X18nError("X18N0001", `${existing} already exists (pass --force to replace it)`);
|
|
483
|
+
if (!options.dryRun) for (const file of result.files) {
|
|
484
|
+
const path = join(root, file.path);
|
|
485
|
+
await mkdir(dirname(path), { recursive: true });
|
|
486
|
+
if (file.path === ".x18n/reviews.yml" && existsSync(path)) continue;
|
|
487
|
+
await writeFile(path, file.content);
|
|
488
|
+
}
|
|
489
|
+
if (options.json) {
|
|
490
|
+
printJson("migrate", {
|
|
491
|
+
...result,
|
|
492
|
+
written: !options.dryRun
|
|
493
|
+
});
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
for (const file of result.files) out(options.dryRun ? `--- ${file.path}\n${file.content}` : `wrote ${file.path}`);
|
|
497
|
+
for (const note of result.notes) out(`note: ${note}`);
|
|
498
|
+
if (result.removable.length) out(`These i18n-ally settings can be removed now:\n${result.removable.map((key) => ` ${key}`).join("\n")}`);
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/commands/prompt.ts
|
|
502
|
+
async function prompt(kind, options) {
|
|
503
|
+
if (kind !== "translate" && kind !== "review") throw new X18nError("X18N0001", `unknown prompt "${kind}" (translate, review)`);
|
|
504
|
+
const locale = kind === "translate" ? options.to : options.locale ?? options.to;
|
|
505
|
+
if (!locale) throw new X18nError("X18N0001", kind === "translate" ? "pass --to <locale>" : "pass --locale <locale>");
|
|
506
|
+
const { project, host } = singleProject(await openWorkspace(options));
|
|
507
|
+
if (options.stale) {
|
|
508
|
+
await project.loadHistory();
|
|
509
|
+
await host.flush();
|
|
510
|
+
}
|
|
511
|
+
const data = project.prompt(kind, locale, {
|
|
512
|
+
keys: options.keys,
|
|
513
|
+
namespace: options.namespace,
|
|
514
|
+
limit: options.limit === void 0 ? void 0 : Number(options.limit),
|
|
515
|
+
cursor: options.cursor,
|
|
516
|
+
context: options.context,
|
|
517
|
+
stale: options.stale
|
|
518
|
+
});
|
|
519
|
+
if (options.json || options.format === "json") printJson(`prompt ${kind}`, data);
|
|
520
|
+
else out(renderPrompt(data));
|
|
521
|
+
}
|
|
522
|
+
//#endregion
|
|
523
|
+
//#region src/commands/setup.ts
|
|
524
|
+
async function schema(name, options) {
|
|
525
|
+
const which = name ?? "config";
|
|
526
|
+
if (which !== "config" && which !== "plugin") throw new X18nError("X18N0001", `unknown schema "${which}" (config, plugin)`);
|
|
527
|
+
const require = createRequire(import.meta.url);
|
|
528
|
+
const text = await readFile(require.resolve(`@x18n/core/schema/${which}.json`), "utf8");
|
|
529
|
+
if (options.json) printJson("schema", {
|
|
530
|
+
name: which,
|
|
531
|
+
schema: JSON.parse(text)
|
|
532
|
+
});
|
|
533
|
+
else out(text);
|
|
534
|
+
}
|
|
535
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
536
|
+
/** The folders and file name that name `namespace`, in the style of `namespace.fromPath`. */
|
|
537
|
+
function namespaceSpan(parts, namespace, fromPath) {
|
|
538
|
+
for (let start = 0; start < parts.length; start++) for (let end = start + 1; end <= parts.length; end++) if (namespaceFromPath(parts.slice(start, end).join("/"), fromPath) === namespace) return {
|
|
539
|
+
start,
|
|
540
|
+
end
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/** The resource pattern that matches a found file: `public/locales/en/common.json` → `public/locales/{locale}/{namespaces}.{ext}`. */
|
|
544
|
+
function patternOf(resource, fromPath) {
|
|
545
|
+
const parts = resource.path.replace(/\.[^./]+$/, "").split("/");
|
|
546
|
+
const span = resource.namespace === void 0 ? void 0 : namespaceSpan(parts, resource.namespace, fromPath);
|
|
547
|
+
if (span) parts.splice(span.start, span.end - span.start, "{namespaces}");
|
|
548
|
+
return `${parts.join("/")}.{ext}`.replace(new RegExp(`(^|[/._-])${escapeRegExp(resource.locale)}(?=[/._-])`), "$1{locale}");
|
|
549
|
+
}
|
|
550
|
+
async function init(options) {
|
|
551
|
+
const root = resolve(options.root ?? cwd());
|
|
552
|
+
const existing = findConfigFile(root);
|
|
553
|
+
if (existing && !options.force) throw new X18nError("X18N0001", `${existing} already exists (pass --force to replace it)`);
|
|
554
|
+
const { project } = (await loadWorkspace({
|
|
555
|
+
cwd: root,
|
|
556
|
+
cache: false
|
|
557
|
+
})).projects[0];
|
|
558
|
+
const detected = project.explain().presets.filter((p) => p.reason === "detected" && p.name !== "general").map((p) => p.name);
|
|
559
|
+
const resources = [...new Set(project.resources.filter((r) => !r.block).map((r) => patternOf(r, project.config.namespace.fromPath)))];
|
|
560
|
+
const config = {
|
|
561
|
+
sourceLocale: project.sourceLocale,
|
|
562
|
+
resources: resources.map((pattern) => ({ pattern }))
|
|
563
|
+
};
|
|
564
|
+
const format = options.format ?? "ts";
|
|
565
|
+
let file;
|
|
566
|
+
let content;
|
|
567
|
+
if (format === "yml" || format === "yaml") {
|
|
568
|
+
file = join(root, "x18n.config.yml");
|
|
569
|
+
content = `# yaml-language-server: $schema=https://x18n.antfu.dev/schema/config.json\n${stringify(config)}`;
|
|
570
|
+
} else if (format === "json") {
|
|
571
|
+
file = join(root, "x18n.config.json");
|
|
572
|
+
content = `${JSON.stringify({
|
|
573
|
+
$schema: "https://x18n.antfu.dev/schema/config.json",
|
|
574
|
+
...config
|
|
575
|
+
}, null, 2)}\n`;
|
|
576
|
+
} else {
|
|
577
|
+
file = join(root, "x18n.config.ts");
|
|
578
|
+
const body = JSON.stringify(config, null, 2).replace(/"(\w+)":/g, "$1:").replace(/"/g, "'");
|
|
579
|
+
content = `import { defineConfig } from '@x18n/core'\n\n// Detected presets: ${detected.join(", ") || "none"}. They apply automatically.\nexport default defineConfig(${body})\n`;
|
|
580
|
+
}
|
|
581
|
+
if (!configFileNames.some((name) => file.endsWith(name))) throw new X18nError("X18N0001", `unknown format "${format}" (ts, yml, json)`);
|
|
582
|
+
await writeFile(file, content);
|
|
583
|
+
if (options.json) printJson("init", {
|
|
584
|
+
file,
|
|
585
|
+
presets: detected,
|
|
586
|
+
config
|
|
587
|
+
});
|
|
588
|
+
else out(`Wrote ${file}\nDetected presets: ${detected.join(", ") || "none"}\nLocales: ${project.locales.join(", ")}`);
|
|
589
|
+
}
|
|
590
|
+
const literal = (value) => JSON.stringify(value, null, 2).replace(/"([a-z_$][\w$]*)":/gi, "$1:");
|
|
591
|
+
/** The TS plugin equivalent of a YAML plugin, using the same built-in factories. */
|
|
592
|
+
function ejectPlugin(data) {
|
|
593
|
+
const lines = [];
|
|
594
|
+
const configLines = [];
|
|
595
|
+
if (data.config) configLines.push(` Object.assign(config, ${literal(data.config).replace(/\n/g, "\n ")})`);
|
|
596
|
+
if (data.scanners?.calls || data.scanners?.regex) {
|
|
597
|
+
configLines.push(` config.usage = {`, ` ...config.usage,`);
|
|
598
|
+
if (data.scanners.calls) configLines.push(` calls: [...config.usage.calls, ...${literal(data.scanners.calls).replace(/\n/g, "\n ")}],`);
|
|
599
|
+
if (data.scanners.regex) configLines.push(` regex: [...config.usage.regex, ...${literal(data.scanners.regex).replace(/\n/g, "\n ")}],`);
|
|
600
|
+
configLines.push(` }`);
|
|
601
|
+
}
|
|
602
|
+
const fields = [` name: ${literal(data.name)},`];
|
|
603
|
+
if (configLines.length) fields.push(` config(config) {`, ...configLines, ` },`);
|
|
604
|
+
if (data.derivedKeys) fields.push(` derivedKeys: ${literal(data.derivedKeys)},`);
|
|
605
|
+
if (data.placeholders) fields.push(` placeholders: ${literal(data.placeholders)}.map(placeholderFromShape),`);
|
|
606
|
+
if (data.normalizeKey) {
|
|
607
|
+
const rules = data.normalizeKey.map((rule) => `.replace(new RegExp(${literal(rule.replace.pattern)}, 'g'), ${literal(rule.replace.with)})`).join("");
|
|
608
|
+
fields.push(` normalizeKey: raw => raw${rules},`);
|
|
609
|
+
}
|
|
610
|
+
if (data.localeInfo) fields.push(` localeInfo: code => (${literal(data.localeInfo)} as Record<string, { flag?: string, displayName?: string }>)[code],`);
|
|
611
|
+
const imports = ["definePlugin", ...data.detect ? ["definePreset"] : []];
|
|
612
|
+
lines.push(`import { ${imports.join(", ")} } from '@x18n/core'`);
|
|
613
|
+
if (data.placeholders) lines.push(`import { placeholderFromShape } from '@x18n/builtins/placeholders'`);
|
|
614
|
+
if (data.scopes) lines.push(`import { regexScopes } from '@x18n/builtins/scanners'`);
|
|
615
|
+
lines.push("", `const plugin = definePlugin({`, ...fields, `})`, "");
|
|
616
|
+
const plugins = `[plugin${data.scopes ? `, regexScopes(${literal(data.scopes.regex)})` : ""}]`;
|
|
617
|
+
if (data.detect) {
|
|
618
|
+
const any = data.detect.dependencies?.any;
|
|
619
|
+
lines.push(`export default definePreset({`, ` name: ${literal(data.name)},`, ...data.exclusive ? [` exclusive: true,`] : [], ` detect: ctx => ${any ? `ctx.hasAnyDependency(${literal(any)})` : "true"},`, ` plugins: ${plugins},`, `})`);
|
|
620
|
+
} else lines.push(data.scopes ? `export default ${plugins}` : "export default plugin");
|
|
621
|
+
return `${lines.join("\n")}\n`;
|
|
622
|
+
}
|
|
623
|
+
async function eject(file, options) {
|
|
624
|
+
const path = resolve(cwd(), file);
|
|
625
|
+
if (!existsSync(path)) throw new X18nError("X18N1001", path);
|
|
626
|
+
const result = validate(PluginFileSchema, parse(await readFile(path, "utf8")));
|
|
627
|
+
if (!result.success) throw new X18nError("X18N1004", result.issues.map((i) => `${i.path}: ${i.message}`).join("; "));
|
|
628
|
+
const code = ejectPlugin(result.output);
|
|
629
|
+
if (options.json) printJson("plugin eject", {
|
|
630
|
+
file: path,
|
|
631
|
+
code
|
|
632
|
+
});
|
|
633
|
+
else out(code);
|
|
634
|
+
}
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/cli.ts
|
|
637
|
+
const uiCommands = () => import("./ui-Bp3xyqr_.mjs");
|
|
638
|
+
/** Runs a command and turns errors into the JSON envelope or a printed hint, with the matching exit code. */
|
|
639
|
+
function run(command, action) {
|
|
640
|
+
return async (...args) => {
|
|
641
|
+
const options = args.at(-1);
|
|
642
|
+
try {
|
|
643
|
+
const code = await action(...args);
|
|
644
|
+
process.exitCode = code ?? 0;
|
|
645
|
+
} catch (error) {
|
|
646
|
+
const info = errorInfo(error);
|
|
647
|
+
if (options.json) printJson(command, null, [], [info]);
|
|
648
|
+
else {
|
|
649
|
+
process.stderr.write(`error ${info.code}: ${info.message}\n`);
|
|
650
|
+
if (info.hint) process.stderr.write(` ${info.hint}\n`);
|
|
651
|
+
if (!(error instanceof Error && "code" in error) && error instanceof Error && process.env.DEBUG) process.stderr.write(`${error.stack}\n`);
|
|
652
|
+
}
|
|
653
|
+
process.exitCode = exitCodeOf(error);
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function createCli() {
|
|
658
|
+
const cli = cac("x18n");
|
|
659
|
+
cli.option("--root <dir>", "Project root (default: nearest config upward from the cwd)").option("--config <file>", "Explicit config file").option("--project <name>", "Pick one project in a monorepo").option("--json", "Print the JSON contract instead of human output").option("--no-cache", "Ignore the persistent cache").option("--yes", "Accept default answers for decisions");
|
|
660
|
+
cli.command("", "Show the status and the main commands").action(run("status", async (options) => {
|
|
661
|
+
await status(options);
|
|
662
|
+
if (!options.json) process.stdout.write("\nCommands: check, keys, get, usages, set, apply, rename, delete, fill, prompt translate, ui. Run `x18n --help` for all.\n");
|
|
663
|
+
}));
|
|
664
|
+
cli.command("status", "Locales with coverage, resources, keys and diagnostics").action(run("status", status));
|
|
665
|
+
cli.command("keys [glob]", "List keys").option("--missing <locale>", "Only keys missing or empty in a locale").option("--unused", "Only keys without usages").option("--namespace <ns>", "Only keys of a namespace").option("--values", "Include the value per locale").option("--limit <n>", "Page size").option("--cursor <cursor>", "Start of the page (from `nextCursor`)").action(run("keys", keys));
|
|
666
|
+
cli.command("get <key>", "Values, resources, usages and diagnostics of one key").action(run("get", get));
|
|
667
|
+
cli.command("usages [key]", "Usages of a key, or all usages").option("--file <path>", "Only usages in a file").option("--limit <n>", "Page size").option("--cursor <cursor>", "Start of the page").action(run("usages", usages));
|
|
668
|
+
cli.command("locales", "Locale codes as found, with display names and resources").action(run("locales", locales));
|
|
669
|
+
cli.command("doctor", "How the project was understood: presets, finders, formats, scanners, config origins").action(run("doctor", doctor));
|
|
670
|
+
cli.command("check", "Report invalid, untranslated and unused keys, like `tsc --noEmit`").option("--watch", "Re-check on change").option("--strict", "Turn every warning into an error").option("--locale <locale>", "Only report for these locales (repeatable)").option("--only <checks>", "Only these check ids (comma separated)").option("--skip <checks>", "Skip these check ids (comma separated)").option("--max-warnings <n>", "Fail when there are more warnings").option("--reporter <id>", "pretty, json, markdown, github or sarif (repeatable)").option("--output <file>", "Write the matching reporter output to a file (repeatable)").action(run("check", check));
|
|
671
|
+
cli.command("set <key>", "Set one value").option("--locale <locale>", "Locale (default: source locale)").option("--value <value>", "The value; `-` reads stdin").option("--dry-run", "Print the plan instead of writing").action(run("set", set));
|
|
672
|
+
cli.command("apply <file>", "Batch set from JSON `{ \"<locale>\": { \"<key>\": \"<value>\" } }`; `-` reads stdin").option("--force", "Write values with placeholder problems").option("--dry-run", "Print the plan instead of writing").action(run("apply", apply));
|
|
673
|
+
cli.command("rename <from> <to>", "Rename a key (or every key under a parent) in all locales and usages").option("--no-usages", "Keep code unchanged").option("--dry-run", "Print the plan instead of writing").action(run("rename", rename));
|
|
674
|
+
cli.command("delete <key>", "Delete a key (or every key under a parent) in all locales").option("--locale <locale>", "Only in these locales (repeatable)").option("--dry-run", "Print the plan instead of writing").action(run("delete", remove));
|
|
675
|
+
cli.command("fill", "Add missing keys with empty values").option("--locale <locale>", "Only these locales (repeatable)").option("--copy-source", "Use the source value instead of an empty string").option("--dry-run", "Print the plan instead of writing").action(run("fill", fill));
|
|
676
|
+
cli.command("format", "Re-write resources with the configured sort and indent").option("--dry-run", "Print the plan instead of writing").action(run("format", format));
|
|
677
|
+
cli.command("extract [...files]", "Find hard-coded strings and turn them into keys").option("--list", "Print candidates and proposed keys").option("--plan <file>", "Write the extraction plan to a file to edit").option("--apply <file>", "Apply an (edited) extraction plan").option("--dry-run", "Print the plan instead of writing").action(run("extract", extract));
|
|
678
|
+
cli.command("prompt <kind>", "Print a prompt for an agent or LLM: `prompt translate --to <locale>` or `prompt review --locale <locale>`").option("--to <locale>", "Target locale (translate)").option("--locale <locale>", "Locale to review").option("--keys <glob>", "Only keys matching a glob").option("--namespace <ns>", "Only keys of a namespace").option("--limit <n>", "Messages per prompt (default 50)").option("--cursor <cursor>", "Continue from `nextCursor`").option("--format <format>", "markdown (default) or json").option("--no-context", "Leave out code around usages").option("--stale", "Also translate stale translations (needs git history)").action(run("prompt", prompt));
|
|
679
|
+
cli.command("ui", "Start the UI: browse and edit messages in the browser, with HTTP MCP at /__mcp").option("--port <port>", "Port (default: a free one from 9999)").option("--host <host>", "Bind host (default: localhost)").option("--no-open", "Do not open the browser").option("--no-auth", "Skip authentication (CI and e2e only)").action(run("ui", async (options) => (await uiCommands()).ui(options)));
|
|
680
|
+
cli.command("build", "Write a static, read-only report of the UI").option("--out-dir <dir>", "Output directory (default: .x18n/report)").action(run("build", async (options) => (await uiCommands()).build(options)));
|
|
681
|
+
cli.command("mcp", "Serve the MCP tools over stdio").action(run("mcp", async (options) => (await uiCommands()).mcp(options)));
|
|
682
|
+
cli.command("init", "Detect the setup and write x18n.config.ts").option("--format <format>", "ts (default), yml or json").option("--force", "Replace an existing config").action(run("init", init));
|
|
683
|
+
cli.command("migrate <source>", "Convert i18n-ally settings: `migrate i18n-ally`").option("--format <format>", "ts (default), yml or json").option("--dry-run", "Print the files instead of writing").option("--force", "Replace an existing config").action(run("migrate", migrate));
|
|
684
|
+
cli.command("schema [name]", "Print a JSON Schema: config (default) or plugin").action(run("schema", schema));
|
|
685
|
+
cli.command("plugin <action> <file>", "Plugin tools: `plugin eject <file.yml>` prints the TS equivalent").action(run("plugin eject", async (action, file, options) => {
|
|
686
|
+
if (action !== "eject") throw new Error(`unknown plugin action "${action}"`);
|
|
687
|
+
await eject(file, options);
|
|
688
|
+
}));
|
|
689
|
+
cli.help();
|
|
690
|
+
cli.version(version);
|
|
691
|
+
return cli;
|
|
692
|
+
}
|
|
693
|
+
process.stdout.on("error", (error) => {
|
|
694
|
+
if (error.code === "EPIPE") process.exit(0);
|
|
695
|
+
throw error;
|
|
696
|
+
});
|
|
697
|
+
const argv = process.argv.map((arg) => arg === "-" ? stdinArg : arg);
|
|
698
|
+
try {
|
|
699
|
+
createCli().parse(argv);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
702
|
+
process.exitCode = 2;
|
|
703
|
+
}
|
|
704
|
+
//#endregion
|
|
705
|
+
export { createCli };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { X18nError, applyTextEdits, envelope, isX18nError, toWireDiagnostic } from "@x18n/core";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join, relative } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { loadWorkspace, stderrLogger } from "@x18n/node";
|
|
8
|
+
//#region src/context.ts
|
|
9
|
+
const cwd = () => process.cwd();
|
|
10
|
+
function useColor(options) {
|
|
11
|
+
if (options.json || process.env.NO_COLOR) return false;
|
|
12
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
|
|
13
|
+
return process.stdout.isTTY === true;
|
|
14
|
+
}
|
|
15
|
+
async function openWorkspace(options) {
|
|
16
|
+
return loadWorkspace({
|
|
17
|
+
cwd: options.root ?? cwd(),
|
|
18
|
+
configFile: options.config,
|
|
19
|
+
upward: options.root === void 0,
|
|
20
|
+
project: options.project,
|
|
21
|
+
cache: options.cache !== false,
|
|
22
|
+
logger: stderrLogger
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** Write commands work on one project; a monorepo needs `--project`. */
|
|
26
|
+
function singleProject(workspace) {
|
|
27
|
+
if (workspace.projects.length !== 1) throw new X18nError("X18N0003", `pick one with --project (${workspace.projects.map((p) => p.name).join(", ")})`);
|
|
28
|
+
return workspace.projects[0];
|
|
29
|
+
}
|
|
30
|
+
function displayPath(root, path) {
|
|
31
|
+
return relative(cwd(), join(root, path)).replace(/\\/g, "/") || ".";
|
|
32
|
+
}
|
|
33
|
+
function out(text) {
|
|
34
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
35
|
+
}
|
|
36
|
+
function printJson(command, data, diagnostics = [], errors = []) {
|
|
37
|
+
const value = envelope(command, data, diagnostics, errors);
|
|
38
|
+
out(JSON.stringify(value, null, 2));
|
|
39
|
+
}
|
|
40
|
+
function wireDiagnostics(loaded, diagnostics) {
|
|
41
|
+
return diagnostics.map((d) => toWireDiagnostic(loaded.name, d, (path) => loaded.project.text(path)));
|
|
42
|
+
}
|
|
43
|
+
/** Exit code for an error: 2 usage or config, 3 decision needed, 4 stale plan, 1 otherwise. */
|
|
44
|
+
function exitCodeOf(error) {
|
|
45
|
+
if (!isX18nError(error)) return 2;
|
|
46
|
+
if (error.code === "X18N4003") return 3;
|
|
47
|
+
if (error.code === "X18N4004") return 4;
|
|
48
|
+
return /^X18N[01]/.test(error.code) ? 2 : 1;
|
|
49
|
+
}
|
|
50
|
+
function errorInfo(error) {
|
|
51
|
+
if (isX18nError(error)) return error.toJSON();
|
|
52
|
+
return {
|
|
53
|
+
code: "X18N0002",
|
|
54
|
+
message: error instanceof Error ? error.message : String(error),
|
|
55
|
+
hint: "",
|
|
56
|
+
docs: ""
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Stands for a lone `-` argument (stdin). */
|
|
60
|
+
const stdinArg = "\0stdin";
|
|
61
|
+
async function readInput(source) {
|
|
62
|
+
if (source !== "-" && source !== "\0stdin") return readFile(source, "utf8");
|
|
63
|
+
const chunks = [];
|
|
64
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
65
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
66
|
+
}
|
|
67
|
+
/** Answers for the plan's decisions: defaults with `--yes`, a prompt in a TTY, else an error. */
|
|
68
|
+
async function answer(plan, options) {
|
|
69
|
+
const answers = {};
|
|
70
|
+
if (plan.decisions.length === 0) return answers;
|
|
71
|
+
if (options.yes) {
|
|
72
|
+
for (const decision of plan.decisions) answers[decision.id] = decision.default;
|
|
73
|
+
return answers;
|
|
74
|
+
}
|
|
75
|
+
if (options.json || !process.stdin.isTTY) throw new X18nError("X18N4003", plan.decisions.map((d) => `${d.message} (${d.options.map((o) => o.id).join(" | ")})`).join(" "), plan.decisions.map((d) => d.id));
|
|
76
|
+
const rl = createInterface({
|
|
77
|
+
input: process.stdin,
|
|
78
|
+
output: process.stderr
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
for (const decision of plan.decisions) {
|
|
82
|
+
process.stderr.write(`${decision.message}\n${decision.options.map((o, i) => ` ${i + 1}) ${o.label}`).join("\n")}\n`);
|
|
83
|
+
const reply = await rl.question(`Choose [1]: `);
|
|
84
|
+
const option = decision.options[Number(reply || "1") - 1] ?? decision.options[0];
|
|
85
|
+
answers[decision.id] = option.id;
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
rl.close();
|
|
89
|
+
}
|
|
90
|
+
return answers;
|
|
91
|
+
}
|
|
92
|
+
/** Changes as a small line diff, for `--dry-run` and the summary after a write. */
|
|
93
|
+
function formatPlan(project, root, plan) {
|
|
94
|
+
const lines = [];
|
|
95
|
+
for (const change of plan.changes) {
|
|
96
|
+
const path = displayPath(root, change.path);
|
|
97
|
+
if (change.type === "create") {
|
|
98
|
+
lines.push(`create ${path}`, ...change.content.split("\n").filter(Boolean).map((line) => `+ ${line}`));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (change.type === "delete") {
|
|
102
|
+
lines.push(`delete ${path}`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const before = project.text(change.path) ?? "";
|
|
106
|
+
const after = applyTextEdits(before, change.edits);
|
|
107
|
+
const a = before.split("\n");
|
|
108
|
+
const b = after.split("\n");
|
|
109
|
+
let start = 0;
|
|
110
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
111
|
+
let endA = a.length;
|
|
112
|
+
let endB = b.length;
|
|
113
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
114
|
+
endA--;
|
|
115
|
+
endB--;
|
|
116
|
+
}
|
|
117
|
+
lines.push(`edit ${path}`, ...a.slice(start, endA).map((line) => `- ${line}`), ...b.slice(start, endB).map((line) => `+ ${line}`));
|
|
118
|
+
}
|
|
119
|
+
for (const warning of plan.warnings) lines.push(`warning: ${warning}`);
|
|
120
|
+
return lines.length ? lines.join("\n") : "No changes.";
|
|
121
|
+
}
|
|
122
|
+
function planData(root, plan, applied) {
|
|
123
|
+
return {
|
|
124
|
+
applied,
|
|
125
|
+
keys: plan.keys,
|
|
126
|
+
warnings: plan.warnings,
|
|
127
|
+
decisions: plan.decisions,
|
|
128
|
+
changes: plan.changes.map((change) => ({
|
|
129
|
+
...change,
|
|
130
|
+
file: displayPath(root, change.path)
|
|
131
|
+
}))
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Plans, asks, then applies (or prints with `--dry-run`). */
|
|
135
|
+
async function runPlan(command, loaded, plan, options) {
|
|
136
|
+
if (options.dryRun) {
|
|
137
|
+
if (options.json) printJson(command, planData(loaded.root, plan, false));
|
|
138
|
+
else out(formatPlan(loaded.project, loaded.root, plan));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const answers = await answer(plan, options);
|
|
142
|
+
const preview = formatPlan(loaded.project, loaded.root, plan);
|
|
143
|
+
await loaded.project.apply(plan, { answers });
|
|
144
|
+
if (options.json) printJson(command, planData(loaded.root, plan, true));
|
|
145
|
+
else out(preview);
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
export { openWorkspace as a, readInput as c, stdinArg as d, useColor as f, exitCodeOf as i, runPlan as l, displayPath as n, out as o, wireDiagnostics as p, errorInfo as r, printJson as s, cwd as t, singleProject as u };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { Check, Format, LocaleFinder, Plugin, Preset, Reporter, Scanner, UserConfig, defineConfig, definePlugin, definePreset } from "@x18n/core";
|
|
2
|
+
export { type Check, type Format, type LocaleFinder, type Plugin, type Preset, type Reporter, type Scanner, type UserConfig, defineConfig, definePlugin, definePreset };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { defineConfig, definePlugin, definePreset } from "@x18n/core";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@x18n/builtins/presets";
|
package/dist/presets.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { a as openWorkspace, o as out, s as printJson, t as cwd } from "./context-Za_ea1Nk.mjs";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { createX18nDevframe } from "@x18n/ui";
|
|
5
|
+
import { createBuild } from "devframe/adapters/build";
|
|
6
|
+
import { createDevServer } from "devframe/adapters/dev";
|
|
7
|
+
import { createMcpServer } from "devframe/adapters/mcp";
|
|
8
|
+
import { buildOtpAuthUrl, getTempAuthCodeInfo } from "devframe/node/auth";
|
|
9
|
+
import { randomToken } from "devframe/utils/crypto-token";
|
|
10
|
+
import { launchEditor } from "devframe/utils/launch-editor";
|
|
11
|
+
//#region src/commands/ui.ts
|
|
12
|
+
/** Starts the UI dev server and keeps running until interrupted. */
|
|
13
|
+
async function ui(options) {
|
|
14
|
+
const workspace = await openWorkspace(options);
|
|
15
|
+
workspace.projects.forEach((p) => p.project.watch());
|
|
16
|
+
const editor = process.env.LAUNCH_EDITOR || process.env.VISUAL || process.env.EDITOR;
|
|
17
|
+
const definition = createX18nDevframe(workspace.projects, { openInEditor: editor ? async ({ project, path, line, column }) => launchEditor(`${join(project.root, path)}:${line}:${column}`, editor) : void 0 });
|
|
18
|
+
const mcpToken = randomToken();
|
|
19
|
+
const auth = options.auth !== false;
|
|
20
|
+
if (!auth) process.stderr.write("warning: --no-auth lets anything that reaches the port edit your files. Use it only in CI and e2e tests.\n");
|
|
21
|
+
const server = await createDevServer(definition, {
|
|
22
|
+
host: options.host ?? "localhost",
|
|
23
|
+
...options.port === void 0 ? {} : { port: Number(options.port) },
|
|
24
|
+
auth,
|
|
25
|
+
openBrowser: options.open !== false,
|
|
26
|
+
mcp: { authorization: mcpToken }
|
|
27
|
+
});
|
|
28
|
+
const url = `${server.origin}/`;
|
|
29
|
+
const code = auth ? getTempAuthCodeInfo().code : void 0;
|
|
30
|
+
const mcp = {
|
|
31
|
+
url: `${url}__mcp`,
|
|
32
|
+
token: mcpToken
|
|
33
|
+
};
|
|
34
|
+
if (options.json) printJson("ui", {
|
|
35
|
+
url,
|
|
36
|
+
code,
|
|
37
|
+
link: code && buildOtpAuthUrl(url, code),
|
|
38
|
+
mcp
|
|
39
|
+
});
|
|
40
|
+
else {
|
|
41
|
+
out(`x18n UI at ${url}`);
|
|
42
|
+
if (code) out(` auth code ${code}\n or open ${buildOtpAuthUrl(url, code)}`);
|
|
43
|
+
out(` MCP ${mcp.url}\n MCP token Authorization: Bearer ${mcp.token}`);
|
|
44
|
+
}
|
|
45
|
+
const stop = () => {
|
|
46
|
+
server.close().finally(() => process.exit(0));
|
|
47
|
+
};
|
|
48
|
+
process.once("SIGINT", stop);
|
|
49
|
+
process.once("SIGTERM", stop);
|
|
50
|
+
}
|
|
51
|
+
async function build(options) {
|
|
52
|
+
const workspace = await openWorkspace(options);
|
|
53
|
+
const outDir = resolve(cwd(), options.outDir ?? ".x18n/report");
|
|
54
|
+
const log = console.log;
|
|
55
|
+
console.log = console.error;
|
|
56
|
+
try {
|
|
57
|
+
await createBuild(createX18nDevframe(workspace.projects), { outDir });
|
|
58
|
+
} finally {
|
|
59
|
+
console.log = log;
|
|
60
|
+
}
|
|
61
|
+
if (options.json) printJson("build", { outDir });
|
|
62
|
+
else out(`Wrote the report to ${outDir}`);
|
|
63
|
+
}
|
|
64
|
+
/** MCP over stdio: stdout is the transport, so nothing else may print there. */
|
|
65
|
+
async function mcp(options) {
|
|
66
|
+
const workspace = await openWorkspace(options);
|
|
67
|
+
workspace.projects.forEach((p) => p.project.watch());
|
|
68
|
+
await createMcpServer(createX18nDevframe(workspace.projects), {
|
|
69
|
+
transport: "stdio",
|
|
70
|
+
serverName: "x18n"
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { build, mcp, ui };
|
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x18n/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.5",
|
|
5
5
|
"description": "Manage and view i18n files of any project, for humans and agents",
|
|
6
6
|
"author": "Anthony Fu <anthonyfu117@hotmail.com>",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"funding": "https://github.com/sponsors/antfu",
|
|
9
|
-
"homepage": "https://github.com/
|
|
9
|
+
"homepage": "https://github.com/x18n-dev/x18n#readme",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/
|
|
12
|
+
"url": "git+https://github.com/x18n-dev/x18n.git",
|
|
13
13
|
"directory": "packages/cli"
|
|
14
14
|
},
|
|
15
|
-
"bugs": "https://github.com/
|
|
15
|
+
"bugs": "https://github.com/x18n-dev/x18n/issues",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"i18n",
|
|
18
18
|
"cli",
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
"node": ">=22"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@x18n/builtins": "0.0.
|
|
42
|
-
"@x18n/core": "0.0.
|
|
43
|
-
"@x18n/node": "0.0.
|
|
44
|
-
"@x18n/ui": "0.0.
|
|
41
|
+
"@x18n/builtins": "0.0.5",
|
|
42
|
+
"@x18n/core": "0.0.5",
|
|
43
|
+
"@x18n/node": "0.0.5",
|
|
44
|
+
"@x18n/ui": "0.0.5",
|
|
45
45
|
"cac": "^7.1.0",
|
|
46
|
-
"devframe": "^1.2.
|
|
46
|
+
"devframe": "^1.2.6",
|
|
47
47
|
"yaml": "^2.9.1"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
package/skills/x18n/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: x18n
|
|
3
|
-
description:
|
|
3
|
+
description: Manage a project's translations with x18n (`@x18n/cli`). Use when the task touches locale files or translation keys - adding, renaming or deleting keys, finding missing or unused keys, translating missing messages, reviewing translations, extracting hard-coded strings, keeping a glossary of terms.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# x18n
|
|
@@ -17,7 +17,6 @@ You do the legwork; the user makes the **decisions** that shape the product. Bef
|
|
|
17
17
|
- tone and formality per locale (German *du* or *Sie*, French *tu* or *vous*)
|
|
18
18
|
- deleting keys that look unused, since keys built at runtime look unused too
|
|
19
19
|
- renames that touch many files or move keys between namespaces
|
|
20
|
-
- turning on checks, and making warnings fail CI with `--strict`
|
|
21
20
|
|
|
22
21
|
Batch the questions into one message. Routine work needs no question: adding a key you use, translating a missing message, fixing a placeholder.
|
|
23
22
|
|
|
@@ -28,14 +27,51 @@ Batch the questions into one message. Routine work needs no question: adding a k
|
|
|
28
27
|
- Keep every placeholder of the source message (`{name}`, `{{count}}`, `%{name}`, `:name`, `%s`, `@:key`).
|
|
29
28
|
- Pass `--json` and read the envelope `{ "x18n": 1, "command", "data", "diagnostics", "errors" }`.
|
|
30
29
|
- Run renames and deletes with `--dry-run` first, and read the plan.
|
|
30
|
+
- Write every key as a full string literal. Do not build keys at runtime (see below).
|
|
31
31
|
|
|
32
|
-
##
|
|
32
|
+
## Avoid composed keys
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
Do not build a key from parts, such as `` t(`foo.bar.${name}`) `` or `t('foo.bar.' + name)`. x18n and the compiler cannot see the full key, so:
|
|
35
|
+
|
|
36
|
+
- `x18n keys --unused` reports the key as unused, and `x18n delete` can remove a message that the code needs
|
|
37
|
+
- `x18n rename` does not update the usage
|
|
38
|
+
- `x18n check` cannot find a missing message until the user hits it at runtime
|
|
39
|
+
- translators cannot see the full key in context
|
|
40
|
+
|
|
41
|
+
Write each key out in full and map the value to it:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// Avoid
|
|
45
|
+
t(`status.${status}`)
|
|
46
|
+
|
|
47
|
+
// Prefer: a lookup of full literal keys
|
|
48
|
+
const labels = {
|
|
49
|
+
active: t('status.active'),
|
|
50
|
+
paused: t('status.paused'),
|
|
51
|
+
} as const
|
|
52
|
+
labels[status]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// Avoid
|
|
57
|
+
t(`plans.${plan}.title`)
|
|
58
|
+
|
|
59
|
+
// Prefer: a switch, or a record of functions when the call must be lazy
|
|
60
|
+
const planTitle = {
|
|
61
|
+
free: () => t('plans.free.title'),
|
|
62
|
+
pro: () => t('plans.pro.title'),
|
|
63
|
+
}
|
|
64
|
+
planTitle[plan]()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
When the variable part is only a word inside a sentence, use a placeholder instead of a key part: `t('greeting', { name })` with the message `Hello, {name}`. When the text differs by count, use the library's plural syntax, not `t(count === 1 ? 'item.one' : 'item.many')`.
|
|
68
|
+
|
|
69
|
+
If the user's code already composes keys, do not rewrite it unasked. Tell the user which keys are affected, and treat those keys as used when you clean up.
|
|
70
|
+
|
|
71
|
+
## Set up and check
|
|
72
|
+
|
|
73
|
+
- To set up x18n, or migrate from i18n-ally, use the `x18n-setup` skill.
|
|
74
|
+
- To run `x18n check` and fix what it reports, use the `x18n-check` skill.
|
|
39
75
|
|
|
40
76
|
Commands below use `x18n`; without the dev dependency, run them as `npx @x18n/cli <command>`.
|
|
41
77
|
|
|
@@ -65,13 +101,6 @@ Maintain it while you work:
|
|
|
65
101
|
|
|
66
102
|
## Loops
|
|
67
103
|
|
|
68
|
-
### Check the project
|
|
69
|
-
|
|
70
|
-
```sh
|
|
71
|
-
x18n check --json
|
|
72
|
-
x18n status --json
|
|
73
|
-
```
|
|
74
|
-
|
|
75
104
|
### Add a key while coding
|
|
76
105
|
|
|
77
106
|
```sh
|
|
@@ -134,6 +163,16 @@ x18n extract --apply extract-plan.json --json
|
|
|
134
163
|
|
|
135
164
|
Edit the keys in the plan file before you apply it. The proposed keys follow the project's style; ask the user when there is none yet.
|
|
136
165
|
|
|
166
|
+
Tests, fixtures, mocks, stories, configs and type declarations are never scanned, in any language (`*.test.ts`, `*_test.go`, `test_*.py`, `*_spec.rb`, `*Test.java`, `vite.config.ts`, `tests/`, `spec/`, `e2e/`, ...). To keep strings that are not UI text out of the plan, drop them from the plan file, or add a control comment to the code:
|
|
167
|
+
|
|
168
|
+
| Comment | Skips |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| `x18n-ignore-file` | the whole file, wherever the comment is |
|
|
171
|
+
| `x18n-ignore-start` ... `x18n-ignore-end` | everything between them (to the end of the file when unclosed) |
|
|
172
|
+
| `x18n-ignore` | strings on its line and on the next line |
|
|
173
|
+
|
|
174
|
+
Use any comment syntax: `// x18n-ignore-file`, `<!-- x18n-ignore -->`. For strings that recur across files, use `extract.ignore` in the config instead.
|
|
175
|
+
|
|
137
176
|
## Output
|
|
138
177
|
|
|
139
178
|
Exit codes:
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: x18n-check
|
|
3
|
+
description: Run `x18n check` and fix what it reports - missing keys and translations, placeholder mismatches, invalid message syntax, unused keys, glossary violations. Use when the user asks to check, lint or fix translations, when `x18n check` fails locally or in CI, or before opening a PR that touches locale files or translation keys.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Check and fix translations
|
|
7
|
+
|
|
8
|
+
`x18n check` reports broken, untranslated and unused messages, like `tsc --noEmit` for i18n. Run it, fix the routine problems, and bring the rest to the user. Change locale files only through the `x18n` CLI (`set`, `apply`, `rename`, `delete`), never by hand. The `x18n` skill covers those commands.
|
|
9
|
+
|
|
10
|
+
If the project has no x18n setup yet, use the `x18n-setup` skill first.
|
|
11
|
+
|
|
12
|
+
## Decisions belong to the user
|
|
13
|
+
|
|
14
|
+
Before you act on one of these, stop and ask, with your recommendation and the facts you found. Batch the questions into one message.
|
|
15
|
+
|
|
16
|
+
- deleting keys reported as unused, since keys built at runtime look unused too
|
|
17
|
+
- a glossary term and its translation per locale
|
|
18
|
+
- changing the severity of a check in the `checks` map of the config, such as `'unused-key': 'error'` or `glossary: 'warning'`
|
|
19
|
+
- making warnings fail CI with `--strict` or `--max-warnings <n>`
|
|
20
|
+
|
|
21
|
+
## Loop
|
|
22
|
+
|
|
23
|
+
1. Run `x18n check --json`. Read `data.projects[].summary` for the counts and `diagnostics` for the problems. Group the diagnostics by `code` and `severity`. **Done when** you know the count per check, errors first.
|
|
24
|
+
2. Fix the errors, check by check, with the table below. Re-run only that check to confirm: `x18n check --only placeholder-mismatch --json`. **Done when** the summary shows no errors, or each remaining error waits on a user decision.
|
|
25
|
+
3. Fix the warnings you can fix without a decision, such as missing translations. **Done when** the remaining warnings each need a decision.
|
|
26
|
+
4. Ask the user about the remaining diagnostics in one message: list them by check, with your recommendation for each. Apply what they decide.
|
|
27
|
+
5. Run `x18n check --json` again and report the result. **Done when** `x18n check` exits with code 0, or every remaining diagnostic is one the user chose to keep.
|
|
28
|
+
|
|
29
|
+
## Fixes per check
|
|
30
|
+
|
|
31
|
+
| Check (`code`) | Fix |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `missing-key` | A usage names a key the source locale lacks. If it is a typo, fix the usage to an existing key (`x18n keys` lists them). Else add it: `x18n set <key> --value <text> --json`. |
|
|
34
|
+
| `missing-translation`, `empty-translation` | Translate: `x18n prompt translate --to <locale> --limit 50`, write the result to a file, then `x18n apply <file> --json`. Follow `nextCursor` when the prompt reports more keys. |
|
|
35
|
+
| `placeholder-mismatch` | Rewrite the target value with every placeholder of the source: `x18n set <key> --locale <locale> --value <text> --json`. |
|
|
36
|
+
| `invalid-syntax` | Fix the message syntax (ICU braces, linked messages) with `x18n set`. When a linked message does not exist, fix the link or add that key. |
|
|
37
|
+
| `key-conflict` | A key is both a message and a parent, or is defined twice in one locale. Ask which one to keep, then `x18n rename` or `x18n delete` with `--dry-run` first. |
|
|
38
|
+
| `unused-key` | Show the list from `x18n keys --unused --json` and delete only the keys the user confirms: `x18n delete <key> --dry-run --json`, then without `--dry-run`. Keys built at runtime go in `usage.keysInUse` in the config instead. |
|
|
39
|
+
| `glossary` | Rewrite the message with the term from the glossary (`x18n doctor --json` lists it under `config.glossary`), with `x18n set`. |
|
|
40
|
+
| `locale-code-conflict`, `dynamic-key`, `key-style`, `identical-to-source`, `hard-string` | Report them. Fix only when the user asks; `hard-string` is fixed with `x18n extract`, or silenced with an `x18n-ignore` control comment when the string is not UI text. |
|
|
41
|
+
|
|
42
|
+
After a batch of fixes, check only what you touched: `x18n check --only placeholder-mismatch,invalid-syntax,glossary --locale de --json`.
|
|
43
|
+
|
|
44
|
+
## CI
|
|
45
|
+
|
|
46
|
+
`x18n check` exits with code 1 on errors, so CI fails on broken keys without more setup. Warnings do not fail it unless the project uses `--strict` or `--max-warnings <n>`. For pull request annotations, use `x18n check --reporter github --reporter pretty`. Change none of this without asking.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: x18n-setup
|
|
3
|
+
description: Set up x18n (`@x18n/cli`) in a project, or migrate it from i18n-ally. Use when a project has no x18n config yet, when x18n detects the wrong locale files or library, when the user asks to add, install or configure x18n, or when `.vscode/settings.json` has `i18n-ally.*` settings.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Set up x18n
|
|
7
|
+
|
|
8
|
+
x18n reads a project's i18n setup (locale files, translate calls in code) and often needs no config. Set it up by checking what it detects, and write a config only when detection needs help.
|
|
9
|
+
|
|
10
|
+
## Decisions belong to the user
|
|
11
|
+
|
|
12
|
+
Before you act on one of these, stop and ask, with your recommendation and the facts you found. Batch the questions into one message.
|
|
13
|
+
|
|
14
|
+
- the source locale, and which locales the project supports
|
|
15
|
+
- the config format: `x18n.config.ts`, or YAML for projects without Node.js tooling
|
|
16
|
+
- what an i18n-ally migration drops (see the reference below)
|
|
17
|
+
- adding `x18n check` to CI or to a git hook
|
|
18
|
+
|
|
19
|
+
## Steps
|
|
20
|
+
|
|
21
|
+
1. Run `npx @x18n/cli doctor` in the project root. Without a config, x18n uses the project's i18n-ally settings if it has any, and detects the i18n library and the locale files. Check the detected presets, the locale files and the source locale. **Done when** the doctor output names the library the project uses and lists every locale file.
|
|
22
|
+
2. If the project has i18n-ally settings (`i18n-ally.*` keys in `.vscode/settings.json`), follow [references/migrate-i18n-ally.md](references/migrate-i18n-ally.md) instead of step 3.
|
|
23
|
+
3. If detection is wrong or incomplete, run `x18n init` to write `x18n.config.ts`, then fix `resources`, `sourceLocale` or `presets` in it. `x18n init --format yml` writes YAML. **Done when** `x18n doctor` shows the right result.
|
|
24
|
+
4. Install the CLI as a dev dependency with the project's package manager (`pnpm add -D @x18n/cli`), and add a script such as `"i18n:check": "x18n check"`. Ask the user before you add it to CI.
|
|
25
|
+
5. Run `x18n check --json` and report the summary to the user: errors and warnings per check (`code` of each diagnostic) and coverage per locale. Do not fix anything in this skill. **Done when** the user has the counts and knows that the `x18n-check` skill fixes them.
|
|
26
|
+
|
|
27
|
+
Without the dev dependency, run commands as `npx @x18n/cli <command>`. In a monorepo, `x18n status --json` lists every project; pass `--project <name>` to pick one.
|
|
File without changes
|