@syedamirali/i18n-toolkit 0.1.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/LICENSE +21 -0
- package/README.md +191 -0
- package/data/languages.json +642 -0
- package/dist/cli.js +2195 -0
- package/package.json +69 -0
- package/prompt/translation-prompt.md +51 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { parse } from "@babel/parser";
|
|
5
|
+
import _traverse from "@babel/traverse";
|
|
6
|
+
import * as t from "@babel/types";
|
|
7
|
+
import fg from "fast-glob";
|
|
8
|
+
import fs from "fs/promises";
|
|
9
|
+
import { createWriteStream } from "fs";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import { format as formatInspect } from "util";
|
|
13
|
+
var traverse = _traverse.default ?? _traverse;
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const opts = {
|
|
16
|
+
src: ".",
|
|
17
|
+
out: null,
|
|
18
|
+
outputDir: "./output",
|
|
19
|
+
logDir: "./logs",
|
|
20
|
+
noLog: false,
|
|
21
|
+
lang: "en",
|
|
22
|
+
includeDynamic: true,
|
|
23
|
+
includeMissing: true,
|
|
24
|
+
failOnConflict: false,
|
|
25
|
+
pretty: true,
|
|
26
|
+
debug: false
|
|
27
|
+
};
|
|
28
|
+
const readBool = (raw, fallback) => {
|
|
29
|
+
if (raw === void 0) return true;
|
|
30
|
+
if (raw === "true" || raw === "1" || raw === "yes") return true;
|
|
31
|
+
if (raw === "false" || raw === "0" || raw === "no") return false;
|
|
32
|
+
return fallback;
|
|
33
|
+
};
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const arg = argv[i];
|
|
36
|
+
if (!arg.startsWith("--")) continue;
|
|
37
|
+
const [flagRaw, eqVal] = arg.includes("=") ? arg.split(/=(.+)/) : [arg, void 0];
|
|
38
|
+
const flag = flagRaw.slice(2);
|
|
39
|
+
const peek = eqVal !== void 0 ? eqVal : argv[i + 1];
|
|
40
|
+
const consumeStr = () => {
|
|
41
|
+
if (eqVal !== void 0) return eqVal;
|
|
42
|
+
i++;
|
|
43
|
+
return argv[i];
|
|
44
|
+
};
|
|
45
|
+
switch (flag) {
|
|
46
|
+
case "src":
|
|
47
|
+
opts.src = consumeStr();
|
|
48
|
+
break;
|
|
49
|
+
case "out":
|
|
50
|
+
opts.out = consumeStr();
|
|
51
|
+
break;
|
|
52
|
+
case "output-dir":
|
|
53
|
+
opts.outputDir = consumeStr();
|
|
54
|
+
break;
|
|
55
|
+
case "log-dir":
|
|
56
|
+
opts.logDir = consumeStr();
|
|
57
|
+
break;
|
|
58
|
+
case "no-log":
|
|
59
|
+
opts.noLog = readBool(peek, true);
|
|
60
|
+
if (eqVal === void 0 && peek && (peek === "true" || peek === "false")) i++;
|
|
61
|
+
break;
|
|
62
|
+
case "lang":
|
|
63
|
+
opts.lang = consumeStr();
|
|
64
|
+
break;
|
|
65
|
+
case "include-dynamic":
|
|
66
|
+
opts.includeDynamic = readBool(peek, true);
|
|
67
|
+
if (eqVal === void 0 && peek && (peek === "true" || peek === "false")) i++;
|
|
68
|
+
break;
|
|
69
|
+
case "include-missing":
|
|
70
|
+
opts.includeMissing = readBool(peek, true);
|
|
71
|
+
if (eqVal === void 0 && peek && (peek === "true" || peek === "false")) i++;
|
|
72
|
+
break;
|
|
73
|
+
case "fail-on-conflict":
|
|
74
|
+
opts.failOnConflict = readBool(peek, false);
|
|
75
|
+
if (eqVal === void 0 && peek && (peek === "true" || peek === "false")) i++;
|
|
76
|
+
break;
|
|
77
|
+
case "pretty":
|
|
78
|
+
opts.pretty = readBool(peek, true);
|
|
79
|
+
if (eqVal === void 0 && peek && (peek === "true" || peek === "false")) i++;
|
|
80
|
+
break;
|
|
81
|
+
case "debug":
|
|
82
|
+
opts.debug = readBool(peek, true);
|
|
83
|
+
if (eqVal === void 0 && peek && (peek === "true" || peek === "false")) i++;
|
|
84
|
+
break;
|
|
85
|
+
case "help":
|
|
86
|
+
case "h":
|
|
87
|
+
printHelpAndExit();
|
|
88
|
+
default:
|
|
89
|
+
console.warn(`${ansi.yellow}[warn]${ansi.reset} unknown flag: --${flag}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return opts;
|
|
93
|
+
}
|
|
94
|
+
function printHelpAndExit() {
|
|
95
|
+
console.log(`translation-extractor \u2014 scan a project for translation keys
|
|
96
|
+
|
|
97
|
+
Usage:
|
|
98
|
+
npx tsx src/index.ts [options]
|
|
99
|
+
yarn extract -- [options]
|
|
100
|
+
|
|
101
|
+
Options:
|
|
102
|
+
--src <path> Root directory to scan (default ".")
|
|
103
|
+
--out <path> Output JSON file (overrides --output-dir)
|
|
104
|
+
--output-dir <path> Directory for auto-named output (default "./output")
|
|
105
|
+
--log-dir <path> Directory for run logs (default "./logs")
|
|
106
|
+
--no-log Disable writing the per-run log file
|
|
107
|
+
--lang <code> Top-level language key (default "en")
|
|
108
|
+
--include-dynamic <bool> Emit dynamic keys with "__dynamic__" value (default true)
|
|
109
|
+
--include-missing <bool> Emit "__missing_default__" for keys w/o fallback (default true)
|
|
110
|
+
--fail-on-conflict <bool> Exit 1 if duplicate keys conflict (default false)
|
|
111
|
+
--pretty <bool> Pretty-print output JSON (default true)
|
|
112
|
+
--debug <bool> Verbose logs; parse files >1 MB (default false)
|
|
113
|
+
|
|
114
|
+
Colors are auto-enabled when stdout is a TTY. To disable, set
|
|
115
|
+
NO_COLOR=1 in your environment. Log files always strip color codes.
|
|
116
|
+
|
|
117
|
+
When --out is omitted, output is written to:
|
|
118
|
+
<output-dir>/{timestamp}_translation_extractor.json
|
|
119
|
+
Logs are always written (unless --no-log) to:
|
|
120
|
+
<log-dir>/{timestamp}_translation_extractor.log
|
|
121
|
+
Timestamp format: DD_mon_HH_MM_SSam|pm (e.g. 10_may_11_52_30pm)
|
|
122
|
+
`);
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
var DYNAMIC = "__dynamic__";
|
|
126
|
+
var MISSING = "__missing_default__";
|
|
127
|
+
var MAX_FILE_BYTES = 1e6;
|
|
128
|
+
var LOCAL_KEY_ATTR_NAMES = /* @__PURE__ */ new Set(["localKey", "localeKey"]);
|
|
129
|
+
var SHORT_MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
130
|
+
var USE_COLOR = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
131
|
+
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
132
|
+
var stripAnsi = (s) => s.replace(ANSI_RE, "");
|
|
133
|
+
var ansi = {
|
|
134
|
+
reset: USE_COLOR ? "\x1B[0m" : "",
|
|
135
|
+
bold: USE_COLOR ? "\x1B[1m" : "",
|
|
136
|
+
dim: USE_COLOR ? "\x1B[2m" : "",
|
|
137
|
+
red: USE_COLOR ? "\x1B[31m" : "",
|
|
138
|
+
green: USE_COLOR ? "\x1B[32m" : "",
|
|
139
|
+
yellow: USE_COLOR ? "\x1B[33m" : "",
|
|
140
|
+
blue: USE_COLOR ? "\x1B[34m" : "",
|
|
141
|
+
magenta: USE_COLOR ? "\x1B[35m" : "",
|
|
142
|
+
cyan: USE_COLOR ? "\x1B[36m" : ""
|
|
143
|
+
};
|
|
144
|
+
var c = {
|
|
145
|
+
label: (s) => `${ansi.dim}${s}${ansi.reset}`,
|
|
146
|
+
value: (s) => `${ansi.bold}${s}${ansi.reset}`,
|
|
147
|
+
ok: (s) => `${ansi.green}${s}${ansi.reset}`,
|
|
148
|
+
warn: (s) => `${ansi.yellow}${s}${ansi.reset}`,
|
|
149
|
+
err: (s) => `${ansi.red}${s}${ansi.reset}`,
|
|
150
|
+
path: (s) => `${ansi.cyan}${s}${ansi.reset}`,
|
|
151
|
+
key: (s) => `${ansi.bold}${ansi.magenta}${s}${ansi.reset}`,
|
|
152
|
+
head: (s) => `${ansi.dim}${s}${ansi.reset}`
|
|
153
|
+
};
|
|
154
|
+
function formatTimestamp(d = /* @__PURE__ */ new Date()) {
|
|
155
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
156
|
+
const mon = SHORT_MONTHS[d.getMonth()];
|
|
157
|
+
let h = d.getHours();
|
|
158
|
+
const ampm = h >= 12 ? "pm" : "am";
|
|
159
|
+
h = h % 12;
|
|
160
|
+
if (h === 0) h = 12;
|
|
161
|
+
const hh = String(h).padStart(2, "0");
|
|
162
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
163
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
164
|
+
return `${dd}_${mon}_${hh}_${mm}_${ss}${ampm}`;
|
|
165
|
+
}
|
|
166
|
+
function setupLogTee(stream) {
|
|
167
|
+
const origLog = console.log;
|
|
168
|
+
const origWarn = console.warn;
|
|
169
|
+
const origError = console.error;
|
|
170
|
+
const tee = (orig) => (...args) => {
|
|
171
|
+
orig(...args);
|
|
172
|
+
stream.write(stripAnsi(formatInspect(...args)) + "\n");
|
|
173
|
+
};
|
|
174
|
+
console.log = tee(origLog);
|
|
175
|
+
console.warn = tee(origWarn);
|
|
176
|
+
console.error = tee(origError);
|
|
177
|
+
return {
|
|
178
|
+
teardown: async () => {
|
|
179
|
+
console.log = origLog;
|
|
180
|
+
console.warn = origWarn;
|
|
181
|
+
console.error = origError;
|
|
182
|
+
await new Promise((resolve) => stream.end(resolve));
|
|
183
|
+
},
|
|
184
|
+
dual: (consoleLine, logLine) => {
|
|
185
|
+
origLog(consoleLine);
|
|
186
|
+
stream.write(logLine + "\n");
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
var IGNORE_DIRS = [
|
|
191
|
+
"**/node_modules/**",
|
|
192
|
+
"**/.git/**",
|
|
193
|
+
"**/.next/**",
|
|
194
|
+
"**/dist/**",
|
|
195
|
+
"**/build/**",
|
|
196
|
+
"**/coverage/**",
|
|
197
|
+
"**/public/**",
|
|
198
|
+
"**/vendor/**",
|
|
199
|
+
"**/*.generated.*"
|
|
200
|
+
];
|
|
201
|
+
var Registry = class {
|
|
202
|
+
map = /* @__PURE__ */ new Map();
|
|
203
|
+
conflictsList = [];
|
|
204
|
+
staticCount = 0;
|
|
205
|
+
dynamicCount = 0;
|
|
206
|
+
missingCount = 0;
|
|
207
|
+
add(key, value, location) {
|
|
208
|
+
const existing = this.map.get(key);
|
|
209
|
+
if (!existing) {
|
|
210
|
+
this.map.set(key, { value, location });
|
|
211
|
+
this.tallyAdd(value);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (existing.value === value) return;
|
|
215
|
+
this.conflictsList.push({
|
|
216
|
+
key,
|
|
217
|
+
kept: existing,
|
|
218
|
+
seen: { value, location }
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
tallyAdd(value) {
|
|
222
|
+
if (value === DYNAMIC) this.dynamicCount++;
|
|
223
|
+
else if (value === MISSING) this.missingCount++;
|
|
224
|
+
else this.staticCount++;
|
|
225
|
+
}
|
|
226
|
+
entries() {
|
|
227
|
+
return Array.from(this.map.entries()).map(([k, v]) => [k, v.value]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
228
|
+
}
|
|
229
|
+
get size() {
|
|
230
|
+
return this.map.size;
|
|
231
|
+
}
|
|
232
|
+
get conflicts() {
|
|
233
|
+
return this.conflictsList;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
function getStaticString(node) {
|
|
237
|
+
if (!node) return null;
|
|
238
|
+
if (t.isStringLiteral(node)) return node.value;
|
|
239
|
+
if (t.isTemplateLiteral(node) && node.expressions.length === 0) {
|
|
240
|
+
return node.quasis.map((q) => q.value.cooked ?? q.value.raw).join("");
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function getKeyFromNode(node) {
|
|
245
|
+
if (!node) return null;
|
|
246
|
+
if (t.isStringLiteral(node)) {
|
|
247
|
+
return { key: node.value, isDynamic: false };
|
|
248
|
+
}
|
|
249
|
+
if (t.isTemplateLiteral(node)) {
|
|
250
|
+
let key = "";
|
|
251
|
+
for (let i = 0; i < node.quasis.length; i++) {
|
|
252
|
+
const q = node.quasis[i];
|
|
253
|
+
key += q.value.cooked ?? q.value.raw;
|
|
254
|
+
if (i < node.expressions.length) {
|
|
255
|
+
const expr = node.expressions[i];
|
|
256
|
+
key += `\${${reconstructExpression(expr)}}`;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { key, isDynamic: node.expressions.length > 0 };
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
function reconstructExpression(node) {
|
|
264
|
+
if (t.isIdentifier(node)) return node.name;
|
|
265
|
+
if (t.isMemberExpression(node)) {
|
|
266
|
+
const serialized = serializeSimpleMember(node);
|
|
267
|
+
return serialized ?? "__expr__";
|
|
268
|
+
}
|
|
269
|
+
return "__expr__";
|
|
270
|
+
}
|
|
271
|
+
function serializeSimpleMember(node) {
|
|
272
|
+
if (node.computed) return null;
|
|
273
|
+
if (!t.isIdentifier(node.property)) return null;
|
|
274
|
+
if (t.isIdentifier(node.object)) {
|
|
275
|
+
return `${node.object.name}.${node.property.name}`;
|
|
276
|
+
}
|
|
277
|
+
if (t.isMemberExpression(node.object)) {
|
|
278
|
+
const inner = serializeSimpleMember(node.object);
|
|
279
|
+
return inner ? `${inner}.${node.property.name}` : null;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
function getObjectPropertyValue(obj, propertyName) {
|
|
284
|
+
for (const prop of obj.properties) {
|
|
285
|
+
if (!t.isObjectProperty(prop)) continue;
|
|
286
|
+
if (prop.computed) continue;
|
|
287
|
+
const k = prop.key;
|
|
288
|
+
const matches = t.isIdentifier(k) && k.name === propertyName || t.isStringLiteral(k) && k.value === propertyName;
|
|
289
|
+
if (!matches) continue;
|
|
290
|
+
const staticVal = getStaticString(prop.value);
|
|
291
|
+
if (staticVal !== null) return { kind: "static", value: staticVal };
|
|
292
|
+
return { kind: "nonstatic" };
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
function normalizeWhitespace(text) {
|
|
297
|
+
return decodeEntities(text).replace(/\s+/g, " ").trim();
|
|
298
|
+
}
|
|
299
|
+
function decodeEntities(text) {
|
|
300
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/ /g, " ");
|
|
301
|
+
}
|
|
302
|
+
function getJSXChildrenText(children) {
|
|
303
|
+
let out = "";
|
|
304
|
+
let isDynamic = false;
|
|
305
|
+
let sawAnyVisible = false;
|
|
306
|
+
for (const child of children) {
|
|
307
|
+
if (t.isJSXText(child)) {
|
|
308
|
+
const normalized = normalizeWhitespace(child.value);
|
|
309
|
+
if (normalized.length === 0) {
|
|
310
|
+
if (sawAnyVisible) out += " ";
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const leftPad = /^\s/.test(child.value) && sawAnyVisible ? " " : "";
|
|
314
|
+
const rightPad = /\s$/.test(child.value) ? " " : "";
|
|
315
|
+
out += leftPad + normalized + rightPad;
|
|
316
|
+
sawAnyVisible = true;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (t.isJSXExpressionContainer(child)) {
|
|
320
|
+
const expr = child.expression;
|
|
321
|
+
if (t.isJSXEmptyExpression(expr)) continue;
|
|
322
|
+
const staticVal = getStaticString(expr);
|
|
323
|
+
if (staticVal !== null) {
|
|
324
|
+
out += staticVal;
|
|
325
|
+
sawAnyVisible = true;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
isDynamic = true;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (t.isJSXElement(child)) {
|
|
332
|
+
const serialized = serializeJSXElement(child);
|
|
333
|
+
if (serialized.isDynamic) {
|
|
334
|
+
isDynamic = true;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
out += serialized.value;
|
|
338
|
+
sawAnyVisible = true;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (t.isJSXFragment(child)) {
|
|
342
|
+
const inner = getJSXChildrenText(child.children);
|
|
343
|
+
if (inner.isDynamic) {
|
|
344
|
+
isDynamic = true;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
out += inner.value;
|
|
348
|
+
if (!inner.isEmpty) sawAnyVisible = true;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
isDynamic = true;
|
|
352
|
+
}
|
|
353
|
+
out = out.replace(/\s+/g, " ").trim();
|
|
354
|
+
return { value: out, isDynamic, isEmpty: out.length === 0 };
|
|
355
|
+
}
|
|
356
|
+
function getJSXElementTagName(el) {
|
|
357
|
+
const name = el.openingElement.name;
|
|
358
|
+
if (t.isJSXIdentifier(name)) return name.name;
|
|
359
|
+
if (t.isJSXMemberExpression(name)) {
|
|
360
|
+
const parts = [];
|
|
361
|
+
let cur = name;
|
|
362
|
+
while (t.isJSXMemberExpression(cur)) {
|
|
363
|
+
parts.unshift(cur.property.name);
|
|
364
|
+
cur = cur.object;
|
|
365
|
+
}
|
|
366
|
+
if (t.isJSXIdentifier(cur)) parts.unshift(cur.name);
|
|
367
|
+
return parts.join(".");
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
function serializeJSXElement(el) {
|
|
372
|
+
const tag = getJSXElementTagName(el);
|
|
373
|
+
if (!tag) return { value: "", isDynamic: true };
|
|
374
|
+
let attrStr = "";
|
|
375
|
+
for (const attr of el.openingElement.attributes) {
|
|
376
|
+
if (t.isJSXSpreadAttribute(attr)) return { value: "", isDynamic: true };
|
|
377
|
+
if (!t.isJSXAttribute(attr)) return { value: "", isDynamic: true };
|
|
378
|
+
const aName = t.isJSXIdentifier(attr.name) ? attr.name.name : null;
|
|
379
|
+
if (!aName) return { value: "", isDynamic: true };
|
|
380
|
+
if (attr.value === null || attr.value === void 0) {
|
|
381
|
+
attrStr += ` ${aName}`;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (t.isStringLiteral(attr.value)) {
|
|
385
|
+
attrStr += ` ${aName}="${escapeAttr(attr.value.value)}"`;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (t.isJSXExpressionContainer(attr.value)) {
|
|
389
|
+
const expr = attr.value.expression;
|
|
390
|
+
if (t.isJSXEmptyExpression(expr)) continue;
|
|
391
|
+
const staticVal = getStaticString(expr);
|
|
392
|
+
if (staticVal !== null) {
|
|
393
|
+
attrStr += ` ${aName}="${escapeAttr(staticVal)}"`;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
return { value: "", isDynamic: true };
|
|
397
|
+
}
|
|
398
|
+
return { value: "", isDynamic: true };
|
|
399
|
+
}
|
|
400
|
+
if (el.openingElement.selfClosing || el.children.length === 0) {
|
|
401
|
+
return { value: `<${tag}${attrStr} />`, isDynamic: false };
|
|
402
|
+
}
|
|
403
|
+
const inner = getJSXChildrenText(el.children);
|
|
404
|
+
if (inner.isDynamic) return { value: "", isDynamic: true };
|
|
405
|
+
return { value: `<${tag}${attrStr}>${inner.value}</${tag}>`, isDynamic: false };
|
|
406
|
+
}
|
|
407
|
+
function escapeAttr(s) {
|
|
408
|
+
return s.replace(/"/g, """);
|
|
409
|
+
}
|
|
410
|
+
function lineOf(node) {
|
|
411
|
+
return node.loc?.start.line ?? 0;
|
|
412
|
+
}
|
|
413
|
+
function extractFromFile(filePath, source, opts) {
|
|
414
|
+
let ast;
|
|
415
|
+
try {
|
|
416
|
+
ast = parse(source, {
|
|
417
|
+
sourceType: "unambiguous",
|
|
418
|
+
allowReturnOutsideFunction: true,
|
|
419
|
+
allowAwaitOutsideFunction: true,
|
|
420
|
+
errorRecovery: true,
|
|
421
|
+
plugins: [
|
|
422
|
+
"typescript",
|
|
423
|
+
"jsx",
|
|
424
|
+
"decorators-legacy",
|
|
425
|
+
"classProperties",
|
|
426
|
+
"objectRestSpread",
|
|
427
|
+
"optionalChaining",
|
|
428
|
+
"nullishCoalescingOperator",
|
|
429
|
+
"importMeta"
|
|
430
|
+
]
|
|
431
|
+
});
|
|
432
|
+
} catch (err) {
|
|
433
|
+
if (opts.debug) console.warn(`${c.err("[parse-error]")} ${c.path(filePath)}: ${err.message}`);
|
|
434
|
+
throw err;
|
|
435
|
+
}
|
|
436
|
+
const records = [];
|
|
437
|
+
const handledCalls = /* @__PURE__ */ new WeakSet();
|
|
438
|
+
const addEntry = (key, value, isDynamicKey, node) => {
|
|
439
|
+
const effectiveValue = isDynamicKey ? DYNAMIC : value;
|
|
440
|
+
if (effectiveValue === DYNAMIC && !opts.includeDynamic) return;
|
|
441
|
+
if (effectiveValue === MISSING && !opts.includeMissing) return;
|
|
442
|
+
records.push({ key, value: effectiveValue, line: lineOf(node) });
|
|
443
|
+
};
|
|
444
|
+
function isTCall(call) {
|
|
445
|
+
if (!t.isIdentifier(call.callee) || call.callee.name !== "t") return false;
|
|
446
|
+
const first = call.arguments[0];
|
|
447
|
+
if (!first) return false;
|
|
448
|
+
return t.isStringLiteral(first) || t.isTemplateLiteral(first);
|
|
449
|
+
}
|
|
450
|
+
function handleTCall(call, externalFallback) {
|
|
451
|
+
const first = call.arguments[0];
|
|
452
|
+
const keyInfo = getKeyFromNode(first);
|
|
453
|
+
if (!keyInfo) return;
|
|
454
|
+
let value;
|
|
455
|
+
const second = call.arguments[1];
|
|
456
|
+
if (second && t.isObjectExpression(second)) {
|
|
457
|
+
const prop = getObjectPropertyValue(second, "__default__");
|
|
458
|
+
if (prop && prop.kind === "static") {
|
|
459
|
+
value = prop.value;
|
|
460
|
+
} else if (prop && prop.kind === "nonstatic") {
|
|
461
|
+
value = DYNAMIC;
|
|
462
|
+
} else if (externalFallback !== null) {
|
|
463
|
+
value = externalFallback;
|
|
464
|
+
} else {
|
|
465
|
+
value = MISSING;
|
|
466
|
+
}
|
|
467
|
+
} else if (externalFallback !== null) {
|
|
468
|
+
value = externalFallback;
|
|
469
|
+
} else {
|
|
470
|
+
value = MISSING;
|
|
471
|
+
}
|
|
472
|
+
addEntry(keyInfo.key, value, keyInfo.isDynamic, call);
|
|
473
|
+
}
|
|
474
|
+
traverse(ast, {
|
|
475
|
+
// Handle `t("k") || "fallback"` BEFORE the inner CallExpression visitor sees it.
|
|
476
|
+
LogicalExpression(path4) {
|
|
477
|
+
const node = path4.node;
|
|
478
|
+
if (node.operator !== "||") return;
|
|
479
|
+
if (!t.isCallExpression(node.left)) return;
|
|
480
|
+
if (!isTCall(node.left)) return;
|
|
481
|
+
const fallback = getStaticString(node.right);
|
|
482
|
+
if (fallback === null) return;
|
|
483
|
+
handleTCall(node.left, fallback);
|
|
484
|
+
handledCalls.add(node.left);
|
|
485
|
+
},
|
|
486
|
+
CallExpression(path4) {
|
|
487
|
+
const node = path4.node;
|
|
488
|
+
if (handledCalls.has(node)) return;
|
|
489
|
+
if (!isTCall(node)) return;
|
|
490
|
+
handleTCall(node, null);
|
|
491
|
+
},
|
|
492
|
+
JSXOpeningElement(path4) {
|
|
493
|
+
const openingEl = path4.node;
|
|
494
|
+
let localKeyAttr = null;
|
|
495
|
+
for (const attr of openingEl.attributes) {
|
|
496
|
+
if (!t.isJSXAttribute(attr)) continue;
|
|
497
|
+
if (!t.isJSXIdentifier(attr.name)) continue;
|
|
498
|
+
if (!LOCAL_KEY_ATTR_NAMES.has(attr.name.name)) continue;
|
|
499
|
+
localKeyAttr = attr;
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
if (!localKeyAttr || !localKeyAttr.value) return;
|
|
503
|
+
let keyNode = null;
|
|
504
|
+
if (t.isStringLiteral(localKeyAttr.value)) {
|
|
505
|
+
keyNode = localKeyAttr.value;
|
|
506
|
+
} else if (t.isJSXExpressionContainer(localKeyAttr.value)) {
|
|
507
|
+
const expr = localKeyAttr.value.expression;
|
|
508
|
+
if (!t.isJSXEmptyExpression(expr)) keyNode = expr;
|
|
509
|
+
}
|
|
510
|
+
const keyInfo = getKeyFromNode(keyNode);
|
|
511
|
+
if (!keyInfo) return;
|
|
512
|
+
const parent = path4.parent;
|
|
513
|
+
const children = t.isJSXElement(parent) ? parent.children : [];
|
|
514
|
+
let value;
|
|
515
|
+
if (openingEl.selfClosing || children.length === 0) {
|
|
516
|
+
value = MISSING;
|
|
517
|
+
} else {
|
|
518
|
+
const serialized = getJSXChildrenText(children);
|
|
519
|
+
if (serialized.isEmpty) value = MISSING;
|
|
520
|
+
else if (serialized.isDynamic) value = DYNAMIC;
|
|
521
|
+
else value = serialized.value;
|
|
522
|
+
}
|
|
523
|
+
addEntry(keyInfo.key, value, keyInfo.isDynamic, openingEl);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
return records;
|
|
527
|
+
}
|
|
528
|
+
async function runExtraction(opts) {
|
|
529
|
+
const srcAbs = path.resolve(opts.src);
|
|
530
|
+
const includeDynamic = opts.includeDynamic !== false;
|
|
531
|
+
const includeMissing = opts.includeMissing !== false;
|
|
532
|
+
const debug = opts.debug === true;
|
|
533
|
+
const fileOpts = {
|
|
534
|
+
includeDynamic,
|
|
535
|
+
includeMissing,
|
|
536
|
+
debug
|
|
537
|
+
};
|
|
538
|
+
const files = await fg(["**/*.{ts,tsx,js,jsx}"], {
|
|
539
|
+
cwd: srcAbs,
|
|
540
|
+
absolute: true,
|
|
541
|
+
ignore: IGNORE_DIRS,
|
|
542
|
+
dot: false,
|
|
543
|
+
followSymbolicLinks: false
|
|
544
|
+
});
|
|
545
|
+
files.sort();
|
|
546
|
+
const registry = new Registry();
|
|
547
|
+
let skippedFiles = 0;
|
|
548
|
+
let parsedFiles = 0;
|
|
549
|
+
let parseErrorFiles = 0;
|
|
550
|
+
const perFileRecords = new Array(files.length).fill(null);
|
|
551
|
+
const concurrency = Math.max(1, os.cpus().length);
|
|
552
|
+
let cursor = 0;
|
|
553
|
+
const worker = async () => {
|
|
554
|
+
while (true) {
|
|
555
|
+
const i = cursor++;
|
|
556
|
+
if (i >= files.length) return;
|
|
557
|
+
const file = files[i];
|
|
558
|
+
let size;
|
|
559
|
+
try {
|
|
560
|
+
const st = await fs.stat(file);
|
|
561
|
+
size = st.size;
|
|
562
|
+
} catch (err) {
|
|
563
|
+
if (debug) console.warn(`${c.warn("[stat-error]")} ${c.path(file)}: ${err.message}`);
|
|
564
|
+
skippedFiles++;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (size > MAX_FILE_BYTES && !debug) {
|
|
568
|
+
skippedFiles++;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
let source;
|
|
572
|
+
try {
|
|
573
|
+
source = await fs.readFile(file, "utf8");
|
|
574
|
+
} catch (err) {
|
|
575
|
+
if (debug) console.warn(`${c.warn("[read-error]")} ${c.path(file)}: ${err.message}`);
|
|
576
|
+
skippedFiles++;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
perFileRecords[i] = extractFromFile(file, source, fileOpts);
|
|
581
|
+
parsedFiles++;
|
|
582
|
+
if (debug) console.log(`${c.ok("[parsed]")} ${c.path(path.relative(process.cwd(), file))}`);
|
|
583
|
+
} catch {
|
|
584
|
+
parseErrorFiles++;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
589
|
+
for (let i = 0; i < files.length; i++) {
|
|
590
|
+
const recs = perFileRecords[i];
|
|
591
|
+
if (!recs) continue;
|
|
592
|
+
const file = files[i];
|
|
593
|
+
for (const r of recs) {
|
|
594
|
+
registry.add(r.key, r.value, { file, line: r.line });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
entries: registry.entries(),
|
|
599
|
+
conflicts: registry.conflicts,
|
|
600
|
+
scannedFiles: files.length,
|
|
601
|
+
parsedFiles,
|
|
602
|
+
skippedFiles,
|
|
603
|
+
parseErrorFiles,
|
|
604
|
+
keys: registry.size,
|
|
605
|
+
staticCount: registry.staticCount,
|
|
606
|
+
dynamicCount: registry.dynamicCount,
|
|
607
|
+
missingCount: registry.missingCount
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
async function main() {
|
|
611
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
612
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
613
|
+
const timestamp = formatTimestamp(startedAt);
|
|
614
|
+
const srcAbs = path.resolve(opts.src);
|
|
615
|
+
let outAbs;
|
|
616
|
+
if (opts.out !== null) {
|
|
617
|
+
outAbs = path.resolve(opts.out);
|
|
618
|
+
} else {
|
|
619
|
+
const outDirAbs = path.resolve(opts.outputDir);
|
|
620
|
+
await fs.mkdir(outDirAbs, { recursive: true });
|
|
621
|
+
outAbs = path.join(outDirAbs, `${timestamp}_translation_extractor.json`);
|
|
622
|
+
}
|
|
623
|
+
let logTee = null;
|
|
624
|
+
let logAbs = null;
|
|
625
|
+
if (!opts.noLog) {
|
|
626
|
+
const logDirAbs = path.resolve(opts.logDir);
|
|
627
|
+
await fs.mkdir(logDirAbs, { recursive: true });
|
|
628
|
+
logAbs = path.join(logDirAbs, `${timestamp}_translation_extractor.log`);
|
|
629
|
+
const stream = createWriteStream(logAbs, { flags: "a" });
|
|
630
|
+
logTee = setupLogTee(stream);
|
|
631
|
+
const tag = c.head("[translation-extractor]");
|
|
632
|
+
console.log(`${tag} run started ${c.label(startedAt.toISOString())}`);
|
|
633
|
+
console.log(`${tag} src=${c.path(srcAbs)}`);
|
|
634
|
+
console.log(`${tag} out=${c.path(outAbs)}`);
|
|
635
|
+
console.log(`${tag} log=${c.path(logAbs)}`);
|
|
636
|
+
console.log("");
|
|
637
|
+
}
|
|
638
|
+
const result = await runExtraction({
|
|
639
|
+
src: srcAbs,
|
|
640
|
+
includeDynamic: opts.includeDynamic,
|
|
641
|
+
includeMissing: opts.includeMissing,
|
|
642
|
+
debug: opts.debug
|
|
643
|
+
});
|
|
644
|
+
if (result.conflicts.length > 0) {
|
|
645
|
+
const emit = (consoleLine, logLine) => {
|
|
646
|
+
if (logTee) logTee.dual(consoleLine, logLine);
|
|
647
|
+
else console.warn(consoleLine);
|
|
648
|
+
};
|
|
649
|
+
if (logTee) logTee.dual("", "");
|
|
650
|
+
else console.log("");
|
|
651
|
+
for (const conf of result.conflicts) {
|
|
652
|
+
const keptAbs = conf.kept.location.file;
|
|
653
|
+
const seenAbs = conf.seen.location.file;
|
|
654
|
+
const keptRel = path.relative(process.cwd(), keptAbs);
|
|
655
|
+
const seenRel = path.relative(process.cwd(), seenAbs);
|
|
656
|
+
const keptLine = conf.kept.location.line;
|
|
657
|
+
const seenLine = conf.seen.location.line;
|
|
658
|
+
const keptVal = JSON.stringify(conf.kept.value);
|
|
659
|
+
const seenVal = JSON.stringify(conf.seen.value);
|
|
660
|
+
emit(
|
|
661
|
+
`${c.err("CONFLICT")} ${c.key(conf.key)}`,
|
|
662
|
+
`CONFLICT ${conf.key}`
|
|
663
|
+
);
|
|
664
|
+
emit(
|
|
665
|
+
` ${c.ok("kept:")} ${keptVal} ${c.label(`(${keptRel}:${keptLine})`)}`,
|
|
666
|
+
` kept: ${keptVal} (${keptAbs}:${keptLine})`
|
|
667
|
+
);
|
|
668
|
+
emit(
|
|
669
|
+
` ${c.warn("seen:")} ${seenVal} ${c.label(`(${seenRel}:${seenLine})`)}`,
|
|
670
|
+
` seen: ${seenVal} (${seenAbs}:${seenLine})`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
if (logTee) logTee.dual("", "");
|
|
674
|
+
else console.log("");
|
|
675
|
+
}
|
|
676
|
+
const langObj = {};
|
|
677
|
+
for (const [k, v] of result.entries) langObj[k] = v;
|
|
678
|
+
const output2 = { [opts.lang]: langObj };
|
|
679
|
+
const json = opts.pretty ? JSON.stringify(output2, null, 2) + "\n" : JSON.stringify(output2);
|
|
680
|
+
await fs.mkdir(path.dirname(outAbs), { recursive: true });
|
|
681
|
+
await fs.writeFile(outAbs, json, "utf8");
|
|
682
|
+
const skippedNum = result.skippedFiles > 0 ? c.warn(result.skippedFiles) : c.value(result.skippedFiles);
|
|
683
|
+
const skipNote = result.skippedFiles > 0 ? c.label(" (too large or unreadable; rerun with --debug to include)") : "";
|
|
684
|
+
const conflictsNum = result.conflicts.length > 0 ? c.warn(result.conflicts.length) : c.ok(result.conflicts.length);
|
|
685
|
+
const parseErrorsNum = c.err(result.parseErrorFiles);
|
|
686
|
+
const labels = ["Scanned", "Parsed", "Skipped", "Keys", "Conflicts", "Output"];
|
|
687
|
+
if (result.parseErrorFiles > 0) labels.push("Parse errors");
|
|
688
|
+
if (logAbs) labels.push("Log");
|
|
689
|
+
const pad = Math.max(...labels.map((l) => l.length));
|
|
690
|
+
const stat = (label, value) => `${c.label(label.padEnd(pad) + " :")} ${value}`;
|
|
691
|
+
console.log(stat("Scanned", `${c.value(result.scannedFiles)} ${c.label("files")}`));
|
|
692
|
+
console.log(stat("Parsed", c.value(result.parsedFiles)));
|
|
693
|
+
if (result.parseErrorFiles > 0) console.log(stat("Parse errors", parseErrorsNum));
|
|
694
|
+
console.log(stat("Skipped", `${skippedNum}${skipNote}`));
|
|
695
|
+
console.log(
|
|
696
|
+
stat(
|
|
697
|
+
"Keys",
|
|
698
|
+
`${c.value(result.keys)} ${c.label("(static:")} ${c.value(result.staticCount)}${c.label(", dynamic:")} ${c.value(result.dynamicCount)}${c.label(", missing:")} ${c.value(result.missingCount)}${c.label(")")}`
|
|
699
|
+
)
|
|
700
|
+
);
|
|
701
|
+
console.log(stat("Conflicts", String(conflictsNum)));
|
|
702
|
+
console.log(stat("Output", c.path(path.relative(process.cwd(), outAbs))));
|
|
703
|
+
if (logAbs) console.log(stat("Log", c.path(path.relative(process.cwd(), logAbs))));
|
|
704
|
+
if (logTee) await logTee.teardown();
|
|
705
|
+
if (opts.failOnConflict && result.conflicts.length > 0) {
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async function runCommand(_argv) {
|
|
710
|
+
try {
|
|
711
|
+
await main();
|
|
712
|
+
} catch (err) {
|
|
713
|
+
console.error(`${ansi.red}${ansi.bold}[fatal]${ansi.reset}`, err);
|
|
714
|
+
process.exit(1);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// src/diff.ts
|
|
719
|
+
import { createInterface } from "readline/promises";
|
|
720
|
+
import fs2 from "fs/promises";
|
|
721
|
+
import { createWriteStream as createWriteStream2 } from "fs";
|
|
722
|
+
import path2 from "path";
|
|
723
|
+
import { stdin as input, stdout as output } from "process";
|
|
724
|
+
var TARGET_LANG = "en";
|
|
725
|
+
var DIFF_OUTPUT_DIR = "./diff-output";
|
|
726
|
+
function parseDiffArgs(argv) {
|
|
727
|
+
const opts = { src: null, resource: null };
|
|
728
|
+
for (let i = 0; i < argv.length; i++) {
|
|
729
|
+
const arg = argv[i];
|
|
730
|
+
if (!arg.startsWith("--")) continue;
|
|
731
|
+
const [flagRaw, eqVal] = arg.includes("=") ? arg.split(/=(.+)/) : [arg, void 0];
|
|
732
|
+
const flag = flagRaw.slice(2);
|
|
733
|
+
const consumeStr = () => eqVal !== void 0 ? eqVal : argv[++i];
|
|
734
|
+
switch (flag) {
|
|
735
|
+
case "src":
|
|
736
|
+
opts.src = consumeStr();
|
|
737
|
+
break;
|
|
738
|
+
case "resource":
|
|
739
|
+
opts.resource = consumeStr();
|
|
740
|
+
break;
|
|
741
|
+
default:
|
|
742
|
+
console.warn(`${ansi.yellow}[warn]${ansi.reset} unknown flag: --${flag}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return opts;
|
|
746
|
+
}
|
|
747
|
+
function cleanPath(raw) {
|
|
748
|
+
let s = raw.trim();
|
|
749
|
+
if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'") || s.startsWith("`") && s.endsWith("`")) {
|
|
750
|
+
s = s.slice(1, -1);
|
|
751
|
+
}
|
|
752
|
+
return s;
|
|
753
|
+
}
|
|
754
|
+
async function resolveSourcePath(p) {
|
|
755
|
+
if (!p) return { error: "Empty input." };
|
|
756
|
+
const abs = path2.resolve(p);
|
|
757
|
+
try {
|
|
758
|
+
const st = await fs2.stat(abs);
|
|
759
|
+
if (st.isDirectory()) return { kind: "directory", absPath: abs };
|
|
760
|
+
if (st.isFile()) {
|
|
761
|
+
if (!abs.toLowerCase().endsWith(".json")) return { error: `Not a .json file: ${abs}` };
|
|
762
|
+
return { kind: "json", absPath: abs };
|
|
763
|
+
}
|
|
764
|
+
return { error: `Not a file or directory: ${abs}` };
|
|
765
|
+
} catch (err) {
|
|
766
|
+
return { error: `Path not accessible: ${abs} \u2014 ${err.message}` };
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async function resolveResourcePath(p) {
|
|
770
|
+
if (!p) return { error: "Empty input." };
|
|
771
|
+
const abs = path2.resolve(p);
|
|
772
|
+
try {
|
|
773
|
+
const st = await fs2.stat(abs);
|
|
774
|
+
if (!st.isFile()) return { error: `Not a file: ${abs}` };
|
|
775
|
+
if (!abs.toLowerCase().endsWith(".json")) return { error: `Not a .json file: ${abs}` };
|
|
776
|
+
return { absPath: abs };
|
|
777
|
+
} catch (err) {
|
|
778
|
+
return { error: `Path not accessible: ${abs} \u2014 ${err.message}` };
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function promptForSource(rl) {
|
|
782
|
+
while (true) {
|
|
783
|
+
console.log(c.head("[1/2] Webapp source"));
|
|
784
|
+
console.log(c.label(" Paste a path. Accepts either:"));
|
|
785
|
+
console.log(c.label(" - a directory (will run the extractor live)"));
|
|
786
|
+
console.log(c.label(" - a JSON file (will use directly, must be extractor-shaped)"));
|
|
787
|
+
const raw = await rl.question(` ${c.value(">")} `);
|
|
788
|
+
const resolved = await resolveSourcePath(cleanPath(raw));
|
|
789
|
+
if ("error" in resolved) {
|
|
790
|
+
console.log(c.err(` ${resolved.error}
|
|
791
|
+
`));
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
return resolved;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
async function promptForResource(rl) {
|
|
798
|
+
while (true) {
|
|
799
|
+
console.log("");
|
|
800
|
+
console.log(c.head("[2/2] Resource file"));
|
|
801
|
+
console.log(c.label(" Paste a JSON file path (flat or language-wrapped):"));
|
|
802
|
+
const raw = await rl.question(` ${c.value(">")} `);
|
|
803
|
+
const resolved = await resolveResourcePath(cleanPath(raw));
|
|
804
|
+
if ("error" in resolved) {
|
|
805
|
+
console.log(c.err(` ${resolved.error}
|
|
806
|
+
`));
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
return resolved.absPath;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async function loadKeyMap(filePath, sourceLabel) {
|
|
813
|
+
const text = await fs2.readFile(filePath, "utf8");
|
|
814
|
+
const json = JSON.parse(text);
|
|
815
|
+
if (json === null || typeof json !== "object" || Array.isArray(json)) {
|
|
816
|
+
throw new Error(`${sourceLabel}: expected a JSON object at the top level`);
|
|
817
|
+
}
|
|
818
|
+
const topKeys = Object.keys(json);
|
|
819
|
+
const wasLangWrapped = topKeys.length > 0 && topKeys.every((k) => typeof json[k] === "object" && json[k] !== null);
|
|
820
|
+
let flat;
|
|
821
|
+
let pickedLang = null;
|
|
822
|
+
if (wasLangWrapped) {
|
|
823
|
+
if (!(TARGET_LANG in json)) {
|
|
824
|
+
throw new Error(
|
|
825
|
+
`${sourceLabel}: language-wrapped JSON has no "${TARGET_LANG}" entry (available: ${topKeys.join(", ")})`
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
flat = json[TARGET_LANG];
|
|
829
|
+
pickedLang = TARGET_LANG;
|
|
830
|
+
} else {
|
|
831
|
+
flat = json;
|
|
832
|
+
}
|
|
833
|
+
const raw = {};
|
|
834
|
+
const emptyValueKeys = [];
|
|
835
|
+
for (const [k, v] of Object.entries(flat)) {
|
|
836
|
+
if (typeof v !== "string") continue;
|
|
837
|
+
if (v === "") emptyValueKeys.push(k);
|
|
838
|
+
raw[k] = v;
|
|
839
|
+
}
|
|
840
|
+
return {
|
|
841
|
+
raw,
|
|
842
|
+
emptyValueKeys,
|
|
843
|
+
pickedLang,
|
|
844
|
+
wasLangWrapped,
|
|
845
|
+
availableLangs: wasLangWrapped ? topKeys : []
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
async function main2() {
|
|
849
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
850
|
+
const timestamp = formatTimestamp(startedAt);
|
|
851
|
+
const runDir = path2.resolve(DIFF_OUTPUT_DIR, timestamp);
|
|
852
|
+
await fs2.mkdir(runDir, { recursive: true });
|
|
853
|
+
const logAbs = path2.join(runDir, `${timestamp}_translation_diff.log`);
|
|
854
|
+
const missingInResourcePath = path2.join(runDir, "missing_in_resource.json");
|
|
855
|
+
const missingInExtractorPath = path2.join(runDir, "missing_in_extractor.json");
|
|
856
|
+
const stream = createWriteStream2(logAbs, { flags: "a" });
|
|
857
|
+
const logTee = setupLogTee(stream);
|
|
858
|
+
const tag = c.head("[translation-diff]");
|
|
859
|
+
console.log(`${tag} run started ${c.label(startedAt.toISOString())}`);
|
|
860
|
+
console.log(`${tag} output ${c.path(runDir)}`);
|
|
861
|
+
console.log("");
|
|
862
|
+
const cli = parseDiffArgs(process.argv.slice(2));
|
|
863
|
+
let source;
|
|
864
|
+
let resourcePath;
|
|
865
|
+
if (cli.src && cli.resource) {
|
|
866
|
+
const srcResolved = await resolveSourcePath(cleanPath(cli.src));
|
|
867
|
+
if ("error" in srcResolved) throw new Error(`--src: ${srcResolved.error}`);
|
|
868
|
+
source = srcResolved;
|
|
869
|
+
const resResolved = await resolveResourcePath(cleanPath(cli.resource));
|
|
870
|
+
if ("error" in resResolved) throw new Error(`--resource: ${resResolved.error}`);
|
|
871
|
+
resourcePath = resResolved.absPath;
|
|
872
|
+
} else {
|
|
873
|
+
const rl = createInterface({ input, output });
|
|
874
|
+
try {
|
|
875
|
+
if (cli.src) {
|
|
876
|
+
const srcResolved = await resolveSourcePath(cleanPath(cli.src));
|
|
877
|
+
if ("error" in srcResolved) throw new Error(`--src: ${srcResolved.error}`);
|
|
878
|
+
source = srcResolved;
|
|
879
|
+
} else {
|
|
880
|
+
source = await promptForSource(rl);
|
|
881
|
+
}
|
|
882
|
+
if (cli.resource) {
|
|
883
|
+
const resResolved = await resolveResourcePath(cleanPath(cli.resource));
|
|
884
|
+
if ("error" in resResolved) throw new Error(`--resource: ${resResolved.error}`);
|
|
885
|
+
resourcePath = resResolved.absPath;
|
|
886
|
+
} else {
|
|
887
|
+
resourcePath = await promptForResource(rl);
|
|
888
|
+
}
|
|
889
|
+
} finally {
|
|
890
|
+
rl.close();
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
console.log("");
|
|
894
|
+
logTee.dual(
|
|
895
|
+
`${c.head("[translation-diff]")} source=${c.path(source.absPath)} (${source.kind})`,
|
|
896
|
+
`[translation-diff] source=${source.absPath} (${source.kind})`
|
|
897
|
+
);
|
|
898
|
+
logTee.dual(
|
|
899
|
+
`${c.head("[translation-diff]")} resource=${c.path(resourcePath)}`,
|
|
900
|
+
`[translation-diff] resource=${resourcePath}`
|
|
901
|
+
);
|
|
902
|
+
let extractorRaw;
|
|
903
|
+
let extractorConflicts = 0;
|
|
904
|
+
let extractorEmptyKeys = [];
|
|
905
|
+
if (source.kind === "json") {
|
|
906
|
+
console.log(`${c.label("Loading extractor JSON:")} ${c.path(source.absPath)}`);
|
|
907
|
+
const loaded = await loadKeyMap(source.absPath, "extractor JSON");
|
|
908
|
+
extractorRaw = loaded.raw;
|
|
909
|
+
extractorEmptyKeys = loaded.emptyValueKeys;
|
|
910
|
+
if (loaded.wasLangWrapped && loaded.pickedLang) {
|
|
911
|
+
console.log(`${c.label(" picked lang:")} ${c.value(loaded.pickedLang)}`);
|
|
912
|
+
}
|
|
913
|
+
} else {
|
|
914
|
+
console.log(`${c.label("Running extractor on:")} ${c.path(source.absPath)}`);
|
|
915
|
+
const result = await runExtraction({ src: source.absPath });
|
|
916
|
+
extractorRaw = {};
|
|
917
|
+
for (const [k, v] of result.entries) extractorRaw[k] = v;
|
|
918
|
+
extractorConflicts = result.conflicts.length;
|
|
919
|
+
console.log(
|
|
920
|
+
` ${c.label("scanned")} ${c.value(result.scannedFiles)} ${c.label("files,")} ${c.label("parsed")} ${c.value(result.parsedFiles)}${c.label(",")} ${c.value(result.keys)} ${c.label("keys (")}${result.conflicts.length > 0 ? c.warn(result.conflicts.length) : c.ok(result.conflicts.length)} ${c.label("conflicts)")}`
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
console.log("");
|
|
924
|
+
console.log(`${c.label("Loading resource:")} ${c.path(resourcePath)}`);
|
|
925
|
+
const resource = await loadKeyMap(resourcePath, "resource file");
|
|
926
|
+
if (resource.wasLangWrapped) {
|
|
927
|
+
console.log(
|
|
928
|
+
` ${c.label("picked lang:")} ${c.value(resource.pickedLang ?? "?")} ${c.label("(available:")} ${resource.availableLangs.map((l) => l === resource.pickedLang ? c.value(l) : c.label(l)).join(c.label(", "))}${c.label(")")}`
|
|
929
|
+
);
|
|
930
|
+
} else {
|
|
931
|
+
console.log(` ${c.label("flat (no language wrapper)")}`);
|
|
932
|
+
}
|
|
933
|
+
console.log(` ${c.label("keys in resource[")}${c.value(TARGET_LANG)}${c.label("]:")} ${c.value(Object.keys(resource.raw).length)}`);
|
|
934
|
+
console.log("");
|
|
935
|
+
const extractorKeys = Object.keys(extractorRaw);
|
|
936
|
+
const dynamicKeys = extractorKeys.filter((k) => extractorRaw[k] === DYNAMIC);
|
|
937
|
+
const extractorPresent = /* @__PURE__ */ new Set();
|
|
938
|
+
for (const k of extractorKeys) {
|
|
939
|
+
const v = extractorRaw[k];
|
|
940
|
+
if (v === DYNAMIC) continue;
|
|
941
|
+
if (v === "") continue;
|
|
942
|
+
extractorPresent.add(k);
|
|
943
|
+
}
|
|
944
|
+
const resourcePresent = /* @__PURE__ */ new Set();
|
|
945
|
+
for (const [k, v] of Object.entries(resource.raw)) {
|
|
946
|
+
if (v === "") continue;
|
|
947
|
+
resourcePresent.add(k);
|
|
948
|
+
}
|
|
949
|
+
const missingInResource = {};
|
|
950
|
+
for (const k of extractorPresent) {
|
|
951
|
+
if (!resourcePresent.has(k)) missingInResource[k] = extractorRaw[k];
|
|
952
|
+
}
|
|
953
|
+
const missingInExtractor = {};
|
|
954
|
+
for (const k of resourcePresent) {
|
|
955
|
+
if (!extractorPresent.has(k)) missingInExtractor[k] = resource.raw[k];
|
|
956
|
+
}
|
|
957
|
+
if (resource.emptyValueKeys.length > 0) {
|
|
958
|
+
console.log(
|
|
959
|
+
`${c.warn("Empty-value keys in resource")} ${c.label("(treated as missing):")} ${c.value(resource.emptyValueKeys.length)}`
|
|
960
|
+
);
|
|
961
|
+
for (const k of resource.emptyValueKeys.sort()) {
|
|
962
|
+
console.log(` ${c.label("-")} ${k}`);
|
|
963
|
+
}
|
|
964
|
+
console.log("");
|
|
965
|
+
}
|
|
966
|
+
if (extractorEmptyKeys.length > 0) {
|
|
967
|
+
console.log(
|
|
968
|
+
`${c.warn("Empty-value keys in extractor JSON")} ${c.label("(treated as missing):")} ${c.value(extractorEmptyKeys.length)}`
|
|
969
|
+
);
|
|
970
|
+
for (const k of extractorEmptyKeys.sort()) {
|
|
971
|
+
console.log(` ${c.label("-")} ${k}`);
|
|
972
|
+
}
|
|
973
|
+
console.log("");
|
|
974
|
+
}
|
|
975
|
+
if (dynamicKeys.length > 0) {
|
|
976
|
+
console.log(
|
|
977
|
+
`${c.label("Skipped from comparison \u2014")} ${c.warn("__dynamic__")} ${c.label("keys in extractor:")} ${c.value(dynamicKeys.length)}`
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
const sortedObject = (m) => {
|
|
981
|
+
const out = {};
|
|
982
|
+
for (const k of Object.keys(m).sort()) out[k] = m[k];
|
|
983
|
+
return out;
|
|
984
|
+
};
|
|
985
|
+
const wrap2 = (m) => ({ [TARGET_LANG]: sortedObject(m) });
|
|
986
|
+
await fs2.writeFile(missingInResourcePath, JSON.stringify(wrap2(missingInResource), null, 2) + "\n", "utf8");
|
|
987
|
+
await fs2.writeFile(missingInExtractorPath, JSON.stringify(wrap2(missingInExtractor), null, 2) + "\n", "utf8");
|
|
988
|
+
const labels = ["Extractor keys", "Resource keys", "Dynamic skipped", "Missing in resource", "Missing in extractor", "Output dir"];
|
|
989
|
+
const pad = Math.max(...labels.map((l) => l.length));
|
|
990
|
+
const stat = (label, value) => `${c.label(label.padEnd(pad) + " :")} ${value}`;
|
|
991
|
+
console.log("");
|
|
992
|
+
console.log(stat("Extractor keys", c.value(extractorKeys.length)));
|
|
993
|
+
console.log(stat("Resource keys", c.value(Object.keys(resource.raw).length)));
|
|
994
|
+
if (extractorConflicts > 0) {
|
|
995
|
+
console.log(stat("Extractor conflicts", c.warn(extractorConflicts)));
|
|
996
|
+
}
|
|
997
|
+
console.log(stat("Dynamic skipped", c.value(dynamicKeys.length)));
|
|
998
|
+
console.log(
|
|
999
|
+
stat(
|
|
1000
|
+
"Missing in resource",
|
|
1001
|
+
`${Object.keys(missingInResource).length > 0 ? c.warn(Object.keys(missingInResource).length) : c.ok(0)} ${c.label("(in extractor, not in resource)")}`
|
|
1002
|
+
)
|
|
1003
|
+
);
|
|
1004
|
+
console.log(
|
|
1005
|
+
stat(
|
|
1006
|
+
"Missing in extractor",
|
|
1007
|
+
`${Object.keys(missingInExtractor).length > 0 ? c.warn(Object.keys(missingInExtractor).length) : c.ok(0)} ${c.label("(in resource, not in extractor)")}`
|
|
1008
|
+
)
|
|
1009
|
+
);
|
|
1010
|
+
console.log(stat("Output dir", c.path(path2.relative(process.cwd(), runDir))));
|
|
1011
|
+
console.log(` ${c.label("\u251C\u2500")} ${c.path("missing_in_resource.json")}`);
|
|
1012
|
+
console.log(` ${c.label("\u251C\u2500")} ${c.path("missing_in_extractor.json")}`);
|
|
1013
|
+
console.log(` ${c.label("\u2514\u2500")} ${c.path(path2.basename(logAbs))}`);
|
|
1014
|
+
await logTee.teardown();
|
|
1015
|
+
}
|
|
1016
|
+
async function runCommand2(_argv) {
|
|
1017
|
+
try {
|
|
1018
|
+
await main2();
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
console.error(`${ansi.red}${ansi.bold}[fatal]${ansi.reset}`, err);
|
|
1021
|
+
process.exit(1);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// src/translate.ts
|
|
1026
|
+
import fs3 from "fs/promises";
|
|
1027
|
+
import { createWriteStream as createWriteStream3 } from "fs";
|
|
1028
|
+
import path3 from "path";
|
|
1029
|
+
import { fileURLToPath } from "url";
|
|
1030
|
+
import { checkbox, select, input as inquirerInput, Separator } from "@inquirer/prompts";
|
|
1031
|
+
|
|
1032
|
+
// src/lib/ui.ts
|
|
1033
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
1034
|
+
const handle = stream._handle;
|
|
1035
|
+
if (handle && typeof handle.setBlocking === "function") {
|
|
1036
|
+
handle.setBlocking(true);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
var NO_COLOR = Boolean(process.env.NO_COLOR) || !process.stdout.isTTY;
|
|
1040
|
+
var wrap = (code) => (s) => NO_COLOR ? String(s) : `\x1B[${code}m${s}\x1B[0m`;
|
|
1041
|
+
var C = {
|
|
1042
|
+
bold: wrap("1"),
|
|
1043
|
+
dim: wrap("2"),
|
|
1044
|
+
red: wrap("31"),
|
|
1045
|
+
green: wrap("32"),
|
|
1046
|
+
yellow: wrap("33"),
|
|
1047
|
+
blue: wrap("34"),
|
|
1048
|
+
magenta: wrap("35"),
|
|
1049
|
+
cyan: wrap("36"),
|
|
1050
|
+
gray: wrap("90"),
|
|
1051
|
+
cyanBold: wrap("1;36"),
|
|
1052
|
+
greenBold: wrap("1;32"),
|
|
1053
|
+
yellowBold: wrap("1;33"),
|
|
1054
|
+
redBold: wrap("1;31")
|
|
1055
|
+
};
|
|
1056
|
+
var CLR_LINE = "\r\x1B[2K";
|
|
1057
|
+
function rule(width = 56) {
|
|
1058
|
+
return C.gray("\u2500".repeat(width));
|
|
1059
|
+
}
|
|
1060
|
+
function fmtMs(ms) {
|
|
1061
|
+
if (ms === void 0) return C.dim("\u2014");
|
|
1062
|
+
if (ms < 1e3) return `${ms.toFixed(0)}ms`;
|
|
1063
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
1064
|
+
const m = Math.floor(ms / 6e4);
|
|
1065
|
+
const s = Math.floor(ms % 6e4 / 1e3);
|
|
1066
|
+
return `${m}m${s.toString().padStart(2, "0")}s`;
|
|
1067
|
+
}
|
|
1068
|
+
var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
1069
|
+
var Spinner = class {
|
|
1070
|
+
timer = null;
|
|
1071
|
+
frameI = 0;
|
|
1072
|
+
t0 = 0;
|
|
1073
|
+
text = "";
|
|
1074
|
+
running = false;
|
|
1075
|
+
start(text) {
|
|
1076
|
+
this.stop();
|
|
1077
|
+
this.text = text;
|
|
1078
|
+
this.t0 = Date.now();
|
|
1079
|
+
this.frameI = 0;
|
|
1080
|
+
this.running = true;
|
|
1081
|
+
this.render();
|
|
1082
|
+
this.timer = setInterval(() => this.render(), 80);
|
|
1083
|
+
}
|
|
1084
|
+
update(text) {
|
|
1085
|
+
this.text = text;
|
|
1086
|
+
if (this.running) this.render();
|
|
1087
|
+
}
|
|
1088
|
+
render() {
|
|
1089
|
+
const elapsed = ((Date.now() - this.t0) / 1e3).toFixed(1);
|
|
1090
|
+
const frame = SPINNER_FRAMES[this.frameI] ?? "\xB7";
|
|
1091
|
+
this.frameI = (this.frameI + 1) % SPINNER_FRAMES.length;
|
|
1092
|
+
process.stdout.write(`${CLR_LINE}${C.cyan(frame)} ${this.text} ${C.dim(`(${elapsed}s)`)}`);
|
|
1093
|
+
}
|
|
1094
|
+
succeed(text) {
|
|
1095
|
+
const elapsedMs = Date.now() - this.t0;
|
|
1096
|
+
const elapsed = (elapsedMs / 1e3).toFixed(1);
|
|
1097
|
+
this.stop();
|
|
1098
|
+
process.stdout.write(`${CLR_LINE}${C.green("\u2713")} ${text} ${C.dim(`(${elapsed}s)`)}
|
|
1099
|
+
`);
|
|
1100
|
+
return elapsedMs;
|
|
1101
|
+
}
|
|
1102
|
+
fail(text) {
|
|
1103
|
+
const elapsed = ((Date.now() - this.t0) / 1e3).toFixed(1);
|
|
1104
|
+
this.stop();
|
|
1105
|
+
process.stdout.write(`${CLR_LINE}${C.red("\u2717")} ${text} ${C.dim(`(${elapsed}s)`)}
|
|
1106
|
+
`);
|
|
1107
|
+
}
|
|
1108
|
+
stop() {
|
|
1109
|
+
if (this.timer) {
|
|
1110
|
+
clearInterval(this.timer);
|
|
1111
|
+
this.timer = null;
|
|
1112
|
+
}
|
|
1113
|
+
if (this.running) {
|
|
1114
|
+
process.stdout.write(CLR_LINE);
|
|
1115
|
+
this.running = false;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
var StatusFooter = class {
|
|
1120
|
+
timer = null;
|
|
1121
|
+
text = "";
|
|
1122
|
+
visible = false;
|
|
1123
|
+
getText = null;
|
|
1124
|
+
write(text, transform) {
|
|
1125
|
+
if (this.visible) {
|
|
1126
|
+
process.stdout.write(CLR_LINE);
|
|
1127
|
+
this.visible = false;
|
|
1128
|
+
}
|
|
1129
|
+
process.stdout.write(transform ? transform(text) : text);
|
|
1130
|
+
if (text.endsWith("\n")) this.draw();
|
|
1131
|
+
}
|
|
1132
|
+
setProvider(fn) {
|
|
1133
|
+
this.getText = fn;
|
|
1134
|
+
}
|
|
1135
|
+
startTicker(intervalMs) {
|
|
1136
|
+
this.stopTicker();
|
|
1137
|
+
this.timer = setInterval(() => this.tick(), intervalMs);
|
|
1138
|
+
}
|
|
1139
|
+
stopTicker() {
|
|
1140
|
+
if (this.timer) {
|
|
1141
|
+
clearInterval(this.timer);
|
|
1142
|
+
this.timer = null;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
tick() {
|
|
1146
|
+
if (!this.getText) return;
|
|
1147
|
+
const next = this.getText();
|
|
1148
|
+
if (next === this.text && this.visible) return;
|
|
1149
|
+
this.text = next;
|
|
1150
|
+
if (this.visible) process.stdout.write(`${CLR_LINE}${next}`);
|
|
1151
|
+
}
|
|
1152
|
+
draw() {
|
|
1153
|
+
if (!this.getText) return;
|
|
1154
|
+
this.text = this.getText();
|
|
1155
|
+
process.stdout.write(this.text);
|
|
1156
|
+
this.visible = true;
|
|
1157
|
+
}
|
|
1158
|
+
clear() {
|
|
1159
|
+
if (this.visible) {
|
|
1160
|
+
process.stdout.write(CLR_LINE);
|
|
1161
|
+
this.visible = false;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
dispose() {
|
|
1165
|
+
this.stopTicker();
|
|
1166
|
+
this.clear();
|
|
1167
|
+
this.getText = null;
|
|
1168
|
+
this.text = "";
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
var JsonPretty = class {
|
|
1172
|
+
started = false;
|
|
1173
|
+
depth = 0;
|
|
1174
|
+
inString = false;
|
|
1175
|
+
escape = false;
|
|
1176
|
+
indent = " ";
|
|
1177
|
+
transform(text) {
|
|
1178
|
+
let out = "";
|
|
1179
|
+
for (let i = 0; i < text.length; i++) {
|
|
1180
|
+
const ch = text[i];
|
|
1181
|
+
if (!this.started) {
|
|
1182
|
+
if (ch === "{" || ch === "[") {
|
|
1183
|
+
this.started = true;
|
|
1184
|
+
this.depth = 1;
|
|
1185
|
+
out += ch + "\n" + this.indent.repeat(this.depth);
|
|
1186
|
+
}
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
if (this.inString) {
|
|
1190
|
+
out += ch;
|
|
1191
|
+
if (this.escape) {
|
|
1192
|
+
this.escape = false;
|
|
1193
|
+
} else if (ch === "\\") {
|
|
1194
|
+
this.escape = true;
|
|
1195
|
+
} else if (ch === '"') {
|
|
1196
|
+
this.inString = false;
|
|
1197
|
+
}
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
switch (ch) {
|
|
1201
|
+
case '"':
|
|
1202
|
+
this.inString = true;
|
|
1203
|
+
out += ch;
|
|
1204
|
+
break;
|
|
1205
|
+
case "{":
|
|
1206
|
+
case "[":
|
|
1207
|
+
this.depth++;
|
|
1208
|
+
out += ch + "\n" + this.indent.repeat(this.depth);
|
|
1209
|
+
break;
|
|
1210
|
+
case "}":
|
|
1211
|
+
case "]":
|
|
1212
|
+
this.depth = Math.max(0, this.depth - 1);
|
|
1213
|
+
out += "\n" + this.indent.repeat(this.depth) + ch;
|
|
1214
|
+
break;
|
|
1215
|
+
case ",":
|
|
1216
|
+
out += ",\n" + this.indent.repeat(this.depth);
|
|
1217
|
+
break;
|
|
1218
|
+
case ":":
|
|
1219
|
+
out += ": ";
|
|
1220
|
+
break;
|
|
1221
|
+
case " ":
|
|
1222
|
+
case " ":
|
|
1223
|
+
case "\n":
|
|
1224
|
+
case "\r":
|
|
1225
|
+
break;
|
|
1226
|
+
default:
|
|
1227
|
+
out += ch;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return out;
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
async function multilineInput(opts = {}) {
|
|
1234
|
+
const stdin = process.stdin;
|
|
1235
|
+
const stdout = process.stdout;
|
|
1236
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
1237
|
+
throw new Error("multilineInput requires an interactive TTY.");
|
|
1238
|
+
}
|
|
1239
|
+
const W = opts.width ?? 64;
|
|
1240
|
+
const horiz = "\u2500".repeat(W - 2);
|
|
1241
|
+
const top = `\u256D${horiz}\u256E`;
|
|
1242
|
+
const bot = `\u2570${horiz}\u256F`;
|
|
1243
|
+
const title = opts.title ?? "Type or paste content";
|
|
1244
|
+
const hints = opts.hints ?? [
|
|
1245
|
+
"Press Enter to submit \xB7 Shift+Enter for a new line",
|
|
1246
|
+
"Right-click or Ctrl+Shift+V to paste (newlines preserved)",
|
|
1247
|
+
"Press Ctrl+C to cancel"
|
|
1248
|
+
];
|
|
1249
|
+
stdout.write(`
|
|
1250
|
+
${C.cyan(top)}
|
|
1251
|
+
`);
|
|
1252
|
+
stdout.write(` ${C.cyan("\u2502")} ${C.bold(title)}
|
|
1253
|
+
`);
|
|
1254
|
+
for (const h of hints) {
|
|
1255
|
+
stdout.write(` ${C.cyan("\u2502")} ${C.dim("\u2022 " + h)}
|
|
1256
|
+
`);
|
|
1257
|
+
}
|
|
1258
|
+
stdout.write(` ${C.cyan(bot)}
|
|
1259
|
+
|
|
1260
|
+
`);
|
|
1261
|
+
const INDENT = " ";
|
|
1262
|
+
const PREFIX_DISPLAY = C.cyan("\u258F") + " ";
|
|
1263
|
+
const PREFIX_LEN = 2;
|
|
1264
|
+
return new Promise((resolve, reject) => {
|
|
1265
|
+
const wasRaw = stdin.isRaw === true;
|
|
1266
|
+
stdin.setRawMode(true);
|
|
1267
|
+
stdin.resume();
|
|
1268
|
+
stdin.setEncoding("utf8");
|
|
1269
|
+
stdout.write("\x1B[>4;2m");
|
|
1270
|
+
stdout.write("\x1B[?2004h");
|
|
1271
|
+
let buffer = "";
|
|
1272
|
+
let cursorPos = 0;
|
|
1273
|
+
let pasting = false;
|
|
1274
|
+
let cursorRow = 0;
|
|
1275
|
+
let closed = false;
|
|
1276
|
+
const cols = () => Math.max(20, stdout.columns ?? 80);
|
|
1277
|
+
const dispRows = (visibleChars) => {
|
|
1278
|
+
if (visibleChars === 0) return 1;
|
|
1279
|
+
return Math.max(1, Math.ceil(visibleChars / cols()));
|
|
1280
|
+
};
|
|
1281
|
+
const cleanup = () => {
|
|
1282
|
+
if (closed) return;
|
|
1283
|
+
closed = true;
|
|
1284
|
+
stdout.write("\x1B[?2004l");
|
|
1285
|
+
stdout.write("\x1B[>4;0m");
|
|
1286
|
+
try {
|
|
1287
|
+
stdin.setRawMode(wasRaw);
|
|
1288
|
+
} catch {
|
|
1289
|
+
}
|
|
1290
|
+
stdin.removeListener("data", onData);
|
|
1291
|
+
stdin.pause();
|
|
1292
|
+
};
|
|
1293
|
+
const render = (final) => {
|
|
1294
|
+
if (cursorRow > 0) stdout.write(`\x1B[${cursorRow}A`);
|
|
1295
|
+
stdout.write("\r\x1B[0J");
|
|
1296
|
+
const lines = buffer.split("\n");
|
|
1297
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1298
|
+
stdout.write(`${INDENT}${PREFIX_DISPLAY}${lines[i]}`);
|
|
1299
|
+
if (i < lines.length - 1) stdout.write("\n");
|
|
1300
|
+
}
|
|
1301
|
+
if (final) {
|
|
1302
|
+
stdout.write("\n");
|
|
1303
|
+
cursorRow = 0;
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
stdout.write("\n");
|
|
1307
|
+
const nLines = lines.length;
|
|
1308
|
+
const nChars = buffer.length;
|
|
1309
|
+
const statsRaw = `${nLines} line${nLines === 1 ? "" : "s"} \xB7 ${nChars} char${nChars === 1 ? "" : "s"} \xB7 Enter=submit \xB7 Shift+Enter=newline \xB7 Ctrl+C=cancel`;
|
|
1310
|
+
const maxStats = Math.max(20, cols() - INDENT.length - 1);
|
|
1311
|
+
const statsVisible = statsRaw.length > maxStats ? statsRaw.slice(0, maxStats - 1) + "\u2026" : statsRaw;
|
|
1312
|
+
stdout.write(`${INDENT}${C.dim(statsVisible)}`);
|
|
1313
|
+
let totalRows = 0;
|
|
1314
|
+
for (const ln of lines) totalRows += dispRows(INDENT.length + PREFIX_LEN + ln.length);
|
|
1315
|
+
const statsRows = dispRows(INDENT.length + statsVisible.length);
|
|
1316
|
+
totalRows += statsRows;
|
|
1317
|
+
let pos = 0;
|
|
1318
|
+
let targetLine = 0;
|
|
1319
|
+
let targetCol = 0;
|
|
1320
|
+
let found = false;
|
|
1321
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1322
|
+
const len = lines[i].length;
|
|
1323
|
+
if (cursorPos <= pos + len) {
|
|
1324
|
+
targetLine = i;
|
|
1325
|
+
targetCol = cursorPos - pos;
|
|
1326
|
+
found = true;
|
|
1327
|
+
break;
|
|
1328
|
+
}
|
|
1329
|
+
pos += len + 1;
|
|
1330
|
+
}
|
|
1331
|
+
if (!found) {
|
|
1332
|
+
targetLine = lines.length - 1;
|
|
1333
|
+
targetCol = lines[targetLine].length;
|
|
1334
|
+
}
|
|
1335
|
+
let rowsAbove = 0;
|
|
1336
|
+
for (let i = 0; i < targetLine; i++) {
|
|
1337
|
+
rowsAbove += dispRows(INDENT.length + PREFIX_LEN + lines[i].length);
|
|
1338
|
+
}
|
|
1339
|
+
const absInLine = INDENT.length + PREFIX_LEN + targetCol;
|
|
1340
|
+
const c2 = cols();
|
|
1341
|
+
const wrapRow = Math.floor(absInLine / c2);
|
|
1342
|
+
const wrapCol = absInLine % c2;
|
|
1343
|
+
const targetRowFromTop = rowsAbove + wrapRow;
|
|
1344
|
+
const currentRowFromTop = totalRows - 1;
|
|
1345
|
+
const up = currentRowFromTop - targetRowFromTop;
|
|
1346
|
+
if (up > 0) stdout.write(`\x1B[${up}A`);
|
|
1347
|
+
else if (up < 0) stdout.write(`\x1B[${-up}B`);
|
|
1348
|
+
stdout.write(`\r\x1B[${wrapCol + 1}G`);
|
|
1349
|
+
cursorRow = targetRowFromTop;
|
|
1350
|
+
};
|
|
1351
|
+
const submit = () => {
|
|
1352
|
+
render(true);
|
|
1353
|
+
cleanup();
|
|
1354
|
+
resolve(buffer);
|
|
1355
|
+
};
|
|
1356
|
+
const cancel = () => {
|
|
1357
|
+
render(true);
|
|
1358
|
+
cleanup();
|
|
1359
|
+
reject(new Error("Input cancelled."));
|
|
1360
|
+
};
|
|
1361
|
+
const insertText = (s) => {
|
|
1362
|
+
if (!s) return;
|
|
1363
|
+
buffer = buffer.slice(0, cursorPos) + s + buffer.slice(cursorPos);
|
|
1364
|
+
cursorPos += s.length;
|
|
1365
|
+
};
|
|
1366
|
+
const normalizePasted = (s) => s.replace(/\r\n|\r/g, "\n");
|
|
1367
|
+
const onData = (data) => {
|
|
1368
|
+
let s = data;
|
|
1369
|
+
while (s.length > 0) {
|
|
1370
|
+
if (pasting) {
|
|
1371
|
+
const end = s.indexOf("\x1B[201~");
|
|
1372
|
+
if (end === -1) {
|
|
1373
|
+
insertText(normalizePasted(s));
|
|
1374
|
+
s = "";
|
|
1375
|
+
} else {
|
|
1376
|
+
insertText(normalizePasted(s.slice(0, end)));
|
|
1377
|
+
pasting = false;
|
|
1378
|
+
s = s.slice(end + 6);
|
|
1379
|
+
}
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
if (s.startsWith("\x1B[200~")) {
|
|
1383
|
+
pasting = true;
|
|
1384
|
+
s = s.slice(6);
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
if (s.startsWith("\x1B[27;2;13~")) {
|
|
1388
|
+
insertText("\n");
|
|
1389
|
+
s = s.slice(10);
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
if (s.startsWith("\x1B[13;2u")) {
|
|
1393
|
+
insertText("\n");
|
|
1394
|
+
s = s.slice(7);
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
const ch = s[0];
|
|
1398
|
+
if (ch === "\r" || ch === "\n") {
|
|
1399
|
+
submit();
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (ch === "") {
|
|
1403
|
+
cancel();
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
if (ch === "") {
|
|
1407
|
+
submit();
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (ch === "\x7F" || ch === "\b") {
|
|
1411
|
+
if (cursorPos > 0) {
|
|
1412
|
+
buffer = buffer.slice(0, cursorPos - 1) + buffer.slice(cursorPos);
|
|
1413
|
+
cursorPos--;
|
|
1414
|
+
}
|
|
1415
|
+
s = s.slice(1);
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
if (ch === "") {
|
|
1419
|
+
const lineStart = buffer.lastIndexOf("\n", cursorPos - 1) + 1;
|
|
1420
|
+
buffer = buffer.slice(0, lineStart) + buffer.slice(cursorPos);
|
|
1421
|
+
cursorPos = lineStart;
|
|
1422
|
+
s = s.slice(1);
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
if (ch === "\v") {
|
|
1426
|
+
const lineEnd = buffer.indexOf("\n", cursorPos);
|
|
1427
|
+
const e = lineEnd === -1 ? buffer.length : lineEnd;
|
|
1428
|
+
buffer = buffer.slice(0, cursorPos) + buffer.slice(e);
|
|
1429
|
+
s = s.slice(1);
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
if (s.startsWith("\x1B[D")) {
|
|
1433
|
+
if (cursorPos > 0) cursorPos--;
|
|
1434
|
+
s = s.slice(3);
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
if (s.startsWith("\x1B[C")) {
|
|
1438
|
+
if (cursorPos < buffer.length) cursorPos++;
|
|
1439
|
+
s = s.slice(3);
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
if (s.startsWith("\x1B[A")) {
|
|
1443
|
+
const curStart = buffer.lastIndexOf("\n", cursorPos - 1) + 1;
|
|
1444
|
+
if (curStart === 0) {
|
|
1445
|
+
cursorPos = 0;
|
|
1446
|
+
} else {
|
|
1447
|
+
const col = cursorPos - curStart;
|
|
1448
|
+
const prevNl = curStart - 1;
|
|
1449
|
+
const prevStart = buffer.lastIndexOf("\n", prevNl - 1) + 1;
|
|
1450
|
+
const prevLen = prevNl - prevStart;
|
|
1451
|
+
cursorPos = prevStart + Math.min(col, prevLen);
|
|
1452
|
+
}
|
|
1453
|
+
s = s.slice(3);
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
if (s.startsWith("\x1B[B")) {
|
|
1457
|
+
const curStart = buffer.lastIndexOf("\n", cursorPos - 1) + 1;
|
|
1458
|
+
const curEnd = buffer.indexOf("\n", cursorPos);
|
|
1459
|
+
if (curEnd === -1) {
|
|
1460
|
+
cursorPos = buffer.length;
|
|
1461
|
+
} else {
|
|
1462
|
+
const col = cursorPos - curStart;
|
|
1463
|
+
const nextStart = curEnd + 1;
|
|
1464
|
+
const nextEnd = buffer.indexOf("\n", nextStart);
|
|
1465
|
+
const nextLen = (nextEnd === -1 ? buffer.length : nextEnd) - nextStart;
|
|
1466
|
+
cursorPos = nextStart + Math.min(col, nextLen);
|
|
1467
|
+
}
|
|
1468
|
+
s = s.slice(3);
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
if (s.startsWith("\x1B[H") || s.startsWith("\x1B[1~") || s.startsWith("\x1B[7~")) {
|
|
1472
|
+
cursorPos = buffer.lastIndexOf("\n", cursorPos - 1) + 1;
|
|
1473
|
+
s = s.slice(s.startsWith("\x1B[H") ? 3 : 4);
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
if (s.startsWith("\x1B[F") || s.startsWith("\x1B[4~") || s.startsWith("\x1B[8~")) {
|
|
1477
|
+
const eol = buffer.indexOf("\n", cursorPos);
|
|
1478
|
+
cursorPos = eol === -1 ? buffer.length : eol;
|
|
1479
|
+
s = s.slice(s.startsWith("\x1B[F") ? 3 : 4);
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
if (ch === "\x1B") {
|
|
1483
|
+
let i = 1;
|
|
1484
|
+
if (s[1] === "[") {
|
|
1485
|
+
i = 2;
|
|
1486
|
+
while (i < s.length && /[\x30-\x3f]/.test(s[i])) i++;
|
|
1487
|
+
while (i < s.length && /[\x20-\x2f]/.test(s[i])) i++;
|
|
1488
|
+
if (i < s.length && /[\x40-\x7e]/.test(s[i])) i++;
|
|
1489
|
+
} else if (s[1] === "O" && s.length >= 3) {
|
|
1490
|
+
i = 3;
|
|
1491
|
+
} else {
|
|
1492
|
+
i = 2;
|
|
1493
|
+
}
|
|
1494
|
+
s = s.slice(i);
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
let n = 0;
|
|
1498
|
+
while (n < s.length) {
|
|
1499
|
+
const code = s.charCodeAt(n);
|
|
1500
|
+
if (code < 32 && code !== 9) break;
|
|
1501
|
+
if (code === 127) break;
|
|
1502
|
+
if (s[n] === "\x1B") break;
|
|
1503
|
+
n++;
|
|
1504
|
+
}
|
|
1505
|
+
if (n > 0) {
|
|
1506
|
+
insertText(s.slice(0, n));
|
|
1507
|
+
s = s.slice(n);
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
s = s.slice(1);
|
|
1511
|
+
}
|
|
1512
|
+
render(false);
|
|
1513
|
+
};
|
|
1514
|
+
stdin.on("data", onData);
|
|
1515
|
+
render(false);
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
function formatError(e) {
|
|
1519
|
+
if (!(e instanceof Error)) return String(e);
|
|
1520
|
+
const parts = [`${C.red("\u2717 " + e.name)}: ${e.message}`];
|
|
1521
|
+
let cause = e.cause;
|
|
1522
|
+
while (cause) {
|
|
1523
|
+
if (cause instanceof Error) {
|
|
1524
|
+
const code = cause.code;
|
|
1525
|
+
parts.push(C.dim(` cause: ${cause.name}: ${cause.message}${code ? ` (code=${code})` : ""}`));
|
|
1526
|
+
cause = cause.cause;
|
|
1527
|
+
} else {
|
|
1528
|
+
parts.push(C.dim(` cause: ${String(cause)}`));
|
|
1529
|
+
break;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
return parts.join("\n");
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// src/translate.ts
|
|
1536
|
+
var SCRIPT_DIR = path3.dirname(fileURLToPath(import.meta.url));
|
|
1537
|
+
var PROJECT_ROOT = path3.resolve(SCRIPT_DIR, "..");
|
|
1538
|
+
var PROMPT_PATH = path3.resolve(PROJECT_ROOT, "prompt/translation-prompt.md");
|
|
1539
|
+
var LANGUAGES_JSON_PATH = path3.resolve(PROJECT_ROOT, "data/languages.json");
|
|
1540
|
+
var DIFF_OUTPUT_DIR2 = path3.resolve(PROJECT_ROOT, "diff-output");
|
|
1541
|
+
var TRANSLATE_OUTPUT_DIR = path3.resolve(PROJECT_ROOT, "translate-output");
|
|
1542
|
+
var PLACEHOLDER_CONTENT = "PASTE CONTENT HERE";
|
|
1543
|
+
var PLACEHOLDER_LANGS = "LANGUAGES TO PRODUCE";
|
|
1544
|
+
var SOURCE_LANG = "en";
|
|
1545
|
+
var OLLAMA_URL = (process.env.OLLAMA_URL ?? "http://172.25.80.1:11434").replace(/\/$/, "");
|
|
1546
|
+
var OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS ?? 30 * 6e4);
|
|
1547
|
+
function parseEnglishKeyMap(text, sourceLabel) {
|
|
1548
|
+
let json;
|
|
1549
|
+
try {
|
|
1550
|
+
json = JSON.parse(text);
|
|
1551
|
+
} catch (err) {
|
|
1552
|
+
throw new Error(`${sourceLabel}: invalid JSON \u2014 ${err.message}`);
|
|
1553
|
+
}
|
|
1554
|
+
if (json === null || typeof json !== "object" || Array.isArray(json)) {
|
|
1555
|
+
throw new Error(`${sourceLabel}: expected a JSON object at the top level`);
|
|
1556
|
+
}
|
|
1557
|
+
const topKeys = Object.keys(json);
|
|
1558
|
+
const obj = json;
|
|
1559
|
+
const wrapped = topKeys.length > 0 && topKeys.every((k) => typeof obj[k] === "object" && obj[k] !== null);
|
|
1560
|
+
let flat;
|
|
1561
|
+
if (wrapped) {
|
|
1562
|
+
if (!(SOURCE_LANG in obj)) {
|
|
1563
|
+
throw new Error(
|
|
1564
|
+
`${sourceLabel}: language-wrapped JSON has no "${SOURCE_LANG}" entry (available: ${topKeys.join(", ")})`
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
flat = obj[SOURCE_LANG];
|
|
1568
|
+
} else {
|
|
1569
|
+
flat = obj;
|
|
1570
|
+
}
|
|
1571
|
+
const result = {};
|
|
1572
|
+
for (const [k, v] of Object.entries(flat)) {
|
|
1573
|
+
if (typeof v !== "string") continue;
|
|
1574
|
+
result[k] = v;
|
|
1575
|
+
}
|
|
1576
|
+
if (Object.keys(result).length === 0) {
|
|
1577
|
+
throw new Error(`${sourceLabel}: no string-valued keys found`);
|
|
1578
|
+
}
|
|
1579
|
+
return result;
|
|
1580
|
+
}
|
|
1581
|
+
function cleanPath2(raw) {
|
|
1582
|
+
let s = raw.trim();
|
|
1583
|
+
if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'") || s.startsWith("`") && s.endsWith("`")) {
|
|
1584
|
+
s = s.slice(1, -1);
|
|
1585
|
+
}
|
|
1586
|
+
return s;
|
|
1587
|
+
}
|
|
1588
|
+
async function findRecentDiffOutputs(max = 5) {
|
|
1589
|
+
let dirEntries = [];
|
|
1590
|
+
try {
|
|
1591
|
+
dirEntries = await fs3.readdir(DIFF_OUTPUT_DIR2);
|
|
1592
|
+
} catch {
|
|
1593
|
+
return [];
|
|
1594
|
+
}
|
|
1595
|
+
const candidates = [];
|
|
1596
|
+
for (const dirName of dirEntries) {
|
|
1597
|
+
const filePath = path3.join(DIFF_OUTPUT_DIR2, dirName, "missing_in_resource.json");
|
|
1598
|
+
try {
|
|
1599
|
+
const st = await fs3.stat(filePath);
|
|
1600
|
+
if (!st.isFile()) continue;
|
|
1601
|
+
const text = await fs3.readFile(filePath, "utf8");
|
|
1602
|
+
const json = JSON.parse(text);
|
|
1603
|
+
const enSlice = json[SOURCE_LANG] ?? json;
|
|
1604
|
+
const keyCount = typeof enSlice === "object" && enSlice !== null ? Object.keys(enSlice).length : 0;
|
|
1605
|
+
candidates.push({ abs: filePath, mtimeMs: st.mtimeMs, keyCount });
|
|
1606
|
+
} catch {
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1610
|
+
return candidates.slice(0, max).map((it) => ({
|
|
1611
|
+
absPath: it.abs,
|
|
1612
|
+
relPath: path3.relative(process.cwd(), it.abs),
|
|
1613
|
+
mtimeMs: it.mtimeMs,
|
|
1614
|
+
keyCount: it.keyCount,
|
|
1615
|
+
ageLabel: humanAge(Date.now() - it.mtimeMs)
|
|
1616
|
+
}));
|
|
1617
|
+
}
|
|
1618
|
+
function humanAge(ms) {
|
|
1619
|
+
const s = Math.floor(ms / 1e3);
|
|
1620
|
+
if (s < 60) return `${s}s ago`;
|
|
1621
|
+
const m = Math.floor(s / 60);
|
|
1622
|
+
if (m < 60) return `${m}m ago`;
|
|
1623
|
+
const h = Math.floor(m / 60);
|
|
1624
|
+
if (h < 24) return `${h}h ago`;
|
|
1625
|
+
const d = Math.floor(h / 24);
|
|
1626
|
+
return `${d}d ago`;
|
|
1627
|
+
}
|
|
1628
|
+
async function loadLanguages() {
|
|
1629
|
+
const text = await fs3.readFile(LANGUAGES_JSON_PATH, "utf8");
|
|
1630
|
+
const arr = JSON.parse(text);
|
|
1631
|
+
return arr.filter((l) => l && l.active && l.code);
|
|
1632
|
+
}
|
|
1633
|
+
async function promptTargetLanguages(allLangs) {
|
|
1634
|
+
const source = allLangs.find((l) => l.code === SOURCE_LANG);
|
|
1635
|
+
const targets = allLangs.filter((l) => l.code !== SOURCE_LANG);
|
|
1636
|
+
const ordered = source ? [source, ...targets] : targets;
|
|
1637
|
+
const idxWidth = String(ordered.length).length;
|
|
1638
|
+
const selected = await checkbox({
|
|
1639
|
+
message: `Select target languages (${targets.length} available, ${SOURCE_LANG} is the source \u2014 space to toggle, enter to submit):`,
|
|
1640
|
+
pageSize: 25,
|
|
1641
|
+
loop: false,
|
|
1642
|
+
required: true,
|
|
1643
|
+
choices: ordered.map((l, i) => {
|
|
1644
|
+
const isSource = l.code === SOURCE_LANG;
|
|
1645
|
+
const native = l.nativeName && l.nativeName !== l.name ? ` (${l.nativeName})` : "";
|
|
1646
|
+
const suffix = isSource ? ` ${C.dim("\u2190 source, always in output")}` : "";
|
|
1647
|
+
return {
|
|
1648
|
+
name: `${String(i + 1).padStart(idxWidth)}. ${l.code.padEnd(8)} ${l.name}${native}${suffix}`,
|
|
1649
|
+
value: l.code,
|
|
1650
|
+
short: l.code,
|
|
1651
|
+
checked: isSource,
|
|
1652
|
+
disabled: isSource ? "(source \u2014 always included)" : false
|
|
1653
|
+
};
|
|
1654
|
+
})
|
|
1655
|
+
});
|
|
1656
|
+
const codeSet = new Set(selected);
|
|
1657
|
+
return targets.filter((l) => codeSet.has(l.code));
|
|
1658
|
+
}
|
|
1659
|
+
async function fetchModels(spinner) {
|
|
1660
|
+
spinner.start(`Fetching available models from ${C.dim(OLLAMA_URL)}`);
|
|
1661
|
+
const controller = new AbortController();
|
|
1662
|
+
const timer = setTimeout(() => controller.abort(), 15e3);
|
|
1663
|
+
try {
|
|
1664
|
+
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: controller.signal });
|
|
1665
|
+
if (!res.ok) {
|
|
1666
|
+
spinner.fail(`Ollama returned HTTP ${res.status}`);
|
|
1667
|
+
throw new Error(`Ollama /api/tags HTTP ${res.status} ${res.statusText}`);
|
|
1668
|
+
}
|
|
1669
|
+
const json = await res.json();
|
|
1670
|
+
const names = (json.models ?? []).map((m) => m.name).filter(Boolean);
|
|
1671
|
+
if (names.length === 0) {
|
|
1672
|
+
spinner.fail("No models available on Ollama");
|
|
1673
|
+
throw new Error(`No models found at ${OLLAMA_URL}. Pull a model first (e.g. \`ollama pull llama3.2\`).`);
|
|
1674
|
+
}
|
|
1675
|
+
spinner.succeed(`Found ${C.bold(String(names.length))} model(s)`);
|
|
1676
|
+
return names;
|
|
1677
|
+
} finally {
|
|
1678
|
+
clearTimeout(timer);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
async function promptModel(models) {
|
|
1682
|
+
return select({
|
|
1683
|
+
message: "Select a model:",
|
|
1684
|
+
pageSize: Math.min(15, models.length),
|
|
1685
|
+
loop: false,
|
|
1686
|
+
choices: models.map((name) => ({ name, value: name }))
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
async function promptInput(recents) {
|
|
1690
|
+
const choices = [];
|
|
1691
|
+
if (recents.length > 0) {
|
|
1692
|
+
choices.push(new Separator(c.label(" Recent missing_in_resource.json files:")));
|
|
1693
|
+
for (const r of recents) {
|
|
1694
|
+
choices.push({
|
|
1695
|
+
name: `${r.relPath} ${C.dim(`(${r.keyCount} keys, ${r.ageLabel})`)}`,
|
|
1696
|
+
value: { kind: "recent", value: r.absPath, file: r },
|
|
1697
|
+
short: r.relPath
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
choices.push(new Separator(c.label(" Other options:")));
|
|
1701
|
+
}
|
|
1702
|
+
choices.push({
|
|
1703
|
+
name: "Paste a JSON file path",
|
|
1704
|
+
value: { kind: "mode", value: "path" },
|
|
1705
|
+
short: "file path"
|
|
1706
|
+
});
|
|
1707
|
+
choices.push({
|
|
1708
|
+
name: "Paste content inline (multi-line)",
|
|
1709
|
+
value: { kind: "mode", value: "paste" },
|
|
1710
|
+
short: "inline paste"
|
|
1711
|
+
});
|
|
1712
|
+
const pick = await select({
|
|
1713
|
+
message: "Input source:",
|
|
1714
|
+
pageSize: Math.min(20, choices.length),
|
|
1715
|
+
loop: false,
|
|
1716
|
+
choices
|
|
1717
|
+
});
|
|
1718
|
+
if (pick.kind === "recent") {
|
|
1719
|
+
const text = await fs3.readFile(pick.file.absPath, "utf8");
|
|
1720
|
+
return {
|
|
1721
|
+
sourceLabel: pick.file.absPath,
|
|
1722
|
+
keyMap: parseEnglishKeyMap(text, pick.file.absPath)
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
if (pick.value === "path") {
|
|
1726
|
+
while (true) {
|
|
1727
|
+
const raw = await inquirerInput({
|
|
1728
|
+
message: "Paste a JSON file path:",
|
|
1729
|
+
validate: (v) => v.trim().length > 0 ? true : "Path required"
|
|
1730
|
+
});
|
|
1731
|
+
const p = path3.resolve(cleanPath2(raw));
|
|
1732
|
+
try {
|
|
1733
|
+
const st = await fs3.stat(p);
|
|
1734
|
+
if (!st.isFile() || !p.toLowerCase().endsWith(".json")) {
|
|
1735
|
+
console.log(c.err(` Not a .json file: ${p}
|
|
1736
|
+
`));
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
const text = await fs3.readFile(p, "utf8");
|
|
1740
|
+
return { sourceLabel: p, keyMap: parseEnglishKeyMap(text, p) };
|
|
1741
|
+
} catch (err) {
|
|
1742
|
+
console.log(c.err(` ${err.message}
|
|
1743
|
+
`));
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
const pasted = await multilineInput({
|
|
1748
|
+
title: "Paste JSON content (extractor-shape or flat)",
|
|
1749
|
+
hints: [
|
|
1750
|
+
"Press Enter to submit \xB7 Shift+Enter for a new line",
|
|
1751
|
+
"Right-click or Ctrl+Shift+V to paste (newlines preserved)",
|
|
1752
|
+
"Press Ctrl+C to cancel"
|
|
1753
|
+
]
|
|
1754
|
+
});
|
|
1755
|
+
return { sourceLabel: "<inline paste>", keyMap: parseEnglishKeyMap(pasted, "inline paste") };
|
|
1756
|
+
}
|
|
1757
|
+
async function callOllama(model, prompt, spinner) {
|
|
1758
|
+
const url = `${OLLAMA_URL}/api/generate`;
|
|
1759
|
+
const controller = new AbortController();
|
|
1760
|
+
const timer = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
|
|
1761
|
+
spinner.start(`Connecting to Ollama ${C.dim(`(${model} @ ${OLLAMA_URL})`)}`);
|
|
1762
|
+
let response = "";
|
|
1763
|
+
let thinking = "";
|
|
1764
|
+
let connectMs = 0;
|
|
1765
|
+
let thinkingMs = 0;
|
|
1766
|
+
let responseMs = 0;
|
|
1767
|
+
let responseTokens = 0;
|
|
1768
|
+
let thinkingTokens = 0;
|
|
1769
|
+
let metrics = {};
|
|
1770
|
+
const footer = new StatusFooter();
|
|
1771
|
+
const pretty = new JsonPretty();
|
|
1772
|
+
try {
|
|
1773
|
+
const res = await fetch(url, {
|
|
1774
|
+
method: "POST",
|
|
1775
|
+
headers: { "content-type": "application/json" },
|
|
1776
|
+
body: JSON.stringify({
|
|
1777
|
+
model,
|
|
1778
|
+
prompt,
|
|
1779
|
+
stream: true,
|
|
1780
|
+
format: "json",
|
|
1781
|
+
think: true,
|
|
1782
|
+
options: { temperature: 0.2 }
|
|
1783
|
+
}),
|
|
1784
|
+
signal: controller.signal
|
|
1785
|
+
});
|
|
1786
|
+
if (!res.ok) {
|
|
1787
|
+
spinner.fail(`Ollama returned HTTP ${res.status}`);
|
|
1788
|
+
throw new Error(`Ollama HTTP ${res.status} ${res.statusText}: ${await res.text()}`);
|
|
1789
|
+
}
|
|
1790
|
+
if (!res.body) throw new Error("Ollama response has no body");
|
|
1791
|
+
const decoder = new TextDecoder("utf-8");
|
|
1792
|
+
let buffer = "";
|
|
1793
|
+
const state = {
|
|
1794
|
+
mode: "idle",
|
|
1795
|
+
modeStartT: 0,
|
|
1796
|
+
connected: false
|
|
1797
|
+
};
|
|
1798
|
+
const renderStatus = () => {
|
|
1799
|
+
const elapsed = (Date.now() - state.modeStartT) / 1e3;
|
|
1800
|
+
const tokens = state.mode === "thinking" ? thinkingTokens : responseTokens;
|
|
1801
|
+
const chars = state.mode === "thinking" ? thinking.length : response.length;
|
|
1802
|
+
const rate = elapsed > 0 ? tokens / elapsed : 0;
|
|
1803
|
+
const kb = (chars / 1024).toFixed(1);
|
|
1804
|
+
return `${C.cyan("\u23F1")} ${C.bold(elapsed.toFixed(1) + "s")} ${C.gray("\u2502")} ${C.bold(String(tokens))} tok ${C.gray("\u2502")} ${C.bold(rate.toFixed(1))} tok/s ${C.gray("\u2502")} ${C.bold(kb + "KB")}`;
|
|
1805
|
+
};
|
|
1806
|
+
const ensureConnected = () => {
|
|
1807
|
+
if (state.connected) return;
|
|
1808
|
+
connectMs = spinner.succeed("Connected");
|
|
1809
|
+
process.stdout.write("\n");
|
|
1810
|
+
state.connected = true;
|
|
1811
|
+
};
|
|
1812
|
+
const enterMode = (next) => {
|
|
1813
|
+
if (state.mode === next) return;
|
|
1814
|
+
ensureConnected();
|
|
1815
|
+
footer.dispose();
|
|
1816
|
+
if (state.mode === "thinking") {
|
|
1817
|
+
thinkingMs = Date.now() - state.modeStartT;
|
|
1818
|
+
process.stdout.write(
|
|
1819
|
+
C.dim(`...done thinking. (${(thinkingMs / 1e3).toFixed(1)}s, ${thinkingTokens} tok)
|
|
1820
|
+
|
|
1821
|
+
`)
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
if (next === "thinking") {
|
|
1825
|
+
process.stdout.write(C.cyanBold("Thinking...\n"));
|
|
1826
|
+
process.stdout.write(C.bold("Thinking Process:\n\n"));
|
|
1827
|
+
} else {
|
|
1828
|
+
process.stdout.write(C.greenBold("Response:\n"));
|
|
1829
|
+
}
|
|
1830
|
+
state.mode = next;
|
|
1831
|
+
state.modeStartT = Date.now();
|
|
1832
|
+
footer.setProvider(renderStatus);
|
|
1833
|
+
footer.startTicker(150);
|
|
1834
|
+
};
|
|
1835
|
+
let done = false;
|
|
1836
|
+
for await (const chunk of res.body) {
|
|
1837
|
+
if (done) break;
|
|
1838
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
1839
|
+
let nl;
|
|
1840
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
1841
|
+
const line = buffer.slice(0, nl).trim();
|
|
1842
|
+
buffer = buffer.slice(nl + 1);
|
|
1843
|
+
if (!line) continue;
|
|
1844
|
+
let evt;
|
|
1845
|
+
try {
|
|
1846
|
+
evt = JSON.parse(line);
|
|
1847
|
+
} catch {
|
|
1848
|
+
continue;
|
|
1849
|
+
}
|
|
1850
|
+
if (evt.error) {
|
|
1851
|
+
footer.dispose();
|
|
1852
|
+
spinner.stop();
|
|
1853
|
+
process.stdout.write("\n");
|
|
1854
|
+
throw new Error(`Ollama error: ${evt.error}`);
|
|
1855
|
+
}
|
|
1856
|
+
if (evt.thinking) {
|
|
1857
|
+
enterMode("thinking");
|
|
1858
|
+
thinking += evt.thinking;
|
|
1859
|
+
thinkingTokens++;
|
|
1860
|
+
footer.write(evt.thinking, C.dim);
|
|
1861
|
+
}
|
|
1862
|
+
if (evt.response) {
|
|
1863
|
+
enterMode("response");
|
|
1864
|
+
response += evt.response;
|
|
1865
|
+
responseTokens++;
|
|
1866
|
+
footer.write(pretty.transform(evt.response));
|
|
1867
|
+
}
|
|
1868
|
+
if (evt.done) {
|
|
1869
|
+
metrics = {
|
|
1870
|
+
promptTokens: evt.prompt_eval_count,
|
|
1871
|
+
evalTokens: evt.eval_count,
|
|
1872
|
+
promptEvalMs: evt.prompt_eval_duration !== void 0 ? evt.prompt_eval_duration / 1e6 : void 0,
|
|
1873
|
+
evalMs: evt.eval_duration !== void 0 ? evt.eval_duration / 1e6 : void 0,
|
|
1874
|
+
loadMs: evt.load_duration !== void 0 ? evt.load_duration / 1e6 : void 0,
|
|
1875
|
+
totalMs: evt.total_duration !== void 0 ? evt.total_duration / 1e6 : void 0
|
|
1876
|
+
};
|
|
1877
|
+
done = true;
|
|
1878
|
+
break;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
footer.dispose();
|
|
1883
|
+
spinner.stop();
|
|
1884
|
+
if (state.mode === "thinking") {
|
|
1885
|
+
thinkingMs = Date.now() - state.modeStartT;
|
|
1886
|
+
process.stdout.write(
|
|
1887
|
+
C.dim(`
|
|
1888
|
+
...done thinking. (${(thinkingMs / 1e3).toFixed(1)}s, ${thinkingTokens} tok)
|
|
1889
|
+
`)
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
if (state.mode === "response") {
|
|
1893
|
+
responseMs = Date.now() - state.modeStartT;
|
|
1894
|
+
process.stdout.write("\n");
|
|
1895
|
+
}
|
|
1896
|
+
return { response, thinking, connectMs, thinkingMs, responseMs, metrics, responseTokens, thinkingTokens };
|
|
1897
|
+
} finally {
|
|
1898
|
+
clearTimeout(timer);
|
|
1899
|
+
footer.dispose();
|
|
1900
|
+
spinner.stop();
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
function extractJSON(text) {
|
|
1904
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
1905
|
+
if (fence && fence[1]) return fence[1].trim();
|
|
1906
|
+
const first = text.indexOf("{");
|
|
1907
|
+
const last = text.lastIndexOf("}");
|
|
1908
|
+
if (first !== -1 && last > first) return text.slice(first, last + 1);
|
|
1909
|
+
return text.trim();
|
|
1910
|
+
}
|
|
1911
|
+
function renderPrompt(template, langs, keyMap) {
|
|
1912
|
+
if (!template.includes(PLACEHOLDER_CONTENT) || !template.includes(PLACEHOLDER_LANGS)) {
|
|
1913
|
+
throw new Error(
|
|
1914
|
+
`Prompt template at ${PROMPT_PATH} must contain both "${PLACEHOLDER_CONTENT}" and "${PLACEHOLDER_LANGS}" placeholders.`
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1917
|
+
const langList = [
|
|
1918
|
+
`- ${SOURCE_LANG} = English (source \u2014 copy values verbatim into the "en" object)`,
|
|
1919
|
+
...langs.map((l) => {
|
|
1920
|
+
const native = l.nativeName && l.nativeName !== l.name ? ` (${l.nativeName})` : "";
|
|
1921
|
+
return `- ${l.code} = ${l.name}${native}`;
|
|
1922
|
+
})
|
|
1923
|
+
].join("\n");
|
|
1924
|
+
const sourceJson = JSON.stringify({ [SOURCE_LANG]: keyMap }, null, 2);
|
|
1925
|
+
return template.replace(PLACEHOLDER_LANGS, langList).replace(PLACEHOLDER_CONTENT, sourceJson);
|
|
1926
|
+
}
|
|
1927
|
+
function validateTranslation(parsed, expectedLangs, expectedKeys) {
|
|
1928
|
+
const report = {
|
|
1929
|
+
allLangsPresent: true,
|
|
1930
|
+
missingLangs: [],
|
|
1931
|
+
perLangMissingKeys: {},
|
|
1932
|
+
perLangExtraKeys: {}
|
|
1933
|
+
};
|
|
1934
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1935
|
+
return { ...report, allLangsPresent: false, missingLangs: expectedLangs };
|
|
1936
|
+
}
|
|
1937
|
+
const obj = parsed;
|
|
1938
|
+
const keySet = new Set(expectedKeys);
|
|
1939
|
+
for (const lang of expectedLangs) {
|
|
1940
|
+
const langObj = obj[lang];
|
|
1941
|
+
if (!langObj || typeof langObj !== "object" || Array.isArray(langObj)) {
|
|
1942
|
+
report.allLangsPresent = false;
|
|
1943
|
+
report.missingLangs.push(lang);
|
|
1944
|
+
continue;
|
|
1945
|
+
}
|
|
1946
|
+
const actualKeys = new Set(Object.keys(langObj));
|
|
1947
|
+
const missing = [];
|
|
1948
|
+
for (const k of expectedKeys) if (!actualKeys.has(k)) missing.push(k);
|
|
1949
|
+
const extra = [];
|
|
1950
|
+
for (const k of actualKeys) if (!keySet.has(k)) extra.push(k);
|
|
1951
|
+
if (missing.length) report.perLangMissingKeys[lang] = missing;
|
|
1952
|
+
if (extra.length) report.perLangExtraKeys[lang] = extra;
|
|
1953
|
+
}
|
|
1954
|
+
return report;
|
|
1955
|
+
}
|
|
1956
|
+
function sortedLangBundle(parsed, expectedLangs, expectedKeys) {
|
|
1957
|
+
const sortedKeys = [...expectedKeys].sort();
|
|
1958
|
+
const out = {};
|
|
1959
|
+
for (const lang of expectedLangs) {
|
|
1960
|
+
const langObj = parsed[lang] ?? {};
|
|
1961
|
+
const sortedObj = {};
|
|
1962
|
+
for (const k of sortedKeys) {
|
|
1963
|
+
sortedObj[k] = typeof langObj[k] === "string" ? langObj[k] : "";
|
|
1964
|
+
}
|
|
1965
|
+
out[lang] = sortedObj;
|
|
1966
|
+
}
|
|
1967
|
+
return out;
|
|
1968
|
+
}
|
|
1969
|
+
function printHeader(args) {
|
|
1970
|
+
process.stdout.write("\n");
|
|
1971
|
+
process.stdout.write(` ${C.cyanBold("Translation Generator")}
|
|
1972
|
+
`);
|
|
1973
|
+
process.stdout.write(` ${rule()}
|
|
1974
|
+
`);
|
|
1975
|
+
process.stdout.write(` ${C.dim("Model :")} ${args.model} ${C.dim("@")} ${OLLAMA_URL}
|
|
1976
|
+
`);
|
|
1977
|
+
process.stdout.write(` ${C.dim("Input :")} ${args.sourceLabel}
|
|
1978
|
+
`);
|
|
1979
|
+
process.stdout.write(` ${C.dim("Keys :")} ${args.keyCount}
|
|
1980
|
+
`);
|
|
1981
|
+
const langCodes = [SOURCE_LANG, ...args.langs.map((l) => l.code)].join(", ");
|
|
1982
|
+
process.stdout.write(
|
|
1983
|
+
` ${C.dim("Languages:")} ${args.langs.length + 1} ${C.dim(`(${langCodes})`)}
|
|
1984
|
+
`
|
|
1985
|
+
);
|
|
1986
|
+
process.stdout.write(` ${C.dim("Output :")} ${args.outputDir}
|
|
1987
|
+
`);
|
|
1988
|
+
process.stdout.write(` ${rule()}
|
|
1989
|
+
|
|
1990
|
+
`);
|
|
1991
|
+
}
|
|
1992
|
+
async function main3() {
|
|
1993
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
1994
|
+
const timestamp = formatTimestamp(startedAt);
|
|
1995
|
+
const runDir = path3.join(TRANSLATE_OUTPUT_DIR, timestamp);
|
|
1996
|
+
await fs3.mkdir(runDir, { recursive: true });
|
|
1997
|
+
const outputPath = path3.join(runDir, "translation.json");
|
|
1998
|
+
const logAbs = path3.join(runDir, `${timestamp}_translate.log`);
|
|
1999
|
+
const stream = createWriteStream3(logAbs, { flags: "a" });
|
|
2000
|
+
const logTee = setupLogTee(stream);
|
|
2001
|
+
const tag = c.head("[translation-translate]");
|
|
2002
|
+
console.log(`${tag} run started ${c.label(startedAt.toISOString())}`);
|
|
2003
|
+
console.log(`${tag} output ${c.path(runDir)}`);
|
|
2004
|
+
console.log("");
|
|
2005
|
+
console.log(c.head("[Step 1/3] Input"));
|
|
2006
|
+
const recents = await findRecentDiffOutputs(5);
|
|
2007
|
+
const picked = await promptInput(recents);
|
|
2008
|
+
console.log(
|
|
2009
|
+
` ${c.label("loaded")} ${c.value(Object.keys(picked.keyMap).length)} ${c.label("english keys from")} ${c.path(picked.sourceLabel)}`
|
|
2010
|
+
);
|
|
2011
|
+
console.log("");
|
|
2012
|
+
console.log(c.head("[Step 2/3] Target languages"));
|
|
2013
|
+
const allLangs = await loadLanguages();
|
|
2014
|
+
const selectedLangs = await promptTargetLanguages(allLangs);
|
|
2015
|
+
console.log(
|
|
2016
|
+
` ${c.label("targets:")} ${selectedLangs.map((l) => c.value(l.code)).join(c.label(", "))} ${c.label(`(${selectedLangs.length} languages)`)}`
|
|
2017
|
+
);
|
|
2018
|
+
console.log("");
|
|
2019
|
+
console.log(c.head("[Step 3/3] Model"));
|
|
2020
|
+
const modelSpinner = new Spinner();
|
|
2021
|
+
const models = await fetchModels(modelSpinner);
|
|
2022
|
+
const model = await promptModel(models);
|
|
2023
|
+
console.log(` ${c.label("model:")} ${c.value(model)}`);
|
|
2024
|
+
console.log("");
|
|
2025
|
+
printHeader({
|
|
2026
|
+
model,
|
|
2027
|
+
sourceLabel: picked.sourceLabel,
|
|
2028
|
+
keyCount: Object.keys(picked.keyMap).length,
|
|
2029
|
+
langs: selectedLangs,
|
|
2030
|
+
outputDir: path3.relative(process.cwd(), runDir)
|
|
2031
|
+
});
|
|
2032
|
+
const promptTemplate = await fs3.readFile(PROMPT_PATH, "utf8");
|
|
2033
|
+
const prompt = renderPrompt(promptTemplate, selectedLangs, picked.keyMap);
|
|
2034
|
+
const t0 = Date.now();
|
|
2035
|
+
const spinner = new Spinner();
|
|
2036
|
+
const { response, thinking, connectMs, thinkingMs, responseMs, metrics, responseTokens, thinkingTokens } = await callOllama(model, prompt, spinner);
|
|
2037
|
+
const totalMs = Date.now() - t0;
|
|
2038
|
+
const jsonText = extractJSON(response);
|
|
2039
|
+
let parsed;
|
|
2040
|
+
try {
|
|
2041
|
+
parsed = JSON.parse(jsonText);
|
|
2042
|
+
} catch (e) {
|
|
2043
|
+
const debugPath = `${outputPath}.raw.txt`;
|
|
2044
|
+
await fs3.writeFile(debugPath, response, "utf8");
|
|
2045
|
+
throw new Error(
|
|
2046
|
+
`Failed to parse JSON from model output. Raw response saved to ${debugPath}.
|
|
2047
|
+
${e.message}`
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
const expectedLangs = [SOURCE_LANG, ...selectedLangs.map((l) => l.code)];
|
|
2051
|
+
const expectedKeys = Object.keys(picked.keyMap);
|
|
2052
|
+
const report = validateTranslation(parsed, expectedLangs, expectedKeys);
|
|
2053
|
+
const sorted = sortedLangBundle(parsed, expectedLangs, expectedKeys);
|
|
2054
|
+
await fs3.writeFile(outputPath, JSON.stringify(sorted, null, 2) + "\n", "utf8");
|
|
2055
|
+
const responseRate = metrics.evalTokens && metrics.evalMs ? (metrics.evalTokens / metrics.evalMs * 1e3).toFixed(1) : "\u2014";
|
|
2056
|
+
process.stdout.write(`
|
|
2057
|
+
${rule()}
|
|
2058
|
+
`);
|
|
2059
|
+
process.stdout.write(` ${C.greenBold("\u2713 Done")} ${C.dim(`in ${(totalMs / 1e3).toFixed(1)}s total`)}
|
|
2060
|
+
`);
|
|
2061
|
+
process.stdout.write(` ${rule()}
|
|
2062
|
+
`);
|
|
2063
|
+
process.stdout.write(` ${C.bold("Timings")}
|
|
2064
|
+
`);
|
|
2065
|
+
process.stdout.write(
|
|
2066
|
+
` ${C.dim(" connect :")} ${fmtMs(connectMs)} ${C.dim("load:")} ${fmtMs(metrics.loadMs)}
|
|
2067
|
+
`
|
|
2068
|
+
);
|
|
2069
|
+
process.stdout.write(
|
|
2070
|
+
` ${C.dim(" thinking :")} ${fmtMs(thinkingMs)} ${C.dim(`(${thinkingTokens} tok streamed)`)}
|
|
2071
|
+
`
|
|
2072
|
+
);
|
|
2073
|
+
process.stdout.write(
|
|
2074
|
+
` ${C.dim(" response :")} ${fmtMs(responseMs)} ${C.dim(`(${responseTokens} tok streamed)`)}
|
|
2075
|
+
`
|
|
2076
|
+
);
|
|
2077
|
+
process.stdout.write(` ${C.bold("Tokens")} ${C.dim("(from Ollama)")}
|
|
2078
|
+
`);
|
|
2079
|
+
process.stdout.write(
|
|
2080
|
+
` ${C.dim(" prompt :")} ${fmtTokOrDash(metrics.promptTokens)} tok ${C.dim("eval:")} ${fmtMs(metrics.promptEvalMs)}
|
|
2081
|
+
`
|
|
2082
|
+
);
|
|
2083
|
+
process.stdout.write(
|
|
2084
|
+
` ${C.dim(" output :")} ${fmtTokOrDash(metrics.evalTokens)} tok ${C.dim("eval:")} ${fmtMs(metrics.evalMs)} ${C.dim("rate:")} ${responseRate} tok/s
|
|
2085
|
+
`
|
|
2086
|
+
);
|
|
2087
|
+
process.stdout.write(` ${C.bold("Output")}
|
|
2088
|
+
`);
|
|
2089
|
+
process.stdout.write(
|
|
2090
|
+
` ${C.dim(" json :")} ${response.length} chars, ${expectedLangs.length} langs ${C.dim(`(${expectedLangs.join(", ")})`)}
|
|
2091
|
+
`
|
|
2092
|
+
);
|
|
2093
|
+
process.stdout.write(` ${C.dim(" thinking :")} ${thinking.length} chars
|
|
2094
|
+
`);
|
|
2095
|
+
process.stdout.write(` ${C.dim(" wrote :")} ${outputPath}
|
|
2096
|
+
`);
|
|
2097
|
+
process.stdout.write(` ${C.dim(" log :")} ${logAbs}
|
|
2098
|
+
`);
|
|
2099
|
+
if (report.missingLangs.length > 0 || Object.keys(report.perLangMissingKeys).length > 0) {
|
|
2100
|
+
process.stdout.write(`
|
|
2101
|
+
${C.yellowBold("\u26A0 Validation warnings:")}
|
|
2102
|
+
`);
|
|
2103
|
+
if (report.missingLangs.length > 0) {
|
|
2104
|
+
process.stdout.write(
|
|
2105
|
+
` ${C.dim(" missing langs:")} ${report.missingLangs.join(", ")}
|
|
2106
|
+
`
|
|
2107
|
+
);
|
|
2108
|
+
}
|
|
2109
|
+
for (const [lang, keys] of Object.entries(report.perLangMissingKeys)) {
|
|
2110
|
+
const preview = keys.slice(0, 5).join(", ") + (keys.length > 5 ? `, \u2026 (+${keys.length - 5})` : "");
|
|
2111
|
+
process.stdout.write(` ${C.dim(` ${lang} missing ${keys.length} key(s):`)} ${preview}
|
|
2112
|
+
`);
|
|
2113
|
+
}
|
|
2114
|
+
for (const [lang, keys] of Object.entries(report.perLangExtraKeys)) {
|
|
2115
|
+
const preview = keys.slice(0, 5).join(", ") + (keys.length > 5 ? `, \u2026 (+${keys.length - 5})` : "");
|
|
2116
|
+
process.stdout.write(` ${C.dim(` ${lang} extra ${keys.length} key(s):`)} ${preview}
|
|
2117
|
+
`);
|
|
2118
|
+
}
|
|
2119
|
+
} else {
|
|
2120
|
+
process.stdout.write(`
|
|
2121
|
+
${C.green("\u2713 Validation passed")} ${C.dim("(all languages have all keys)")}
|
|
2122
|
+
`);
|
|
2123
|
+
}
|
|
2124
|
+
process.stdout.write(` ${rule()}
|
|
2125
|
+
|
|
2126
|
+
`);
|
|
2127
|
+
await logTee.teardown();
|
|
2128
|
+
}
|
|
2129
|
+
function fmtTokOrDash(n) {
|
|
2130
|
+
return n === void 0 ? C.dim("\u2014") : String(n);
|
|
2131
|
+
}
|
|
2132
|
+
async function runCommand3(_argv) {
|
|
2133
|
+
try {
|
|
2134
|
+
await main3();
|
|
2135
|
+
} catch (err) {
|
|
2136
|
+
process.stdout.write("\n");
|
|
2137
|
+
console.error(formatError(err));
|
|
2138
|
+
process.stdout.write(`
|
|
2139
|
+
${ansi.red}${ansi.bold}[fatal]${ansi.reset} translation failed
|
|
2140
|
+
`);
|
|
2141
|
+
process.exit(1);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// src/cli.ts
|
|
2146
|
+
var SUBCOMMANDS = [
|
|
2147
|
+
{ name: "extract", summary: "Scan source for translation keys", run: runCommand },
|
|
2148
|
+
{ name: "diff", summary: "Compare extracted keys against a translation file", run: runCommand2 },
|
|
2149
|
+
{ name: "translate", summary: "Translate missing keys via Ollama", run: runCommand3 }
|
|
2150
|
+
];
|
|
2151
|
+
function printHelp() {
|
|
2152
|
+
const pad = Math.max(...SUBCOMMANDS.map((s) => s.name.length));
|
|
2153
|
+
process.stdout.write(`
|
|
2154
|
+
i18n-toolkit \u2014 extract, diff, and translate i18n keys for React/Next.js
|
|
2155
|
+
|
|
2156
|
+
Usage:
|
|
2157
|
+
i18n-toolkit <command> [options]
|
|
2158
|
+
|
|
2159
|
+
Commands:
|
|
2160
|
+
${SUBCOMMANDS.map((s) => ` ${s.name.padEnd(pad)} ${s.summary}`).join("\n")}
|
|
2161
|
+
${"help".padEnd(pad)} Show this message
|
|
2162
|
+
|
|
2163
|
+
Examples:
|
|
2164
|
+
i18n-toolkit extract --src ./src
|
|
2165
|
+
i18n-toolkit diff --src ./src --resource ./translations.json
|
|
2166
|
+
i18n-toolkit translate
|
|
2167
|
+
|
|
2168
|
+
Pass --help to any command for command-specific options.
|
|
2169
|
+
|
|
2170
|
+
`);
|
|
2171
|
+
}
|
|
2172
|
+
async function main4() {
|
|
2173
|
+
const [sub, ...rest] = process.argv.slice(2);
|
|
2174
|
+
if (!sub || sub === "help" || sub === "-h" || sub === "--help") {
|
|
2175
|
+
printHelp();
|
|
2176
|
+
process.exit(sub ? 0 : 0);
|
|
2177
|
+
}
|
|
2178
|
+
const cmd = SUBCOMMANDS.find((s) => s.name === sub);
|
|
2179
|
+
if (!cmd) {
|
|
2180
|
+
process.stderr.write(`
|
|
2181
|
+
Unknown command: ${sub}
|
|
2182
|
+
`);
|
|
2183
|
+
printHelp();
|
|
2184
|
+
process.exit(2);
|
|
2185
|
+
}
|
|
2186
|
+
process.argv = [process.argv[0], process.argv[1], ...rest];
|
|
2187
|
+
await cmd.run(rest);
|
|
2188
|
+
}
|
|
2189
|
+
main4().catch((err) => {
|
|
2190
|
+
process.stderr.write(`
|
|
2191
|
+
[fatal] ${err instanceof Error ? err.message : String(err)}
|
|
2192
|
+
`);
|
|
2193
|
+
if (err instanceof Error && err.stack) process.stderr.write(err.stack + "\n");
|
|
2194
|
+
process.exit(1);
|
|
2195
|
+
});
|