bctranslate 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,445 @@
1
+ import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import chalk from 'chalk';
4
+ import { getLocaleDir } from './locales.js';
5
+
6
+ /**
7
+ * Ensure the i18n configuration file and locale files exist.
8
+ * Creates them if they don't.
9
+ *
10
+ * @param {string} cwd - Project root
11
+ * @param {object} project - Detected project info
12
+ * @param {string} from - Source language code
13
+ * @param {string} to - Target language code
14
+ * @param {string} [customLocaleDir] - Optional override for locale directory
15
+ */
16
+ export async function ensureI18nSetup(cwd, project, from, to, customLocaleDir, options = {}) {
17
+ const { autoImport = true } = options;
18
+ const localeDir = customLocaleDir || getLocaleDir(cwd, project);
19
+ mkdirSync(localeDir, { recursive: true });
20
+
21
+ // Ensure locale JSON files exist
22
+ for (const lang of [from, to]) {
23
+ const localePath = join(localeDir, `${lang}.json`);
24
+ if (!existsSync(localePath)) {
25
+ writeFileSync(localePath, '{}\n', 'utf-8');
26
+ console.log(chalk.green(` ✓ Created ${localePath}`));
27
+ }
28
+ }
29
+
30
+ if (project.type === 'vue') {
31
+ await ensureVueI18n(cwd, project, from, to, localeDir, { autoImport });
32
+ } else if (project.type === 'react') {
33
+ await ensureReactI18n(cwd, project, from, to, localeDir, { autoImport });
34
+ } else {
35
+ await ensureVanillaI18n(cwd, from, to, localeDir, { autoImport });
36
+ }
37
+ }
38
+
39
+ async function ensureVueI18n(cwd, project, from, to, localeDir, { autoImport }) {
40
+ const i18nFile = join(cwd, 'src', 'i18n.js');
41
+
42
+ if (existsSync(i18nFile)) {
43
+ console.log(chalk.gray(' → i18n.js already exists, skipping setup'));
44
+ return;
45
+ }
46
+
47
+ const isVue3 = project.usesCompositionApi;
48
+ const relLocale = localeDir.replace(cwd, '.').replace(/\\/g, '/');
49
+
50
+ let content;
51
+ if (isVue3) {
52
+ content = `import { createI18n } from 'vue-i18n';
53
+ import ${from} from '${relLocale}/${from}.json';
54
+ import ${to} from '${relLocale}/${to}.json';
55
+
56
+ function nestMessages(input) {
57
+ if (!input || typeof input !== 'object') return {};
58
+ const out = Array.isArray(input) ? [] : {};
59
+ for (const [rawKey, value] of Object.entries(input)) {
60
+ if (!rawKey.includes('.')) {
61
+ out[rawKey] = value;
62
+ continue;
63
+ }
64
+ const parts = rawKey.split('.').filter(Boolean);
65
+ let cur = out;
66
+ for (let i = 0; i < parts.length; i++) {
67
+ const p = parts[i];
68
+ if (i === parts.length - 1) {
69
+ cur[p] = value;
70
+ } else {
71
+ if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
72
+ cur = cur[p];
73
+ }
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ const i18n = createI18n({
80
+ legacy: false,
81
+ locale: '${from}',
82
+ fallbackLocale: '${from}',
83
+ messages: {
84
+ ${from}: nestMessages(${from}),
85
+ ${to}: nestMessages(${to}),
86
+ },
87
+ });
88
+
89
+ export default i18n;
90
+ `;
91
+ } else {
92
+ content = `import Vue from 'vue';
93
+ import VueI18n from 'vue-i18n';
94
+ import ${from} from '${relLocale}/${from}.json';
95
+ import ${to} from '${relLocale}/${to}.json';
96
+
97
+ Vue.use(VueI18n);
98
+
99
+ function nestMessages(input) {
100
+ if (!input || typeof input !== 'object') return {};
101
+ const out = Array.isArray(input) ? [] : {};
102
+ for (const [rawKey, value] of Object.entries(input)) {
103
+ if (!rawKey.includes('.')) {
104
+ out[rawKey] = value;
105
+ continue;
106
+ }
107
+ const parts = rawKey.split('.').filter(Boolean);
108
+ let cur = out;
109
+ for (let i = 0; i < parts.length; i++) {
110
+ const p = parts[i];
111
+ if (i === parts.length - 1) {
112
+ cur[p] = value;
113
+ } else {
114
+ if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
115
+ cur = cur[p];
116
+ }
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ const i18n = new VueI18n({
123
+ locale: '${from}',
124
+ fallbackLocale: '${from}',
125
+ messages: {
126
+ ${from}: nestMessages(${from}),
127
+ ${to}: nestMessages(${to}),
128
+ },
129
+ });
130
+
131
+ export default i18n;
132
+ `;
133
+ }
134
+
135
+ mkdirSync(join(cwd, 'src'), { recursive: true });
136
+ writeFileSync(i18nFile, content, 'utf-8');
137
+ console.log(chalk.green(` ✓ Created ${i18nFile}`));
138
+ console.log(chalk.yellow(` ⚠ Don't forget to install vue-i18n: npm install vue-i18n`));
139
+
140
+ if (autoImport) {
141
+ const injected = injectVueEntrypoint(cwd, isVue3);
142
+ if (!injected) {
143
+ console.log(chalk.yellow(` ⚠ Import and use i18n in your main.js/main.ts`));
144
+ }
145
+ } else {
146
+ console.log(chalk.yellow(` ⚠ Import and use i18n in your main.js/main.ts`));
147
+ }
148
+ }
149
+
150
+ async function ensureReactI18n(cwd, project, from, to, localeDir, { autoImport }) {
151
+ const i18nFile = join(cwd, 'src', 'i18n.js');
152
+
153
+ if (existsSync(i18nFile)) {
154
+ console.log(chalk.gray(' → i18n.js already exists, skipping setup'));
155
+ return;
156
+ }
157
+
158
+ const relLocale = localeDir.replace(cwd, '.').replace(/\\/g, '/');
159
+
160
+ const content = `import i18n from 'i18next';
161
+ import { initReactI18next } from 'react-i18next';
162
+ import ${from} from '${relLocale}/${from}.json';
163
+ import ${to} from '${relLocale}/${to}.json';
164
+
165
+ function nestMessages(input) {
166
+ if (!input || typeof input !== 'object') return {};
167
+ const out = Array.isArray(input) ? [] : {};
168
+ for (const [rawKey, value] of Object.entries(input)) {
169
+ if (!rawKey.includes('.')) {
170
+ out[rawKey] = value;
171
+ continue;
172
+ }
173
+ const parts = rawKey.split('.').filter(Boolean);
174
+ let cur = out;
175
+ for (let i = 0; i < parts.length; i++) {
176
+ const p = parts[i];
177
+ if (i === parts.length - 1) {
178
+ cur[p] = value;
179
+ } else {
180
+ if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
181
+ cur = cur[p];
182
+ }
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+
188
+ i18n.use(initReactI18next).init({
189
+ resources: {
190
+ ${from}: { translation: nestMessages(${from}) },
191
+ ${to}: { translation: nestMessages(${to}) },
192
+ },
193
+ lng: '${from}',
194
+ fallbackLng: '${from}',
195
+ interpolation: {
196
+ escapeValue: false,
197
+ },
198
+ });
199
+
200
+ export default i18n;
201
+ `;
202
+
203
+ mkdirSync(join(cwd, 'src'), { recursive: true });
204
+ writeFileSync(i18nFile, content, 'utf-8');
205
+ console.log(chalk.green(` ✓ Created ${i18nFile}`));
206
+ console.log(chalk.yellow(` ⚠ Don't forget to install: npm install i18next react-i18next`));
207
+
208
+ if (autoImport) {
209
+ const injected = injectReactEntrypoint(cwd);
210
+ if (!injected) {
211
+ console.log(chalk.yellow(` ⚠ Import './i18n' in your index.js/App.js`));
212
+ }
213
+ } else {
214
+ console.log(chalk.yellow(` ⚠ Import './i18n' in your index.js/App.js`));
215
+ }
216
+ }
217
+
218
+ async function ensureVanillaI18n(cwd, from, to, localeDir, { autoImport }) {
219
+ const i18nFile = join(cwd, 'i18n.js');
220
+
221
+ if (existsSync(i18nFile)) {
222
+ console.log(chalk.gray(' → i18n.js already exists, skipping setup'));
223
+ return;
224
+ }
225
+
226
+ const content = `/**
227
+ * Simple i18n for vanilla JS — generated by bctranslate
228
+ */
229
+ (function () {
230
+ const locales = {};
231
+ let currentLocale = '${from}';
232
+ const fallbackLocale = '${from}';
233
+
234
+ async function loadLocale(lang) {
235
+ if (locales[lang]) return;
236
+ const resp = await fetch('./locales/' + lang + '.json');
237
+ locales[lang] = await resp.json();
238
+ }
239
+
240
+ function t(key, params) {
241
+ // Support both flat keys ("home.submit") and nested objects ({ home: { submit: ... } })
242
+ const dict = locales[currentLocale] || {};
243
+ const dictFallback = locales[fallbackLocale] || {};
244
+
245
+ const lookup = (d) => {
246
+ const direct = Object.prototype.hasOwnProperty.call(d, key) ? d[key] : null;
247
+ return direct !== null && direct !== undefined
248
+ ? direct
249
+ : key.split('.').reduce((obj, i) => (obj ? obj[i] : null), d);
250
+ };
251
+
252
+ const msg = lookup(dict) ?? lookup(dictFallback) ?? key;
253
+
254
+ if (!params) return msg;
255
+ return String(msg).replace(/\\{(\\w+)\\}/g, (match, k) =>
256
+ params[k] !== undefined ? params[k] : match
257
+ );
258
+ }
259
+
260
+ async function setLocale(lang) {
261
+ await loadLocale(lang);
262
+ currentLocale = lang;
263
+ // Re-translate all elements with data-i18n attribute
264
+ document.querySelectorAll('[data-i18n]').forEach(function (el) {
265
+ const key = el.getAttribute('data-i18n');
266
+ const translated = t(key);
267
+ // Preserve markup translations (e.g. "Hello <strong>world</strong>")
268
+ if (el.children.length > 0 || /<[^>]+>/.test(String(translated))) {
269
+ el.innerHTML = translated;
270
+ } else {
271
+ el.textContent = translated;
272
+ }
273
+ });
274
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
275
+ el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
276
+ });
277
+ document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
278
+ const translated = t(el.getAttribute('data-i18n-title'));
279
+ if (el.tagName === 'TITLE') {
280
+ document.title = translated;
281
+ } else {
282
+ el.title = translated;
283
+ }
284
+ });
285
+ }
286
+
287
+ // Auto-init
288
+ loadLocale('${from}');
289
+
290
+ const api = { t: t, setLocale: setLocale, loadLocale: loadLocale, ready: null };
291
+ window.i18n = api;
292
+ api.ready = setLocale('${to}');
293
+ })();
294
+ `;
295
+
296
+ writeFileSync(i18nFile, content, 'utf-8');
297
+ console.log(chalk.green(` ✓ Created ${i18nFile}`));
298
+
299
+ if (autoImport) {
300
+ const injected = injectVanillaI18nEntrypoint(cwd);
301
+ if (!injected) {
302
+ console.log(chalk.yellow(` ⚠ Add <script src="i18n.js"></script> to your HTML`));
303
+ }
304
+ } else {
305
+ console.log(chalk.yellow(` ⚠ Add <script src="i18n.js"></script> to your HTML`));
306
+ }
307
+ }
308
+
309
+ function injectVueEntrypoint(cwd, isVue3) {
310
+ const candidates = [
311
+ join(cwd, 'src', 'main.ts'),
312
+ join(cwd, 'src', 'main.js'),
313
+ join(cwd, 'src', 'main.tsx'),
314
+ join(cwd, 'src', 'main.jsx'),
315
+ ];
316
+
317
+ const mainFile = candidates.find((p) => existsSync(p));
318
+ if (!mainFile) return false;
319
+
320
+ let code = readFileSync(mainFile, 'utf-8');
321
+
322
+ const hasI18nImport =
323
+ code.includes("from './i18n'") ||
324
+ code.includes('from "./i18n"') ||
325
+ code.includes("from './i18n.js'") ||
326
+ code.includes('from "./i18n.js"') ||
327
+ code.includes("import './i18n'") ||
328
+ code.includes('import "./i18n"');
329
+
330
+ if (!hasI18nImport) {
331
+ const importRe = /^import\s.+?;\s*$/gm;
332
+ let lastImportEnd = 0;
333
+ let m;
334
+ while ((m = importRe.exec(code)) !== null) lastImportEnd = m.index + m[0].length;
335
+ code =
336
+ code.slice(0, lastImportEnd) +
337
+ `\nimport i18n from './i18n';\n` +
338
+ code.slice(lastImportEnd);
339
+ }
340
+
341
+ if (isVue3) {
342
+ if (!code.includes('.use(i18n)') && !code.includes('use(i18n)')) {
343
+ const chained = /createApp\(([^)]*)\)\s*\.mount\(/;
344
+ if (chained.test(code)) {
345
+ code = code.replace(chained, 'createApp($1).use(i18n).mount(');
346
+ } else {
347
+ const hasAppVar =
348
+ /\bconst\s+app\s*=\s*createApp\(/.test(code) ||
349
+ /\blet\s+app\s*=\s*createApp\(/.test(code);
350
+ const mountIdx = code.indexOf('app.mount(');
351
+ if (hasAppVar && mountIdx !== -1 && !code.includes('app.use(i18n')) {
352
+ code = code.slice(0, mountIdx) + `app.use(i18n);\n` + code.slice(mountIdx);
353
+ } else {
354
+ return false;
355
+ }
356
+ }
357
+ }
358
+ } else {
359
+ if (!/\bi18n\s*[:,]/.test(code)) {
360
+ const newVue = /new\s+Vue\s*\(\s*\{/;
361
+ if (newVue.test(code)) {
362
+ code = code.replace(newVue, (m) => m + '\n i18n,');
363
+ } else {
364
+ return false;
365
+ }
366
+ }
367
+ }
368
+
369
+ writeFileSync(mainFile, code, 'utf-8');
370
+ console.log(chalk.green(` ✓ Updated ${mainFile} (wired i18n)`));
371
+ return true;
372
+ }
373
+
374
+ function injectReactEntrypoint(cwd) {
375
+ const candidates = [
376
+ join(cwd, 'src', 'index.tsx'),
377
+ join(cwd, 'src', 'index.js'),
378
+ join(cwd, 'src', 'main.tsx'),
379
+ join(cwd, 'src', 'main.jsx'),
380
+ ];
381
+
382
+ const entryFile = candidates.find((p) => existsSync(p));
383
+ if (!entryFile) return false;
384
+
385
+ let code = readFileSync(entryFile, 'utf-8');
386
+ if (
387
+ code.includes("from './i18n'") ||
388
+ code.includes('from "./i18n"') ||
389
+ code.includes("import './i18n'") ||
390
+ code.includes('import "./i18n"')
391
+ ) {
392
+ return true;
393
+ }
394
+
395
+ const importRe = /^import\s.+?;\s*$/gm;
396
+ let lastImportEnd = 0;
397
+ let m;
398
+ while ((m = importRe.exec(code)) !== null) lastImportEnd = m.index + m[0].length;
399
+ code = code.slice(0, lastImportEnd) + `\nimport './i18n';\n` + code.slice(lastImportEnd);
400
+
401
+ writeFileSync(entryFile, code, 'utf-8');
402
+ console.log(chalk.green(` ✓ Updated ${entryFile} (imported i18n)`));
403
+ return true;
404
+ }
405
+
406
+ function injectVanillaI18nEntrypoint(cwd) {
407
+ // Prefer HTML entrypoint if present
408
+ const htmlPath = join(cwd, 'index.html');
409
+ if (existsSync(htmlPath)) {
410
+ const html = readFileSync(htmlPath, 'utf-8');
411
+ if (!/\bi18n\.js\b/.test(html)) {
412
+ const scriptTag = ` <script src="./i18n.js"></script>\n`;
413
+ let updated = html;
414
+
415
+ const firstScript = updated.match(/<script\b/i);
416
+ if (firstScript) {
417
+ updated = updated.replace(firstScript[0], scriptTag + firstScript[0]);
418
+ } else if (updated.includes('</body>')) {
419
+ updated = updated.replace('</body>', scriptTag + '</body>');
420
+ } else if (updated.includes('</head>')) {
421
+ updated = updated.replace('</head>', scriptTag + '</head>');
422
+ } else {
423
+ updated += '\n' + scriptTag;
424
+ }
425
+
426
+ writeFileSync(htmlPath, updated, 'utf-8');
427
+ console.log(chalk.green(` ✓ Updated ${htmlPath} (added i18n.js script)`));
428
+ }
429
+ return true;
430
+ }
431
+
432
+ // Fallback: ESM entrypoint
433
+ const jsPath = join(cwd, 'index.js');
434
+ if (existsSync(jsPath)) {
435
+ const js = readFileSync(jsPath, 'utf-8');
436
+ const alreadyImports = js.includes("./i18n.js") || js.includes("'./i18n.js'") || js.includes("\"./i18n.js\"");
437
+ if (!alreadyImports && /\b(import|export)\b/.test(js)) {
438
+ writeFileSync(jsPath, `import './i18n.js';\n` + js, 'utf-8');
439
+ console.log(chalk.green(` ✓ Updated ${jsPath} (imported i18n.js)`));
440
+ return true;
441
+ }
442
+ }
443
+
444
+ return false;
445
+ }
package/src/index.js ADDED
@@ -0,0 +1,233 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { extname, relative, basename, dirname, join } from 'path';
3
+ import { parseVue } from './parsers/vue.js';
4
+ import { parseReact } from './parsers/react.js';
5
+ import { parseHtml } from './parsers/html.js';
6
+ import { parseJs } from './parsers/js.js';
7
+ import { parseJson, applyJsonTranslations } from './parsers/json.js';
8
+ import { translateBatch } from './bridges/python.js';
9
+ import { getLocaleDir, saveLocale, loadLocale } from './generators/locales.js';
10
+
11
+ // ── Route source file to the correct parser ──────────────────────────────────
12
+
13
+ function routeParser(ext, source, filePath, project, jsonMode) {
14
+ switch (ext) {
15
+ case '.vue': return parseVue(source, filePath);
16
+ case '.jsx':
17
+ case '.tsx': return parseReact(source, filePath);
18
+ case '.html':
19
+ case '.htm': return parseHtml(source, filePath, project);
20
+ case '.js':
21
+ case '.ts':
22
+ if (source.includes('React') || source.includes('jsx') || /<\w+[\s>]/.test(source)) {
23
+ return parseReact(source, filePath);
24
+ }
25
+ return parseJs(source, filePath, project);
26
+ case '.json': return parseJson(source, filePath, { jsonMode });
27
+ default: return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Parse a file — no translation, no disk writes.
33
+ * Returns extracted strings + modified source for later application.
34
+ */
35
+ export function parseFileOnly(filePath, opts) {
36
+ const { project, jsonMode = 'values' } = opts;
37
+ const ext = extname(filePath).toLowerCase();
38
+ const source = readFileSync(filePath, 'utf-8');
39
+
40
+ const result = routeParser(ext, source, filePath, project, jsonMode);
41
+ if (!result || !result.extracted?.length) {
42
+ return { source, modified: source, extracted: [], ext, filePath };
43
+ }
44
+ return {
45
+ source,
46
+ modified: result.source,
47
+ extracted: result.extracted,
48
+ ext,
49
+ filePath,
50
+ jsonData: result.jsonData,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Apply a completed translations map to one parsed file and write to disk.
56
+ * The original file is NOT touched until an atomic rename succeeds.
57
+ */
58
+ export async function writeFileResult(parseResult, translations, opts) {
59
+ const { source, modified, extracted, ext, filePath, jsonData } = parseResult;
60
+ const { from, to, dryRun, outdir, project, cwd, verbose, localesDir } = opts;
61
+
62
+ const relativePath = relative(cwd, filePath);
63
+ if (!extracted?.length) return { count: 0, skipped: 0, relativePath };
64
+
65
+ // Deduplicate extracted items by key
66
+ const seen = new Set();
67
+ const unique = extracted.filter(({ key }) => {
68
+ if (seen.has(key)) return false;
69
+ seen.add(key);
70
+ return true;
71
+ });
72
+
73
+ const resolvedLocaleDir = localesDir || getLocaleDir(cwd, project);
74
+ const existingTarget = loadLocale(resolvedLocaleDir, to);
75
+
76
+ const newCount = unique.filter(({ key }) => !(key in existingTarget)).length;
77
+ const skipped = unique.length - newCount;
78
+
79
+ // Build locale entry maps
80
+ const fromEntries = {};
81
+ const toEntries = {};
82
+ for (const { key, text } of unique) {
83
+ fromEntries[key] = text;
84
+ toEntries[key] = translations[key] ?? existingTarget[key] ?? text;
85
+ }
86
+
87
+ if (!dryRun) {
88
+ saveLocale(resolvedLocaleDir, from, fromEntries);
89
+ saveLocale(resolvedLocaleDir, to, toEntries);
90
+
91
+ if (ext === '.json') {
92
+ const translatedData = applyJsonTranslations(jsonData, toEntries);
93
+ const outName = basename(filePath, ext) + `.${to}${ext}`;
94
+ const outPath = outdir
95
+ ? join(outdir, outName)
96
+ : join(dirname(filePath), outName);
97
+ mkdirSync(dirname(outPath), { recursive: true });
98
+ writeFileSync(outPath, JSON.stringify(translatedData, null, 2) + '\n', 'utf-8');
99
+
100
+ } else if (modified !== source) {
101
+ // Only rewrite source when content actually changed
102
+ // Atomic: write .bctmp first, then rename — original untouched until swap
103
+ const outPath = outdir ? join(outdir, basename(filePath)) : filePath;
104
+ const tmpPath = outPath + '.bctmp';
105
+ if (outdir) mkdirSync(outdir, { recursive: true });
106
+ writeFileSync(tmpPath, modified, 'utf-8');
107
+ const { renameSync } = await import('fs');
108
+ renameSync(tmpPath, outPath);
109
+ }
110
+ }
111
+
112
+ return {
113
+ count: newCount,
114
+ skipped,
115
+ relativePath,
116
+ diff: dryRun || verbose ? generateDiff(source, modified, relativePath) : null,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Translate ALL files in one Python invocation — model loads exactly once.
122
+ *
123
+ * Phase 1: Parse every file (pure CPU — no network/Python)
124
+ * Phase 2: Collect all unique keys not already in the target locale
125
+ * Phase 3: translateBatch() once → Python starts, model loads, all strings translated
126
+ * Phase 4: Write each file
127
+ */
128
+ export async function translateAllFiles(files, opts) {
129
+ const { from, to, cwd, project, verbose, localesDir } = opts;
130
+ const prof0 = opts.profile ? Date.now() : 0;
131
+
132
+ const resolvedLocaleDir = opts.outdir ? join(opts.outdir, 'locales') : (localesDir || getLocaleDir(cwd, project));
133
+ const existingTarget = loadLocale(resolvedLocaleDir, to);
134
+
135
+ // Phase 1 — parse
136
+ const profParse0 = opts.profile ? Date.now() : 0;
137
+ const parsed = [];
138
+ for (const file of files) {
139
+ try {
140
+ const result = parseFileOnly(file, opts);
141
+ if (result.extracted.length > 0) parsed.push(result);
142
+ } catch (err) {
143
+ if (verbose) console.error(` Parse error ${file}: ${err.message}`);
144
+ }
145
+ }
146
+ const profParse1 = opts.profile ? Date.now() : 0;
147
+
148
+ if (parsed.length === 0) return [];
149
+
150
+ // Phase 2 — deduplicate across all files, skip already-translated keys
151
+ const seenKeys = new Set(Object.keys(existingTarget));
152
+ const needed = [];
153
+ for (const { extracted } of parsed) {
154
+ for (const { key, text } of extracted) {
155
+ if (!seenKeys.has(key)) {
156
+ seenKeys.add(key);
157
+ needed.push({ key, text });
158
+ }
159
+ }
160
+ }
161
+
162
+ // Phase 3 — translate once (Python spawned exactly once, model loads once)
163
+ const profTrans0 = opts.profile ? Date.now() : 0;
164
+ let newTranslations = {};
165
+ if (needed.length > 0) {
166
+ newTranslations = await translateBatch(needed, from, to);
167
+ }
168
+ const profTrans1 = opts.profile ? Date.now() : 0;
169
+
170
+ const allTranslations = { ...existingTarget, ...newTranslations };
171
+
172
+ // Phase 4 — write files
173
+ const profWrite0 = opts.profile ? Date.now() : 0;
174
+ if (!opts.dryRun) {
175
+ console.log(` → Locale dir: ${resolvedLocaleDir}`);
176
+ }
177
+ const results = [];
178
+ for (const parseResult of parsed) {
179
+ try {
180
+ const r = await writeFileResult(parseResult, allTranslations, {
181
+ ...opts,
182
+ localesDir: resolvedLocaleDir,
183
+ });
184
+ results.push(r);
185
+ } catch (err) {
186
+ console.error(` ✗ Write error ${relative(cwd, parseResult.filePath)}: ${err.message}`);
187
+ results.push({ count: 0, skipped: 0, relativePath: relative(cwd, parseResult.filePath) });
188
+ }
189
+ }
190
+ const profWrite1 = opts.profile ? Date.now() : 0;
191
+
192
+ if (opts.profile) {
193
+ const total = Date.now() - prof0;
194
+ console.log(
195
+ ` Timing: parse ${profParse1 - profParse0}ms | translate ${profTrans1 - profTrans0}ms | write ${profWrite1 - profWrite0}ms | total ${total}ms`
196
+ );
197
+ }
198
+
199
+ return results;
200
+ }
201
+
202
+ /**
203
+ * Single-file convenience wrapper (for backward compatibility).
204
+ */
205
+ export async function translateFile(filePath, opts) {
206
+ const results = await translateAllFiles([filePath], opts);
207
+ return results[0] ?? { count: 0, skipped: 0, relativePath: relative(opts.cwd, filePath) };
208
+ }
209
+
210
+ // ── Diff display ─────────────────────────────────────────────────────────────
211
+
212
+ export function generateDiff(original, modified, label = '') {
213
+ if (!modified || original === modified) return '';
214
+
215
+ const origLines = original.split('\n');
216
+ const modLines = modified.split('\n');
217
+ const out = label ? [`--- ${label} ---`] : [];
218
+
219
+ const maxLines = Math.max(origLines.length, modLines.length);
220
+ for (let i = 0; i < maxLines; i++) {
221
+ const a = origLines[i] ?? '';
222
+ const b = modLines[i] ?? '';
223
+ if (a !== b) {
224
+ out.push(` ${String(i + 1).padStart(4)} - ${a.trimEnd()}`);
225
+ out.push(` ${String(i + 1).padStart(4)} + ${b.trimEnd()}`);
226
+ }
227
+ }
228
+
229
+ if (out.length > 80) {
230
+ return out.slice(0, 80).join('\n') + `\n ... (${out.length - 80} more lines truncated)`;
231
+ }
232
+ return out.join('\n');
233
+ }