docs-i18n 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.
@@ -0,0 +1,707 @@
1
+ import "./chunk-QSVWLTGQ.js";
2
+ import {
3
+ FRONTMATTER_TRANSLATABLE_FIELDS,
4
+ init_parser
5
+ } from "./chunk-SUIDX6IZ.js";
6
+ import {
7
+ TranslationCache
8
+ } from "./chunk-XEOYZUHS.js";
9
+ import {
10
+ flattenSources
11
+ } from "./chunk-3YNFMSJH.js";
12
+ import {
13
+ __esm,
14
+ __export,
15
+ __toCommonJS
16
+ } from "./chunk-AKLW2MUS.js";
17
+
18
+ // src/core/frontmatter.ts
19
+ var frontmatter_exports = {};
20
+ __export(frontmatter_exports, {
21
+ extractTranslatableFields: () => extractTranslatableFields,
22
+ reconstructFrontmatter: () => reconstructFrontmatter
23
+ });
24
+ import { parseDocument } from "yaml";
25
+ function getNestedValue(doc, path2) {
26
+ const parts = path2.split(".");
27
+ let current = doc;
28
+ for (const part of parts) {
29
+ if (current == null) return void 0;
30
+ if (typeof current.get === "function") {
31
+ current = current.get(part);
32
+ } else if (typeof current === "object") {
33
+ current = current[part];
34
+ } else {
35
+ return void 0;
36
+ }
37
+ }
38
+ return current;
39
+ }
40
+ function setNestedValue(doc, path2, value) {
41
+ const parts = path2.split(".");
42
+ if (parts.length === 1) {
43
+ if (doc.has(parts[0])) doc.set(parts[0], value);
44
+ return;
45
+ }
46
+ let current = doc;
47
+ for (let i = 0; i < parts.length - 1; i++) {
48
+ if (typeof current.get === "function") {
49
+ current = current.get(parts[i]);
50
+ } else {
51
+ return;
52
+ }
53
+ }
54
+ const lastKey = parts[parts.length - 1];
55
+ if (typeof current.set === "function") {
56
+ current.set(lastKey, value);
57
+ }
58
+ }
59
+ function extractTranslatableFields(frontmatterText) {
60
+ const yaml = frontmatterText.replace(/^---\n/, "").replace(/\n---$/, "");
61
+ const doc = parseDocument(yaml);
62
+ const fields = {};
63
+ for (const field of FRONTMATTER_TRANSLATABLE_FIELDS) {
64
+ const value = getNestedValue(doc, field);
65
+ if (typeof value === "string" && value.trim()) {
66
+ fields[field] = value;
67
+ }
68
+ }
69
+ return fields;
70
+ }
71
+ function reconstructFrontmatter(frontmatterText, translatedFields) {
72
+ const yaml = frontmatterText.replace(/^---\n/, "").replace(/\n---$/, "");
73
+ const doc = parseDocument(yaml);
74
+ for (const [field, value] of Object.entries(translatedFields)) {
75
+ setNestedValue(doc, field, value);
76
+ }
77
+ return `---
78
+ ${doc.toString().trimEnd()}
79
+ ---`;
80
+ }
81
+ var init_frontmatter = __esm({
82
+ "src/core/frontmatter.ts"() {
83
+ "use strict";
84
+ init_parser();
85
+ }
86
+ });
87
+
88
+ // src/commands/translate.ts
89
+ import { resolve } from "path";
90
+
91
+ // src/core/translator.ts
92
+ import OpenAI from "openai";
93
+ var DEFAULT_MAX_TOKENS = 16384;
94
+ var MAX_RETRIES = 3;
95
+ var INITIAL_BACKOFF_MS = 2e3;
96
+ function stripThinkingBlock(text) {
97
+ return text.replace(/<think>[\s\S]*?<\/think>\s*/g, "").trim();
98
+ }
99
+ function sleep(ms) {
100
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
101
+ }
102
+ function extractJson(response) {
103
+ let raw = stripThinkingBlock(response);
104
+ raw = raw.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "");
105
+ const start = raw.indexOf("{");
106
+ const end = raw.lastIndexOf("}");
107
+ if (start === -1 || end === -1 || end <= start) {
108
+ return raw.trim();
109
+ }
110
+ return raw.substring(start, end + 1);
111
+ }
112
+ function repairJson(raw) {
113
+ let result = "";
114
+ let inString = false;
115
+ let escaped = false;
116
+ for (let i = 0; i < raw.length; i++) {
117
+ const ch = raw[i];
118
+ if (escaped) {
119
+ result += ch;
120
+ escaped = false;
121
+ continue;
122
+ }
123
+ if (ch === "\\") {
124
+ result += ch;
125
+ escaped = true;
126
+ continue;
127
+ }
128
+ if (ch === '"') {
129
+ inString = !inString;
130
+ result += ch;
131
+ continue;
132
+ }
133
+ if (inString && ch === "\n") {
134
+ result += "\\n";
135
+ continue;
136
+ }
137
+ if (inString && ch === "\r") {
138
+ continue;
139
+ }
140
+ if (inString && ch === " ") {
141
+ result += "\\t";
142
+ continue;
143
+ }
144
+ result += ch;
145
+ }
146
+ result = result.replace(/,\s*}/g, "}");
147
+ const trimmed = result.trim();
148
+ if (trimmed.startsWith("{") && !trimmed.endsWith("}")) {
149
+ const lastQuote = result.lastIndexOf('"');
150
+ if (lastQuote > 0) {
151
+ result = `${result.substring(0, lastQuote + 1)}}`;
152
+ }
153
+ }
154
+ return result;
155
+ }
156
+ var deadModels = /* @__PURE__ */ new Set();
157
+ var rateLimitedModels = /* @__PURE__ */ new Set();
158
+ function pickModel(opts) {
159
+ if (opts.modelRotate && opts.modelRotate.length > 0) {
160
+ const alive = opts.modelRotate.filter((m) => !deadModels.has(m));
161
+ if (alive.length === 0) {
162
+ deadModels.clear();
163
+ return opts.modelRotate[0];
164
+ }
165
+ const preferred = alive.filter((m) => !rateLimitedModels.has(m));
166
+ if (preferred.length > 0) return preferred[0];
167
+ rateLimitedModels.clear();
168
+ return alive[0];
169
+ }
170
+ return opts.model;
171
+ }
172
+ function isModelError(msg) {
173
+ return msg.includes("400") && msg.includes("not a valid") || msg.includes("404") || msg.includes("No endpoints found") || msg.includes("Empty response from API") || msg.includes("guardrail restrictions");
174
+ }
175
+ function resolveApiConfig(opts) {
176
+ const apiType = opts.apiType ?? "openrouter";
177
+ if (apiType === "openrouter") {
178
+ const apiKey2 = opts.apiKey ?? process.env.OPENROUTER_API_KEY ?? "";
179
+ if (!apiKey2) {
180
+ throw new Error(
181
+ "No API key found. Set OPENROUTER_API_KEY in .env or pass --api-key."
182
+ );
183
+ }
184
+ return {
185
+ baseURL: opts.apiBaseUrl ?? "https://openrouter.ai/api/v1",
186
+ apiKey: apiKey2,
187
+ model: pickModel(opts) ?? process.env.OPENROUTER_MODEL ?? "deepseek/deepseek-chat-v3-0324:free"
188
+ };
189
+ }
190
+ if (apiType === "openai") {
191
+ const apiKey2 = opts.apiKey ?? process.env.OPENAI_API_KEY ?? "";
192
+ if (!apiKey2) {
193
+ throw new Error(
194
+ "No API key found. Set OPENAI_API_KEY or pass --api-key."
195
+ );
196
+ }
197
+ return {
198
+ baseURL: opts.apiBaseUrl ?? "https://api.deepseek.com",
199
+ apiKey: apiKey2,
200
+ model: pickModel(opts) ?? "deepseek-chat"
201
+ };
202
+ }
203
+ const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "";
204
+ if (!apiKey) {
205
+ throw new Error(
206
+ "No API key found. Set ANTHROPIC_API_KEY or pass --api-key."
207
+ );
208
+ }
209
+ return {
210
+ baseURL: opts.apiBaseUrl ?? "https://api.anthropic.com/v1",
211
+ apiKey,
212
+ model: pickModel(opts) ?? "claude-sonnet-4-20250514"
213
+ };
214
+ }
215
+ function buildJsonPrompt(opts) {
216
+ let prompt = `You are a professional technical translator specializing in software documentation.
217
+
218
+ TASK: Translate the provided nodes to ${opts.langName} and return as JSON.
219
+
220
+ INPUT: A JSON object with a "nodes" array. Each node has:
221
+ - "key": unique identifier (use as key in your response)
222
+ - "type": one of heading, paragraph, list, blockquote, html
223
+ - "text": the content to translate
224
+
225
+ OUTPUT: A single valid JSON object mapping each key to its translation. No other text.
226
+
227
+ RULES:
228
+ 1. Return ONLY valid JSON. No markdown fences, no explanation.
229
+ 2. Escape newlines as \\n in JSON strings. Output must be parseable JSON.
230
+ 3. Translate EVERY node. Do not skip any key.
231
+ 4. Preserve Markdown: heading levels (##), links [text](url), inline \`code\`, **bold**, *italic*.
232
+ 5. Keep code blocks, file paths, URLs, variable names, component names unchanged.
233
+ 6. Keep HTML/JSX tags balanced: <AppOnly></AppOnly>, <PagesOnly></PagesOnly>.
234
+ 7. CRITICAL: Inline code wrapped in backticks (\`...\`) MUST keep the backticks. Example: \`<Link>\` must stay as \`<Link>\`, NOT become bare <Link>. Bare HTML tags will BREAK the document.
235
+
236
+ TYPE-SPECIFIC RULES:
237
+ - "heading": Keep ## or ### prefix exactly as original.
238
+ - "list": Keep - or 1. markers and indentation.
239
+ - "blockquote": Keep > prefix.
240
+ - "html": Translate human text only. Keep all tags/attributes unchanged.
241
+
242
+ EXAMPLE:
243
+ Input: {"nodes":[{"key":"b2","type":"heading","text":"## Installation"},{"key":"c3","type":"paragraph","text":"Run the following command:"}]}
244
+ Output: {"b2":"## \u5B89\u88C5","c3":"\u8FD0\u884C\u4EE5\u4E0B\u547D\u4EE4\uFF1A"}`;
245
+ if (opts.docsContext) {
246
+ prompt += `
247
+
248
+ CONTEXT:
249
+ ${opts.docsContext}`;
250
+ }
251
+ if (opts.guide) {
252
+ prompt += `
253
+
254
+ GUIDELINES:
255
+ ${opts.guide}`;
256
+ }
257
+ return prompt;
258
+ }
259
+ function buildJsonUserMessage(uncached, nodeTypes) {
260
+ const { extractTranslatableFields: extractTranslatableFields2 } = (init_frontmatter(), __toCommonJS(frontmatter_exports));
261
+ const nodes = [];
262
+ const frontmatterMap = /* @__PURE__ */ new Map();
263
+ for (const [md5, text] of Object.entries(uncached)) {
264
+ const type = nodeTypes[md5] ?? "paragraph";
265
+ if (type === "frontmatter") {
266
+ const fields = extractTranslatableFields2(text);
267
+ if (Object.keys(fields).length === 0) continue;
268
+ const fieldKeys = {};
269
+ for (const [field, value] of Object.entries(fields)) {
270
+ const virtualKey = `fm:${md5}:${field}`;
271
+ fieldKeys[field] = virtualKey;
272
+ nodes.push({ key: virtualKey, type: "paragraph", text: value });
273
+ }
274
+ frontmatterMap.set(md5, { source: text, fieldKeys });
275
+ } else {
276
+ nodes.push({ key: md5, type, text });
277
+ }
278
+ }
279
+ return { message: JSON.stringify({ nodes }), frontmatterMap };
280
+ }
281
+ async function translateJsonChunk(opts) {
282
+ const systemPrompt = buildJsonPrompt({
283
+ langName: opts.langName,
284
+ guide: opts.guide,
285
+ docsContext: opts.docsContext
286
+ });
287
+ const { message: userMessage, frontmatterMap } = buildJsonUserMessage(
288
+ opts.uncached,
289
+ opts.nodeTypes
290
+ );
291
+ const maxTokens = opts.maxTokens ?? DEFAULT_MAX_TOKENS;
292
+ const log = opts.logger ?? console.warn;
293
+ let lastError;
294
+ const requestedMd5s = (() => {
295
+ const keys = [];
296
+ for (const md5 of Object.keys(opts.uncached)) {
297
+ if (frontmatterMap.has(md5)) {
298
+ const info = frontmatterMap.get(md5);
299
+ keys.push(...Object.values(info.fieldKeys));
300
+ } else {
301
+ keys.push(md5);
302
+ }
303
+ }
304
+ return keys;
305
+ })();
306
+ log(
307
+ `\u{1F4E4} SYSTEM PROMPT (${systemPrompt.length} chars):
308
+ ${systemPrompt}
309
+ --- END SYSTEM PROMPT ---`
310
+ );
311
+ log(
312
+ `\u{1F4E4} USER MESSAGE (${userMessage.length} chars):
313
+ ${userMessage}
314
+ --- END USER MESSAGE ---`
315
+ );
316
+ const maxAttempts = opts.modelRotate && opts.modelRotate.length > 0 ? MAX_RETRIES + opts.modelRotate.length : MAX_RETRIES;
317
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
318
+ const { baseURL, apiKey, model } = resolveApiConfig(opts);
319
+ const client = new OpenAI({ baseURL, apiKey });
320
+ const modelCap = opts.modelMaxTokens?.get(model);
321
+ const effectiveMaxTokens = modelCap ? Math.min(maxTokens, modelCap) : maxTokens;
322
+ log(
323
+ `\u{1F527} attempt=${attempt}/${maxAttempts} model=${model} keys=${requestedMd5s.length} max_tokens=${effectiveMaxTokens}`
324
+ );
325
+ try {
326
+ const t0 = Date.now();
327
+ const params = {
328
+ model,
329
+ messages: [
330
+ { role: "system", content: systemPrompt },
331
+ { role: "user", content: userMessage }
332
+ ],
333
+ max_tokens: effectiveMaxTokens,
334
+ reasoning: { exclude: true }
335
+ };
336
+ if (!opts.noSchema) {
337
+ params.response_format = {
338
+ type: "json_schema",
339
+ json_schema: {
340
+ name: "translation_result",
341
+ strict: false,
342
+ schema: {
343
+ type: "object",
344
+ properties: Object.fromEntries(
345
+ requestedMd5s.map((k) => [k, { type: "string" }])
346
+ ),
347
+ required: requestedMd5s,
348
+ additionalProperties: false
349
+ }
350
+ }
351
+ };
352
+ params.provider = { require_parameters: true };
353
+ }
354
+ const response = await client.chat.completions.create(params);
355
+ const elapsed = Date.now() - t0;
356
+ const choice = response.choices?.[0];
357
+ const usage = response.usage;
358
+ log(
359
+ `\u{1F4E5} response in ${(elapsed / 1e3).toFixed(1)}s | finish=${choice?.finish_reason} | content=${choice?.message?.content?.length ?? 0} chars` + (usage ? ` | tokens: in=${usage.prompt_tokens} out=${usage.completion_tokens} total=${usage.total_tokens}` : "")
360
+ );
361
+ if (choice?.message?.content) {
362
+ log(
363
+ `\u{1F4E5} RESPONSE BODY:
364
+ ${choice.message.content}
365
+ --- END RESPONSE BODY ---`
366
+ );
367
+ }
368
+ if (!choice?.message?.content) {
369
+ throw new Error("Empty response from API");
370
+ }
371
+ if (choice.finish_reason === "length") {
372
+ throw new Error(
373
+ `Output truncated (finish_reason=length). Model hit max_tokens=${maxTokens}.`
374
+ );
375
+ }
376
+ const raw = extractJson(choice.message.content);
377
+ let parsed;
378
+ try {
379
+ parsed = JSON.parse(raw);
380
+ } catch {
381
+ try {
382
+ parsed = JSON.parse(repairJson(raw));
383
+ } catch {
384
+ throw new Error(`Failed to parse JSON: ${raw.substring(0, 200)}...`);
385
+ }
386
+ }
387
+ if (Object.keys(parsed).length === 1 && typeof Object.values(parsed)[0] === "object") {
388
+ const inner = Object.values(parsed)[0];
389
+ log("\u{1F504} Unwrapping nested JSON object");
390
+ parsed = inner;
391
+ }
392
+ const returnedMd5s = new Set(Object.keys(parsed));
393
+ const missing = requestedMd5s.filter((md5) => !returnedMd5s.has(md5));
394
+ const extra = [...returnedMd5s].filter(
395
+ (md5) => !requestedMd5s.includes(md5)
396
+ );
397
+ if (missing.length > 0) {
398
+ log(`\u26A0\uFE0F JSON missing ${missing.length}/${requestedMd5s.length} keys`);
399
+ }
400
+ if (extra.length > 0) {
401
+ log(`\u26A0\uFE0F JSON has ${extra.length} extra keys`);
402
+ if (missing.length > requestedMd5s.length * 0.5) {
403
+ log(" Extra key samples:");
404
+ for (const k of extra.slice(0, 3)) {
405
+ log(
406
+ ` ${k.substring(0, 16)}\u2026 = ${String(parsed[k]).substring(0, 60).replace(/\n/g, "\u21B5")}`
407
+ );
408
+ }
409
+ log(" Expected key samples:");
410
+ for (const k of requestedMd5s.slice(0, 3)) {
411
+ log(` ${k.substring(0, 16)}\u2026`);
412
+ }
413
+ }
414
+ }
415
+ if (extra.length > 0 && missing.length > 0) {
416
+ for (const extraKey of extra) {
417
+ let bestMatch = null;
418
+ let bestDist = Number.POSITIVE_INFINITY;
419
+ for (const missKey of missing) {
420
+ let diff = Math.abs(extraKey.length - missKey.length);
421
+ const minLen = Math.min(extraKey.length, missKey.length);
422
+ for (let i = 0; i < minLen; i++) {
423
+ if (extraKey[i] !== missKey[i]) diff++;
424
+ }
425
+ if (diff < bestDist) {
426
+ bestDist = diff;
427
+ bestMatch = missKey;
428
+ }
429
+ }
430
+ if (bestMatch && bestDist <= 3) {
431
+ log(
432
+ `\u{1F527} Recovered key: ${extraKey} \u2192 ${bestMatch} (${bestDist} char diff)`
433
+ );
434
+ parsed[bestMatch] = parsed[extraKey];
435
+ missing.splice(missing.indexOf(bestMatch), 1);
436
+ }
437
+ }
438
+ }
439
+ const values = Object.values(parsed);
440
+ if (values.length > 3) {
441
+ const freq = /* @__PURE__ */ new Map();
442
+ for (const v of values) freq.set(v, (freq.get(v) ?? 0) + 1);
443
+ const maxFreq = Math.max(...freq.values());
444
+ if (maxFreq > values.length * 0.5) {
445
+ const dupVal = [...freq.entries()].find(
446
+ ([, c]) => c === maxFreq
447
+ )?.[0];
448
+ const msg = `Model returned identical value for ${maxFreq}/${values.length} keys: "${dupVal?.substring(0, 60)}..."`;
449
+ log(`\u{1F5D1}\uFE0F ${msg}`);
450
+ if (opts.modelRotate?.length) {
451
+ deadModels.add(model);
452
+ log(`\u{1F480} Model ${model} produces garbage, skipping`);
453
+ continue;
454
+ }
455
+ throw new Error(msg);
456
+ }
457
+ }
458
+ const translations = {};
459
+ for (const md5 of requestedMd5s) {
460
+ if (parsed[md5]) {
461
+ translations[md5] = parsed[md5];
462
+ }
463
+ }
464
+ if (frontmatterMap.size > 0) {
465
+ const { reconstructFrontmatter: reconstructFrontmatter2 } = (init_frontmatter(), __toCommonJS(frontmatter_exports));
466
+ for (const [fmMd5, info] of frontmatterMap) {
467
+ const fields = {};
468
+ let allFound = true;
469
+ for (const [field, vKey] of Object.entries(info.fieldKeys)) {
470
+ if (translations[vKey]) {
471
+ fields[field] = translations[vKey];
472
+ delete translations[vKey];
473
+ } else {
474
+ allFound = false;
475
+ }
476
+ }
477
+ if (Object.keys(fields).length > 0) {
478
+ translations[fmMd5] = reconstructFrontmatter2(info.source, fields);
479
+ log(
480
+ `\u2705 Frontmatter ${fmMd5.substring(0, 8)}: ${Object.keys(fields).join(", ")}`
481
+ );
482
+ }
483
+ if (!allFound) {
484
+ const vKeys = new Set(Object.values(info.fieldKeys));
485
+ const idx = missing.findIndex((m) => vKeys.has(m));
486
+ while (idx >= 0) {
487
+ missing.splice(idx, 1);
488
+ }
489
+ }
490
+ }
491
+ }
492
+ return { translations, missing, extra };
493
+ } catch (err) {
494
+ lastError = err instanceof Error ? err : new Error(String(err));
495
+ const msg = lastError.message;
496
+ if (isModelError(msg) && opts.modelRotate?.length) {
497
+ deadModels.add(model);
498
+ log(`\u{1F480} Model ${model} is dead (${msg.substring(0, 80)}), skipping`);
499
+ continue;
500
+ }
501
+ if (msg.includes("429") && opts.modelRotate?.length) {
502
+ rateLimitedModels.add(model);
503
+ const alive = opts.modelRotate.filter(
504
+ (m) => !deadModels.has(m) && !rateLimitedModels.has(m)
505
+ );
506
+ if (alive.length === 0) {
507
+ const waitSec = 30;
508
+ log(
509
+ `\u23F3 All models rate-limited. Waiting ${waitSec}s before retry...`
510
+ );
511
+ await sleep(waitSec * 1e3);
512
+ rateLimitedModels.clear();
513
+ } else {
514
+ log(`\u23F3 Model ${model} rate-limited, trying next model`);
515
+ }
516
+ continue;
517
+ }
518
+ const isRetryable = msg.includes("429") || msg.includes("503") || msg.includes("405") || msg.includes("timeout") || msg.includes("ECONNRESET") || msg.includes("truncated") || msg.includes("Empty response") || msg.includes("Failed to parse JSON");
519
+ if (!isRetryable || attempt === maxAttempts) {
520
+ throw lastError;
521
+ }
522
+ const backoff = INITIAL_BACKOFF_MS * 2 ** (attempt - 1);
523
+ log(
524
+ `\u26A0\uFE0F Attempt ${attempt}/${maxAttempts} failed: ${msg}. Retrying in ${backoff / 1e3}s...`
525
+ );
526
+ await sleep(backoff);
527
+ }
528
+ }
529
+ throw lastError ?? new Error("Translation failed after retries");
530
+ }
531
+
532
+ // src/core/ui.ts
533
+ import fs from "fs";
534
+ import path from "path";
535
+ var FileLogger = class {
536
+ logDir;
537
+ streams = /* @__PURE__ */ new Map();
538
+ constructor(baseDir, lang) {
539
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
540
+ this.logDir = path.join(baseDir, `${ts}-translate-${lang}`);
541
+ fs.mkdirSync(this.logDir, { recursive: true });
542
+ this.log("_run", `Started at ${(/* @__PURE__ */ new Date()).toISOString()}`);
543
+ this.log("_run", `Language: ${lang}`);
544
+ }
545
+ getLogDir() {
546
+ return this.logDir;
547
+ }
548
+ log(file, message) {
549
+ const safeName = file.replace(/\//g, "__");
550
+ let stream = this.streams.get(safeName);
551
+ if (!stream) {
552
+ const logPath = path.join(this.logDir, `${safeName}.log`);
553
+ stream = fs.createWriteStream(logPath, { flags: "a" });
554
+ this.streams.set(safeName, stream);
555
+ }
556
+ const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
557
+ stream.write(`[${ts}] ${message}
558
+ `);
559
+ }
560
+ close() {
561
+ for (const stream of this.streams.values()) {
562
+ stream.end();
563
+ }
564
+ this.streams.clear();
565
+ }
566
+ };
567
+
568
+ // src/commands/translate.ts
569
+ var OUTPUT_MULTIPLIER = {
570
+ ja: 2.5,
571
+ ko: 2.5,
572
+ hi: 2.5,
573
+ th: 2.5,
574
+ ru: 2,
575
+ ar: 2,
576
+ uk: 2,
577
+ he: 2,
578
+ "zh-hans": 1.5,
579
+ "zh-hant": 1.5,
580
+ de: 1.3,
581
+ fr: 1.3,
582
+ pt: 1.3,
583
+ es: 1.2,
584
+ vi: 1.2
585
+ };
586
+ async function translate(config, opts) {
587
+ const lang = opts.lang;
588
+ const cacheDir = resolve(config.cacheDir ?? ".cache");
589
+ const cache = new TranslationCache(cacheDir);
590
+ cache.load(lang);
591
+ const sources = flattenSources(config);
592
+ const llm = config.llm ?? {};
593
+ const maxTokens = opts.maxTokens ?? llm.maxTokens ?? 16384;
594
+ const contextLength = opts.contextLength ?? llm.contextLength ?? 32768;
595
+ const model = opts.model ?? llm.model ?? "";
596
+ const apiType = llm.provider ?? "openrouter";
597
+ const docsContext = config.context ?? "";
598
+ for (const source of sources) {
599
+ if (opts.project && source.project !== opts.project) continue;
600
+ if (opts.version && source.version !== opts.version) continue;
601
+ console.log(`
602
+ \u{1F4E6} Translating ${source.versionKey} \u2192 ${lang}`);
603
+ console.log(` Model: ${model || "default"}`);
604
+ const fileFilter = opts.files?.length ? opts.files : void 0;
605
+ if (fileFilter) {
606
+ console.log(` Files: ${fileFilter.length} selected`);
607
+ }
608
+ const untranslated = cache.untranslatedKeys(lang, source.versionKey, 0, fileFilter);
609
+ console.log(` ${untranslated.length} untranslated keys`);
610
+ if (untranslated.length === 0) {
611
+ console.log(" \u2705 All translated.");
612
+ continue;
613
+ }
614
+ const outputMultiplier = OUTPUT_MULTIPLIER[lang] ?? 2;
615
+ const SYSTEM_PROMPT_TOKENS = 700;
616
+ const JSON_OVERHEAD = 200;
617
+ const maxCompletionTokens = Math.min(maxTokens, contextLength - 4096);
618
+ const inputBudget = Math.floor(
619
+ (contextLength - maxCompletionTokens - SYSTEM_PROMPT_TOKENS - JSON_OVERHEAD) * 0.85
620
+ );
621
+ const outputBudget = Math.floor(maxCompletionTokens * 0.75);
622
+ const chunks = [[]];
623
+ let curIn = SYSTEM_PROMPT_TOKENS;
624
+ let curOut = JSON_OVERHEAD;
625
+ for (const k of untranslated) {
626
+ const inTok = Math.ceil(k.text.length / 4 + 80);
627
+ const outTok = Math.ceil(k.text.length * outputMultiplier / 4 + 80);
628
+ if ((curIn + inTok > inputBudget || curOut + outTok > outputBudget) && chunks[chunks.length - 1].length > 0) {
629
+ chunks.push([]);
630
+ curIn = SYSTEM_PROMPT_TOKENS;
631
+ curOut = JSON_OVERHEAD;
632
+ }
633
+ chunks[chunks.length - 1].push(k);
634
+ curIn += inTok;
635
+ curOut += outTok;
636
+ }
637
+ const maxChunks = Math.min(chunks.length, opts.max ?? 999);
638
+ const totalKeys = chunks.slice(0, maxChunks).reduce((s, c) => s + c.length, 0);
639
+ console.log(` ${chunks.length} chunks, ${maxChunks} to process (${totalKeys} keys)`);
640
+ if (opts.dryRun) {
641
+ console.log(" \u23ED\uFE0F Dry run \u2014 skipping API calls");
642
+ continue;
643
+ }
644
+ const logDir = resolve(".logs");
645
+ const fileLogger = new FileLogger(logDir, lang);
646
+ const concurrency = opts.concurrency ?? 3;
647
+ const startTime = Date.now();
648
+ let totalCached = 0;
649
+ let totalMissing = 0;
650
+ let chunkErrors = 0;
651
+ async function processChunk(i) {
652
+ const chunk = chunks[i];
653
+ const uncached = {};
654
+ const nodeTypes = {};
655
+ for (const k of chunk) {
656
+ uncached[k.key] = k.text;
657
+ nodeTypes[k.key] = k.type;
658
+ }
659
+ const chunkLog = `_md5-chunk-${String(i + 1).padStart(3, "0")}`;
660
+ const clog = (msg) => fileLogger.log(chunkLog, msg);
661
+ const label = `chunk ${i + 1}/${maxChunks} (${chunk.length} keys)`;
662
+ console.log(`\u23F3 ${label}...`);
663
+ try {
664
+ const result = await translateJsonChunk({
665
+ assembledContent: "",
666
+ uncached,
667
+ nodeTypes,
668
+ langName: lang,
669
+ guide: "",
670
+ apiType,
671
+ model: model || void 0,
672
+ maxTokens,
673
+ filePath: chunkLog,
674
+ logger: clog,
675
+ docsContext
676
+ });
677
+ let cached = 0;
678
+ for (const [key, translation] of Object.entries(result.translations)) {
679
+ if (typeof translation !== "string" || !translation.trim()) continue;
680
+ cache.set(lang, key, translation);
681
+ cached++;
682
+ }
683
+ totalCached += cached;
684
+ totalMissing += result.missing.length;
685
+ console.log(`\u2705 ${label} \u2014 +${cached} cached, ${result.missing.length} missing`);
686
+ } catch (err) {
687
+ chunkErrors++;
688
+ console.error(`\u274C ${label} \u2014 ${err instanceof Error ? err.message.substring(0, 100) : err}`);
689
+ }
690
+ }
691
+ const running = /* @__PURE__ */ new Set();
692
+ for (let i = 0; i < maxChunks; i++) {
693
+ const p = processChunk(i).finally(() => running.delete(p));
694
+ running.add(p);
695
+ if (running.size >= concurrency) await Promise.race(running);
696
+ }
697
+ await Promise.all(running);
698
+ const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
699
+ console.log(`
700
+ \u2705 ${source.versionKey}/${lang}: +${totalCached} cached, ${totalMissing} missing, ${chunkErrors} errors (${elapsed}s)`);
701
+ fileLogger.close();
702
+ }
703
+ cache.db.close();
704
+ }
705
+ export {
706
+ translate
707
+ };