-
-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathi18n-translate.mjs
More file actions
794 lines (745 loc) · 30.8 KB
/
Copy pathi18n-translate.mjs
File metadata and controls
794 lines (745 loc) · 30.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
#!/usr/bin/env node
// i18n-translate — incremental, glossary-locked machine translation of the
// source catalog into every target locale, modeled on LobeHub's lobe-i18n.
//
// Pipeline (matches the LobeHub approach, adapted for three.ws):
// 1. Read the entryLocale catalog (locales/en.json) as the source of truth.
// 2. For each target locale, diff against the committed translation and
// translate ONLY the missing/empty keys — re-runs are nearly free.
// 3. Brand/protocol terms, {{placeholders}}, and HTML tags are masked to
// opaque sentinels before the text reaches the model and restored after,
// so `$THREE`, the contract address, etc. come back byte-for-byte.
// 4. Large namespaces are split under a token budget; chunks run concurrently.
// 5. Output is committed as static JSON — zero runtime translation cost.
//
// Backends (real APIs, selected by `provider` in .i18nrc.json):
// gemini → Generative Language API (GEMINI_API_KEY | GOOGLE_API_KEY)
// vertex → Vertex AI (Gemini) (GOOGLE_CLOUD_PROJECT + GCP creds; billed to
// platform GCP credits, no free-tier quota to exhaust)
// openai → Chat Completions (OPENAI_API_KEY [+ OPENAI_BASE_URL])
// anthropic → Messages (ANTHROPIC_API_KEY)
//
// Usage:
// node scripts/i18n-translate.mjs # translate missing keys, all locales
// node scripts/i18n-translate.mjs --locale=es # one locale
// node scripts/i18n-translate.mjs --force # retranslate everything
// node scripts/i18n-translate.mjs --lint # validate only (build gate, no API key needed)
// node scripts/i18n-translate.mjs --repair # re-translate only lint-failing keys
// node scripts/i18n-translate.mjs --dry-run # report what would translate
// node scripts/i18n-translate.mjs --concurrency=8 # widen the chunk pool for a bulk run
import { writeFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { config as dotenv } from 'dotenv';
import JSON5 from 'json5';
import {
ROOT,
loadConfig,
readJSON,
flatten,
setDeep,
getDeep,
missingKeys,
untranslatedCount,
mergeOrdered,
buildMasker,
lintLocale,
markupDrift,
} from './lib/i18n-shared.mjs';
// The backend credentials live in .env alongside every other platform secret.
// Without this the default provider (vertex) has no project, every call fails
// its config check, and the run bakes English into the whole catalog - the
// silent-English failure `i18n:backfill` exists to clean up afterwards. dotenv
// never overrides a var already exported, so a one-off `GEMINI_API_KEY=… npm
// run i18n:translate` still wins.
dotenv({ path: new URL('../.env', import.meta.url), quiet: true });
dotenv({ path: new URL('../.env.local', import.meta.url), quiet: true });
const args = process.argv.slice(2);
const flag = (name) => args.includes(`--${name}`);
const opt = (name) => {
const hit = args.find((a) => a.startsWith(`--${name}=`));
return hit ? hit.split('=').slice(1).join('=') : undefined;
};
const cfg = loadConfig();
// CLI overrides for the backend, so a one-off run can switch provider/model
// without editing the committed .i18nrc.json (e.g. --provider=openrouter
// --model=openai/gpt-4o-mini when the default GCP/Vertex path is unavailable).
if (opt('provider')) cfg.provider = opt('provider');
if (opt('model')) cfg.modelName = opt('model');
// The committed concurrency is tuned for free-tier lanes that rate-limit hard.
// A Vertex run billed to the platform's own project has far more headroom, and
// a from-scratch locale is ~550 chunks, so let a bulk run open it up without
// editing the config every other lane still depends on.
if (opt('concurrency')) cfg.concurrency = Math.max(1, Number(opt('concurrency')) || cfg.concurrency);
const sourcePath = resolve(ROOT, cfg.entry);
const source = readJSON(sourcePath);
if (!source) {
console.error(`Source catalog not found: ${cfg.entry}. Run \`npm run i18n:extract\` first.`);
process.exit(1);
}
const onlyLocale = opt('locale');
const targets = (onlyLocale ? [onlyLocale] : cfg.outputLocales).filter(Boolean);
const localePath = (code) => resolve(ROOT, cfg.output, `${code}.json`);
// --- lint mode: pure validation, no network, safe to run in CI -------------
function runLint() {
let problems = 0;
for (const code of targets) {
const target = readJSON(localePath(code));
if (!target) {
// Configured but not yet translated — not an integrity failure. Lint
// gates the catalogs we actually ship; run `npm run i18n:translate` to
// generate the rest.
console.log(`◦ ${code}: not generated yet (skipped)`);
continue;
}
const found = lintLocale(source, target, { code, doNotTranslate: cfg.doNotTranslate });
if (found.length) {
problems += found.length;
for (const p of found) console.error('✗ ' + p);
} else {
console.log(`✓ ${code}: ${Object.keys(flatten(target)).length} keys OK`);
}
}
if (problems) {
console.error(`\ni18n lint failed: ${problems} problem(s).`);
process.exit(1);
}
console.log('\ni18n lint passed.');
}
// --- chunking --------------------------------------------------------------
// Split missing keys into chunks whose combined source text stays under the
// token budget (≈4 chars/token) so no single request risks truncation.
function chunkKeys(keys, budgetChars) {
const chunks = [];
let cur = [];
let size = 0;
for (const k of keys) {
const len = String(getDeep(source, k) ?? '').length + k.length + 8;
if (cur.length && size + len > budgetChars) {
chunks.push(cur);
cur = [];
size = 0;
}
cur.push(k);
size += len;
}
if (cur.length) chunks.push(cur);
return chunks;
}
// --- LLM backends ----------------------------------------------------------
//
// Every backend below is free-tier capable. Gemini and Anthropic use native
// APIs; the rest are OpenAI-compatible chat-completions endpoints, so a single
// caller serves all of them. Env-var names and model defaults match
// api/_lib/chat-models.js, so a key that already powers /chat works here too.
//
// Free lanes (no card required):
// groq GROQ_API_KEY https://console.groq.com/keys
// gemini GEMINI_API_KEY https://aistudio.google.com/apikey (free tier)
// openrouter OPENROUTER_API_KEY https://openrouter.ai/keys (use a :free model)
// nvidia NVIDIA_API_KEY https://build.nvidia.com (free NIM credits)
const PROVIDER_DEFAULT_MODEL = {
gemini: 'gemini-2.5-flash',
vertex: 'google/gemini-2.5-flash',
groq: 'llama-3.3-70b-versatile',
openrouter: 'openai/gpt-oss-20b:free',
nvidia: 'meta/llama-3.3-70b-instruct',
openai: 'gpt-4o-mini',
anthropic: 'claude-haiku-4-5-20251001',
};
// OpenAI-compatible lanes. jsonMode is set only where the endpoint reliably
// honors response_format:json_object — free models often 400 on it, so those
// rely on prompt-enforced JSON plus fence stripping instead.
const OPENAI_COMPAT = {
groq: {
envKey: 'GROQ_API_KEY',
url: () => 'https://api.groq.com/openai/v1/chat/completions',
jsonMode: true,
},
openrouter: {
envKey: 'OPENROUTER_API_KEY',
url: () => 'https://openrouter.ai/api/v1/chat/completions',
extraHeaders: { 'HTTP-Referer': 'https://three.ws', 'X-Title': 'three.ws i18n' },
},
nvidia: {
envKey: 'NVIDIA_API_KEY',
url: () => 'https://integrate.api.nvidia.com/v1/chat/completions',
},
openai: {
envKey: 'OPENAI_API_KEY',
url: () => `${process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'}/chat/completions`,
jsonMode: true,
},
};
function httpError(provider, status, body, retryAfter) {
return Object.assign(new Error(`${provider} ${status}: ${String(body).slice(0, 300)}`), {
status,
retryAfter,
});
}
/**
* A backend that is not configured at all - no key, no project. Distinct from a
* key the model choked on, and it must never be treated like one: the per-key
* fallback bakes the English source, so an unconfigured provider would quietly
* rewrite an entire catalog into English and leave lint green. Marked so the
* run can abort instead.
*/
export function configError(message) {
return Object.assign(new Error(message), { isConfigError: true });
}
/**
* Should this failure abort the run instead of falling back to English?
*
* True for anything that means "the credentials are wrong", which fails
* identically on every key: an absent key or project (configError), and an
* auth rejection from the provider (401/403). The English fallback exists for
* the occasional value a model cannot render as valid JSON; applied to a
* credential failure it silently rewrites a whole catalog into English and
* exits 0.
*/
export function isFatalAuthFailure(err) {
return Boolean(err?.isConfigError || err?.status === 401 || err?.status === 403);
}
function modelName() {
return cfg.modelName || PROVIDER_DEFAULT_MODEL[cfg.provider] || PROVIDER_DEFAULT_MODEL.gemini;
}
function stripFences(text) {
return text
.replace(/^\s*```(?:json)?/i, '')
.replace(/```\s*$/, '')
.trim();
}
function buildPrompt(langName, payload) {
return [
`You are a professional software localizer translating UI and marketing copy from English to ${langName}.`,
cfg.reference || '',
'',
'Rules:',
`- Translate every VALUE in the JSON below into ${langName}. Keep every KEY exactly as-is.`,
'- Return ONLY a single JSON object with the same keys. No prose, no markdown, no code fences.',
'- Some values contain protected tokens written as [[T0]], [[T1]], and so on. They stand in for brand names, code, and placeholders. Copy each token VERBATIM into a natural position for the target language. Never translate a token, change its number, add one, or drop one.',
'- Preserve meaning and tone. Do not add explanations.',
'',
'JSON to translate:',
JSON.stringify(payload),
]
.filter(Boolean)
.join('\n');
}
async function callGemini(prompt) {
const key =
process.env.GEMINI_API_KEY ||
process.env.GOOGLE_API_KEY ||
process.env.GOOGLE_GENAI_API_KEY;
if (!key)
throw configError(
'GEMINI_API_KEY (or GOOGLE_API_KEY) not set — free keys: https://aistudio.google.com/apikey',
);
const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelName()}:generateContent?key=${key}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: {
temperature: cfg.temperature ?? 0.2,
topP: cfg.topP ?? 0.9,
responseMimeType: 'application/json',
},
}),
});
if (!res.ok)
throw httpError(
'gemini',
res.status,
await res.text(),
Number(res.headers.get('retry-after')) || 0,
);
const data = await res.json();
const text = data?.candidates?.[0]?.content?.parts?.map((p) => p.text).join('') || '';
if (!text) throw new Error('gemini returned empty content');
return text;
}
async function callAnthropic(prompt) {
const key = process.env.ANTHROPIC_API_KEY;
if (!key) throw configError('ANTHROPIC_API_KEY not set');
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': key,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: modelName(),
max_tokens: 8192,
temperature: cfg.temperature ?? 0.2,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!res.ok)
throw httpError(
'anthropic',
res.status,
await res.text(),
Number(res.headers.get('retry-after')) || 0,
);
const data = await res.json();
return data?.content?.map((b) => b.text || '').join('') || '';
}
// Vertex AI Gemini — service-account/metadata-server auth, billed to the
// platform's GCP credit pool instead of a free-tier key. No quota to exhaust
// (unlike groq/gemini-aistudio/nvidia's shared free tiers, which can 429
// mid-batch and leave a run partially translated), so this is the reliability
// option when the free lanes are flaky. Same OpenAI-compatible endpoint shape
// api/_lib/llm.js's vertexGeminiProvider() and api/_mcp3d/vertex-imagen.js
// already use, so there is no new wire format to trust. Verified live
// 2026-07-16 (translated a real key through the endpoint end-to-end).
//
// Gotcha (verified live): Gemini 2.5 Flash spends part of its budget on
// internal "reasoning" tokens before emitting any visible content — a tight
// max_tokens (as the chat-completion callers elsewhere use) can burn the
// whole budget on reasoning and return empty content with
// finish_reason:"length". Use a generous ceiling, matching callAnthropic's.
// Mint a Vertex access token, preferring the same service-account path every
// other platform Vertex caller uses. That path is built for Cloud Run and
// Vercel, where a service account is attached or pasted into the environment;
// on a developer machine neither exists, but an authenticated `gcloud` almost
// always does. Falling back to the CLI is what makes the committed default
// provider actually runnable locally instead of failing into English.
let _cliToken = { value: null, expiresAt: 0 };
async function vertexToken({ fresh = false } = {}) {
if (fresh) _cliToken = { value: null, expiresAt: 0 };
try {
const { getGcpAccessToken } = await import('../api/_lib/gcp-auth.js');
if (!fresh) return await getGcpAccessToken();
} catch (err) {
if (err?.code !== 'unconfigured') throw err;
}
if (_cliToken.value && Date.now() < _cliToken.expiresAt) return _cliToken.value;
const { spawnSync } = await import('node:child_process');
const out = spawnSync('gcloud', ['auth', 'print-access-token'], { encoding: 'utf8' });
const token = out.status === 0 && out.stdout.trim();
if (!token) {
throw configError(
'No GCP credentials for vertex: set GCP_SERVICE_ACCOUNT_JSON, or run `gcloud auth login` ' +
'(the CLI token is used automatically). Alternatively pass --provider=gemini with GOOGLE_API_KEY set.',
);
}
// gcloud tokens last an hour; re-shell every 45 minutes rather than per call.
_cliToken = { value: token, expiresAt: Date.now() + 45 * 60 * 1000 };
return token;
}
async function callVertex(prompt) {
const project = process.env.GOOGLE_CLOUD_PROJECT;
if (!project) {
throw configError(
'GOOGLE_CLOUD_PROJECT not set — vertex needs the same GCP project + credentials ' +
'(GCP_SERVICE_ACCOUNT_JSON, or the Cloud Run/GCE metadata server) as the rest of the ' +
'platform\'s Vertex AI callers.',
);
}
const location = process.env.GOOGLE_CLOUD_LOCATION_GEMINI || 'global';
const host = location === 'global' ? 'aiplatform.googleapis.com' : `${location}-aiplatform.googleapis.com`;
const url = `https://${host}/v1beta1/projects/${project}/locations/${location}/endpoints/openapi/chat/completions`;
const send = (token) =>
fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
model: modelName(),
temperature: cfg.temperature ?? 0.2,
top_p: cfg.topP ?? 0.9,
max_tokens: 8192,
messages: [{ role: 'user', content: prompt }],
}),
});
let res = await send(await vertexToken());
// A token that was valid at mint time can be revoked long before it expires
// (a Workspace reauth policy invalidates outstanding gcloud tokens), and a
// bulk run holds one for hours. Re-mint once on 401/403 rather than letting
// a stale token turn into thousands of English-baked keys.
if (res.status === 401 || res.status === 403) {
res = await send(await vertexToken({ fresh: true }));
if (res.status === 401 || res.status === 403) {
throw configError(
`vertex ${res.status}: credentials are not valid even after re-minting. ` +
'Run `gcloud auth login` (this environment revokes tokens on a reauth policy), ' +
'or pass --provider=gemini with GOOGLE_API_KEY set.',
);
}
}
if (!res.ok)
throw httpError('vertex', res.status, await res.text(), Number(res.headers.get('retry-after')) || 0);
const data = await res.json();
const content = data?.choices?.[0]?.message?.content || '';
if (!content) {
const reason = data?.choices?.[0]?.finish_reason;
throw new Error(`vertex returned no content${reason ? ` (finish_reason: ${reason})` : ''}`);
}
return content;
}
async function callOpenAICompat(prompt, providerName = cfg.provider, modelOverride = null) {
const spec = OPENAI_COMPAT[providerName];
const key = process.env[spec.envKey];
if (!key) throw configError(`${spec.envKey} not set`);
const body = {
model: modelOverride || modelName(),
temperature: cfg.temperature ?? 0.2,
top_p: cfg.topP ?? 0.9,
messages: [{ role: 'user', content: prompt }],
};
if (spec.jsonMode) body.response_format = { type: 'json_object' };
const res = await fetch(spec.url(), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${key}`,
...(spec.extraHeaders || {}),
},
body: JSON.stringify(body),
});
if (!res.ok)
throw httpError(
cfg.provider,
res.status,
await res.text(),
Number(res.headers.get('retry-after')) || 0,
);
const data = await res.json();
return data?.choices?.[0]?.message?.content || '';
}
function backend() {
if (cfg.provider === 'gemini') return callGemini;
if (cfg.provider === 'anthropic') return callAnthropic;
if (cfg.provider === 'vertex') return callVertex;
if (OPENAI_COMPAT[cfg.provider]) return callOpenAICompat;
throw new Error(
`unknown provider: ${cfg.provider} (use gemini, groq, openrouter, nvidia, vertex, openai, or anthropic)`,
);
}
// Ordered backend chain: the configured provider first, then OpenRouter as the
// universal failover so a mid-batch outage of the primary lane (a Vertex token
// hiccup, a free-tier 429 storm) doesn't degrade a whole run to English
// fallback. OpenRouter uses a FUNDED model (no :free) so the failover reliably
// serves — a free-tier model would 402/429 exactly when it is needed. Set
// OPENROUTER_I18N_MODEL to override. Skipped when the primary IS OpenRouter or
// no OpenRouter key is configured.
function backendChain() {
const chain = [{ name: cfg.provider, call: (p) => backend()(p) }];
const orKey = process.env.OPENROUTER_API_KEY?.trim();
if (orKey && cfg.provider !== 'openrouter') {
const orModel = process.env.OPENROUTER_I18N_MODEL?.trim() || 'meta-llama/llama-3.3-70b-instruct';
chain.push({ name: `openrouter(${orModel})`, call: (p) => callOpenAICompat(p, 'openrouter', orModel) });
}
return chain;
}
// Parse the model's reply into an object, tolerating the usual LLM JSON noise:
// code fences (stripped upstream), leading/trailing prose, and a trailing comma.
// Falls back to the largest {...} span when a raw parse fails, so one stray
// character doesn't discard an otherwise-good chunk.
function parseModelJSON(raw) {
const text = stripFences(raw);
try {
return JSON.parse(text);
} catch {
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start === -1 || end <= start) throw new Error('no JSON object in response');
const span = text.slice(start, end + 1).replace(/,\s*([}\]])/g, '$1');
try {
return JSON.parse(span);
} catch {
// JSON5 tolerates single quotes, unquoted keys, and trailing commas that
// small models sometimes emit. It still can't fix a truly broken string
// (an unescaped quote) — that key drops to the per-key split retry.
return JSON5.parse(span);
}
}
}
// Try one backend with the existing retry/backoff. Returns the parsed object or
// throws the last error after exhausting retries for THIS backend.
async function callBackendWithRetry(call, langName, payload, attempt = 0) {
try {
const raw = await call(buildPrompt(langName, payload));
const parsed = parseModelJSON(raw);
if (!parsed || typeof parsed !== 'object') throw new Error('non-object response');
return parsed;
} catch (err) {
// A missing key or project is not transient - retrying it just burns the
// backoff budget on an outcome that cannot change.
if (isFatalAuthFailure(err)) throw err;
// Free tiers rate-limit hard; honor Retry-After and back off more on a 429
// than on a transient parse/5xx error.
const max = err.status === 429 ? 5 : 2;
if (attempt < max) {
const wait =
err.status === 429
? Math.max((err.retryAfter || 0) * 1000, 2000 * (attempt + 1))
: 400 * (attempt + 1);
await new Promise((r) => setTimeout(r, wait));
return callBackendWithRetry(call, langName, payload, attempt + 1);
}
throw err;
}
}
// Translate one chunk, failing over across the backend chain (primary →
// OpenRouter). Each backend gets its full retry budget before the next is tried;
// only when every backend is exhausted does the error propagate (to the caller's
// halve-and-retry / English-fallback logic).
async function translateChunk(langName, payload) {
const chain = backendChain();
let lastErr;
for (const [i, b] of chain.entries()) {
try {
return await callBackendWithRetry(b.call, langName, payload);
} catch (err) {
lastErr = err;
if (i < chain.length - 1) {
console.warn(` ↻ ${b.name} failed (${err.message?.slice(0, 80)}); failing over to ${chain[i + 1].name}`);
}
}
}
throw lastErr;
}
// Bounded-concurrency map.
async function pool(items, limit, worker) {
const results = new Array(items.length);
let i = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
results[idx] = await worker(items[idx], idx);
}
});
await Promise.all(runners);
return results;
}
// --- per-locale translation ------------------------------------------------
const masker = buildMasker(cfg.doNotTranslate);
async function translateLocale(code) {
const langName = cfg.localeNames?.[code] || code;
const existing = readJSON(localePath(code), {}) || {};
const todo = flag('force') ? Object.keys(flatten(source)) : missingKeys(source, existing);
if (!todo.length) {
console.log(`• ${code}: up to date`);
return { code, translated: 0 };
}
if (flag('dry-run')) {
console.log(`• ${code}: would translate ${todo.length} key(s)`);
return { code, translated: 0 };
}
const chunks = chunkKeys(todo, (cfg.splitToken || 1200) * 4);
console.log(
`→ ${code}: ${todo.length} key(s) in ${chunks.length} chunk(s) via ${cfg.provider}`,
);
const translatedFlat = {};
let done = 0;
let failedKeys = 0;
// Translate one set of keys. On a hard failure (a model reply that never
// parses, a persistent 5xx) the set is split in half and each half retried,
// down to a single key. This isolates the one value whose translation the
// model keeps mangling (a stray unescaped quote in Arabic, an empty Korean
// reply) so it can't sink the dozens of good keys chunked alongside it; only
// that lone key falls back to English, and the rest land.
async function translateKeySet(keys) {
const payload = {};
const tokenMap = {};
for (const k of keys) {
const { masked, tokens } = masker.mask(String(getDeep(source, k) ?? ''));
payload[k] = masked;
tokenMap[k] = tokens;
}
let out;
try {
out = await translateChunk(langName, payload);
} catch (err) {
// An unconfigured backend fails identically on every key, so the
// per-key English fallback below would march through the entire
// catalog rewriting it into English and finish "successfully". Fail
// the run instead: nothing already translated is lost (the pipeline
// persists after each chunk) and the operator gets the one line that
// actually tells them what to fix.
if (isFatalAuthFailure(err)) throw err;
if (keys.length > 1) {
const mid = Math.ceil(keys.length / 2);
await translateKeySet(keys.slice(0, mid));
await translateKeySet(keys.slice(mid));
return;
}
// A lone key that fails every retry is one the model genuinely can't
// render as valid JSON (usually a value it mangles into an unescaped
// quote). Bake the English source so the catalog stays complete and
// lint-clean, the runtime shows English (the same graceful fallback an
// empty value would trigger), and re-runs don't loop on it forever.
failedKeys++;
translatedFlat[keys[0]] = getDeep(source, keys[0]);
if (cfg.saveImmediately) persist(code, existing, translatedFlat);
console.warn(` ! ${code} ${keys[0]}: unrenderable after retries (${err.message}); English fallback baked`);
return;
}
for (const k of keys) {
const val = out[k];
if (typeof val !== 'string') {
console.warn(` ! ${code} ${k}: model omitted key, keeping source`);
translatedFlat[k] = getDeep(source, k);
continue;
}
translatedFlat[k] = masker.unmask(val, tokenMap[k]);
}
done += keys.length;
if (cfg.saveImmediately) persist(code, existing, translatedFlat);
console.log(` ${code}: ${done}/${todo.length}`);
}
await pool(chunks, cfg.concurrency || 4, (keys) => translateKeySet(keys));
if (failedKeys) console.warn(` ⚠ ${code}: ${failedKeys} key(s) left as English fallback — re-run to retry`);
persist(code, existing, translatedFlat);
return { code, translated: todo.length };
}
// Merge fresh translations over prior ones, dropping stale keys (mergeOrdered
// only emits keys that exist in the source), and write committed JSON.
function persist(code, existing, translatedFlat) {
const translatedNested = {};
for (const [k, v] of Object.entries(translatedFlat)) setDeep(translatedNested, k, v);
const merged = mergeOrdered(source, existing, translatedNested);
writeFileSync(localePath(code), JSON.stringify(merged, null, '\t') + '\n');
}
// Refresh the runtime manifest the locale switcher reads. Only locales with a
// COMPLETE catalog are listed, so the switcher never offers a language that
// would silently fall back to English.
//
// File existence is not enough. A run that dies partway (expired credentials, an
// exhausted provider) still writes the full key skeleton with empty-string values
// for everything it never reached, so the catalog looks finished by key count and
// then renders as a half-translated page, because the runtime falls back to English
// per key. Listing such a locale is exactly the failure this manifest exists to
// prevent, so an incomplete catalog is skipped and reported rather than shipped.
function writeManifest() {
const skipped = [];
const ready = (code) => {
if (code === cfg.entryLocale) return true;
if (!existsSync(localePath(code))) return false;
let empty = 0;
try {
empty = untranslatedCount(readJSON(localePath(code)));
} catch {
skipped.push(`${code} (unreadable)`);
return false;
}
if (empty) skipped.push(`${code} (${empty} untranslated key${empty === 1 ? '' : 's'})`);
return empty === 0;
};
const localesList = [cfg.entryLocale, ...cfg.outputLocales].filter(ready).map((code) => ({
code,
name: cfg.localeNames?.[code] || code,
dir: (cfg.rtlLocales || []).includes(code) ? 'rtl' : 'ltr',
}));
const manifest = { default: cfg.entryLocale, locales: localesList };
writeFileSync(
resolve(ROOT, cfg.output, 'manifest.json'),
JSON.stringify(manifest, null, '\t') + '\n',
);
if (skipped.length) {
console.log(
`• manifest: ${localesList.length} locale(s) listed, ${skipped.length} held back until complete: ${skipped.join(', ')}`,
);
}
}
// --- repair mode: re-translate only lint-failing keys ----------------------
// The masker replaces glossary terms ("Solana", "x402", …) and {{placeholders}}
// with sentinels the model is meant to reproduce verbatim. A small model
// occasionally omits a sentinel mid-sentence, so the term never gets restored
// on unmask and the key fails lint. That drop is probabilistic, not
// deterministic: re-translating the same key usually preserves the sentinel.
// Repair re-runs only the failing keys, one at a time, retrying until the key
// passes its own integrity check, and bakes English on the rare key that never
// converges (lint-clean, graceful runtime fallback). Idempotent: a clean locale
// reports "nothing to repair".
async function repairLocale(code, maxAttempts = 4) {
const existing = readJSON(localePath(code), {}) || {};
if (!existing || !Object.keys(existing).length) {
console.log(`◦ ${code}: not generated yet (skipped)`);
return { code, repaired: 0, baked: 0 };
}
// Only keys whose current value drops a glossary term, a placeholder, or an
// HTML entity - the three failures a re-translation reliably fixes.
const problems = lintLocale(source, existing, { code, doNotTranslate: cfg.doNotTranslate });
const failingKeys = [
...new Set(
problems
.map(
(p) =>
/(?:glossary term dropped|placeholder drift|markup drift) in ([^:]+):/.exec(
p,
)?.[1],
)
.filter(Boolean),
),
];
if (!failingKeys.length) {
console.log(`• ${code}: nothing to repair`);
return { code, repaired: 0, baked: 0 };
}
const langName = cfg.localeNames?.[code] || code;
console.log(`→ ${code}: repairing ${failingKeys.length} key(s) via ${cfg.provider}`);
const passes = (key, value) => {
const sv = String(getDeep(source, key) ?? '');
if (typeof value !== 'string' || value.trim() === '') return false;
for (const term of cfg.doNotTranslate) {
if (sv.includes(term) && !value.includes(term)) return false;
}
const srcVars = (sv.match(/\{\{[^}]+\}\}/g) || []).sort().join('|');
const tgtVars = (value.match(/\{\{[^}]+\}\}/g) || []).sort().join('|');
if (srcVars !== tgtVars) return false;
return !markupDrift(sv, value);
};
let repaired = 0;
let baked = 0;
for (const key of failingKeys) {
let fixed = null;
for (let attempt = 0; attempt < maxAttempts && fixed === null; attempt++) {
const { masked, tokens } = masker.mask(String(getDeep(source, key) ?? ''));
let out;
try {
out = await translateChunk(langName, { [key]: masked });
} catch {
continue;
}
const candidate =
typeof out[key] === 'string' ? masker.unmask(out[key], tokens) : null;
if (candidate && passes(key, candidate)) fixed = candidate;
}
if (fixed === null) {
// Never converged: bake the English source so the key is lint-clean and
// the runtime shows English (the same graceful fallback an empty value
// triggers). Rare — brand terms preserved on the very next attempt.
fixed = getDeep(source, key);
baked++;
console.warn(` ! ${code} ${key}: baked English after ${maxAttempts} attempts`);
} else {
repaired++;
}
setDeep(existing, key, fixed);
writeFileSync(localePath(code), JSON.stringify(existing, null, '\t') + '\n');
}
console.log(` ${code}: repaired ${repaired}, baked ${baked}`);
return { code, repaired, baked };
}
async function main() {
if (flag('lint')) return runLint();
if (flag('repair')) {
writeManifest();
const results = await pool(targets, 1, (c) => repairLocale(c));
const r = results.reduce((s, x) => s + (x?.repaired || 0), 0);
const b = results.reduce((s, x) => s + (x?.baked || 0), 0);
console.log(`\ni18n-repair: ${r} key(s) repaired, ${b} baked across ${targets.length} locale(s).`);
return;
}
writeManifest();
const results = await pool(targets, 1, translateLocale); // locales sequential; chunks parallel within
const n = results.reduce((s, r) => s + (r?.translated || 0), 0);
console.log(`\ni18n-translate: ${n} key(s) translated across ${targets.length} locale(s).`);
}
main().catch((err) => {
console.error(err.message || err);
process.exit(1);
});