champollion 0.3.3
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 +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- package/shared/schemas/source-snapshot.schema.json +96 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* docusaurus-sync.js — Docusaurus-specific sync operation
|
|
3
|
+
*
|
|
4
|
+
* Extracted from sync.js to reduce god-module complexity.
|
|
5
|
+
* Handles directory-per-locale JSON + Markdown translation for
|
|
6
|
+
* Docusaurus projects. Two phases:
|
|
7
|
+
*
|
|
8
|
+
* Phase 1: JSON UI strings — {message, description} files in i18n/{locale}/
|
|
9
|
+
* Phase 2: Markdown content — docs/ and blog/ mirrored into i18n/{locale}/
|
|
10
|
+
*
|
|
11
|
+
* Reuses the shared translation pipeline (lib/translate-pair.js) for
|
|
12
|
+
* the TM→API→gate sequence, and Docusaurus-specific format helpers
|
|
13
|
+
* (extractDocusaurusMessages, injectDocusaurusMessages) for round-trip.
|
|
14
|
+
* Does NOT modify or share state with the main runSync() path.
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import { translateBatch, isUnsafeKey } from './translate.js';
|
|
20
|
+
import { diffLocale, diffLabel } from './diff.js';
|
|
21
|
+
import { isPathContained } from './security.js';
|
|
22
|
+
import {
|
|
23
|
+
extractDocusaurusMessages, injectDocusaurusMessages,
|
|
24
|
+
extractDocusaurusDescriptions,
|
|
25
|
+
} from './format.js';
|
|
26
|
+
import {
|
|
27
|
+
parseContentFile, protectBlocks, restoreBlocks, hasOrphanedPlaceholders,
|
|
28
|
+
buildContentPrompt, reassembleContentFile, DEFAULT_TRANSLATABLE_FIELDS,
|
|
29
|
+
discoverDocusaurusContentFiles, getDocusaurusTargetPath,
|
|
30
|
+
findUntranslatableNestedFields,
|
|
31
|
+
} from './content.js';
|
|
32
|
+
import { translateRawContent } from './translate.js';
|
|
33
|
+
import {
|
|
34
|
+
splitBlocks, buildBlockBatchPrompt, parseBlockBatchResponse,
|
|
35
|
+
translateBlockBatchResilient,
|
|
36
|
+
assertSegmentationMode,
|
|
37
|
+
} from './segment.js';
|
|
38
|
+
import { DEFAULT_REGISTERS } from './registers.js';
|
|
39
|
+
import { DEFAULT_JSON_CONCURRENCY, EST_CHARS_PER_KEY } from './config.js';
|
|
40
|
+
import { compileNoTranslate } from './no-translate.js';
|
|
41
|
+
import { checkContentPreservation } from './validate.js';
|
|
42
|
+
import { convertScript, applyScriptFallback } from './scripts.js';
|
|
43
|
+
import { pMap } from './concurrent.js';
|
|
44
|
+
import {
|
|
45
|
+
loadTM, saveTM, tmSize, isTMDirty,
|
|
46
|
+
lookupTM, lookupTMValidated, storeTM, evictTM, partitionByTM, tmMethodKey,
|
|
47
|
+
} from './tm.js';
|
|
48
|
+
import {
|
|
49
|
+
readManifest, writeManifest, detectChangedKeys, hashValue,
|
|
50
|
+
} from './hash.js';
|
|
51
|
+
import { CONTENT_LOCK_FILENAME } from './content-sync.js';
|
|
52
|
+
import { parseMaxCost, abortForMaxCost, printCostTable } from './cost-report.js';
|
|
53
|
+
import { output } from './output.js';
|
|
54
|
+
import { translateAndValidate } from './translate-pair.js';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Discover all JSON locale files in a Docusaurus i18n source directory.
|
|
58
|
+
*
|
|
59
|
+
* Walks the source locale directory recursively and returns all .json files.
|
|
60
|
+
* These include code.json and plugin-specific files in subdirectories.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} sourceLocaleDir - e.g., /project/i18n/en
|
|
63
|
+
* @returns {string[]} Absolute paths to JSON files
|
|
64
|
+
*/
|
|
65
|
+
function discoverDocusaurusJSONFiles(sourceLocaleDir) {
|
|
66
|
+
const files = [];
|
|
67
|
+
function walk(dir) {
|
|
68
|
+
if (!fs.existsSync(dir)) return;
|
|
69
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
70
|
+
const fullPath = path.join(dir, entry.name);
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
walk(fullPath);
|
|
73
|
+
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
74
|
+
files.push(fullPath);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
walk(sourceLocaleDir);
|
|
79
|
+
return files.sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Scan docs/ + blog/ for pending (file × locale) content translations.
|
|
84
|
+
*
|
|
85
|
+
* Single source of truth for "what content work exists": the cost estimator
|
|
86
|
+
* and the Phase-2 translation loop both consume this, so the estimate can
|
|
87
|
+
* never drift from what the sync actually does. Read-only — hand-translated
|
|
88
|
+
* files it discovers are reported via recordedHashes for the CALLER to fold
|
|
89
|
+
* into its manifest; nothing is written here.
|
|
90
|
+
*
|
|
91
|
+
* Work items carry the parsed source (body, front-matter fields, page
|
|
92
|
+
* title) so the estimator can TM-partition them and the translation loop
|
|
93
|
+
* never re-parses.
|
|
94
|
+
*
|
|
95
|
+
* @param {Array<{dir: string, plugin: string}>} contentSources - docs/blog dirs
|
|
96
|
+
* @param {Array<[string, object]>} pairEntries - Resolved pair graph entries
|
|
97
|
+
* @param {object} config - Resolved config (localesDir)
|
|
98
|
+
* @param {object} manifest - Content lock manifest (hash per file × locale)
|
|
99
|
+
* @param {boolean} forceContent - --force-content: re-process up-to-date files
|
|
100
|
+
* too (action 're-translate'), WITHOUT clearing the manifest — a target
|
|
101
|
+
* with no lock entry and no '[EN] ' markers is a genuine hand-translated
|
|
102
|
+
* file, and force must never overwrite human work with machine output.
|
|
103
|
+
* @returns {{ workItems: Array<object>, totalContentSkipped: number, recordedHashes: object }}
|
|
104
|
+
*/
|
|
105
|
+
function scanDocusaurusContentWork(contentSources, pairEntries, config, manifest, forceContent) {
|
|
106
|
+
const workItems = [];
|
|
107
|
+
let totalContentSkipped = 0;
|
|
108
|
+
const recordedHashes = {};
|
|
109
|
+
// sourcePath → { parsed, fieldsToTranslate, pageTitle, sourceHash }
|
|
110
|
+
const sourceCache = new Map();
|
|
111
|
+
|
|
112
|
+
for (const { dir: sourceContentDir, plugin: pluginName } of contentSources) {
|
|
113
|
+
const sourceFiles = discoverDocusaurusContentFiles(sourceContentDir);
|
|
114
|
+
const dirName = path.basename(sourceContentDir);
|
|
115
|
+
|
|
116
|
+
for (const sourcePath of sourceFiles) {
|
|
117
|
+
const relPath = path.relative(sourceContentDir, sourcePath);
|
|
118
|
+
|
|
119
|
+
// Read + parse source file once per source path. The staleness hash
|
|
120
|
+
// covers the RAW file (same as the Hugo twin's hashFileContent): a
|
|
121
|
+
// front-matter-noise edit must re-process the file so the metadata
|
|
122
|
+
// propagates to every locale copy (reassembleContentFile copies the
|
|
123
|
+
// source's full rawFrontMatter). Cost is NOT the hash's job — the
|
|
124
|
+
// TM makes that re-run API-free for unchanged text.
|
|
125
|
+
if (!sourceCache.has(sourcePath)) {
|
|
126
|
+
const raw = fs.readFileSync(sourcePath, 'utf-8');
|
|
127
|
+
const parsed = parseContentFile(raw);
|
|
128
|
+
const fieldsToTranslate = {};
|
|
129
|
+
if (parsed.hasFrontMatter) {
|
|
130
|
+
for (const field of DEFAULT_TRANSLATABLE_FIELDS) {
|
|
131
|
+
if (parsed.frontMatter[field] && typeof parsed.frontMatter[field] === 'string') {
|
|
132
|
+
fieldsToTranslate[field] = parsed.frontMatter[field];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Terminology context for block-batch prompts: the page's title
|
|
137
|
+
// (front matter first, else the first H1 in the body).
|
|
138
|
+
const pageTitle = fieldsToTranslate.title
|
|
139
|
+
|| (parsed.body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null);
|
|
140
|
+
const sourceHash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
|
|
141
|
+
sourceCache.set(sourcePath, { parsed, fieldsToTranslate, pageTitle, sourceHash });
|
|
142
|
+
}
|
|
143
|
+
const { parsed, fieldsToTranslate, pageTitle, sourceHash } = sourceCache.get(sourcePath);
|
|
144
|
+
|
|
145
|
+
for (const [, pairConfig] of pairEntries) {
|
|
146
|
+
const code = pairConfig.target;
|
|
147
|
+
const targetPath = getDocusaurusTargetPath(
|
|
148
|
+
sourcePath, sourceContentDir, code, config.localesDir, pluginName
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Security: verify target stays within i18n directory
|
|
152
|
+
if (!isPathContained(targetPath, config.localesDir)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const manifestKey = `docusaurus:${dirName}/${relPath}:${code}`;
|
|
157
|
+
let action = 'new'; // 'new' | 'changed' | 're-translate'
|
|
158
|
+
|
|
159
|
+
if (fs.existsSync(targetPath)) {
|
|
160
|
+
const storedHash = manifest[manifestKey];
|
|
161
|
+
if (storedHash) {
|
|
162
|
+
if (storedHash === sourceHash) {
|
|
163
|
+
if (!forceContent) {
|
|
164
|
+
// Source unchanged since last sync — skip
|
|
165
|
+
totalContentSkipped++;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// --force-content on an up-to-date file: honest label.
|
|
169
|
+
action = 're-translate';
|
|
170
|
+
} else {
|
|
171
|
+
action = 'changed';
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// No stored hash — check for [EN] fallback markers. This runs
|
|
175
|
+
// even under --force-content: a target with no lock entry and
|
|
176
|
+
// no [EN] markers is a genuine hand-translated file, and force
|
|
177
|
+
// must never overwrite human work with machine output.
|
|
178
|
+
const existingContent = fs.readFileSync(targetPath, 'utf-8');
|
|
179
|
+
const isLegacyFallback = existingContent.includes('[EN] ');
|
|
180
|
+
if (!isLegacyFallback) {
|
|
181
|
+
// Genuine hand-translated file — preserve it, record hash
|
|
182
|
+
recordedHashes[manifestKey] = sourceHash;
|
|
183
|
+
totalContentSkipped++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
action = 're-translate';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
workItems.push({
|
|
191
|
+
sourcePath, parsed, fieldsToTranslate, pageTitle, sourceHash,
|
|
192
|
+
relPath, dirName,
|
|
193
|
+
pairConfig, code, targetPath, manifestKey, pluginName, action,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { workItems, totalContentSkipped, recordedHashes };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Estimate and display translation costs for a Docusaurus sync.
|
|
204
|
+
*
|
|
205
|
+
* Docusaurus flavor of cost-report.js's printCostEstimate, so --max-cost is
|
|
206
|
+
* enforceable on this path too (it used to abort unconditionally — "no
|
|
207
|
+
* estimator" ≠ free). Two components, mirroring the two sync phases:
|
|
208
|
+
*
|
|
209
|
+
* Phase 1 (JSON UI strings): per (source JSON file × pair), the same
|
|
210
|
+
* extract → drop-unsafe-keys → changed-key-detect → diff → string-filter
|
|
211
|
+
* sequence the sync loop runs, then TM-partitioned with the pair's full
|
|
212
|
+
* method key — exactly what translateAndValidate will do. TM hits price
|
|
213
|
+
* at $0. Changed-key detection reads the same .champollion.lock manifest
|
|
214
|
+
* as Phase 1: an edited English string is invisible to the plain diff
|
|
215
|
+
* (the target still holds the old translation) but WILL re-fire, so the
|
|
216
|
+
* estimate must price it.
|
|
217
|
+
*
|
|
218
|
+
* Phase 2 (Markdown content): the pending work items from
|
|
219
|
+
* scanDocusaurusContentWork (the SAME scan the sync consumes), priced
|
|
220
|
+
* TM-aware by walking the exact lookup ladder the sync runs per item:
|
|
221
|
+
* front-matter fields partition per field; the body is free on a
|
|
222
|
+
* whole-body TM hit, else split into blocks ('block' mode) with only
|
|
223
|
+
* TM-missed translatable blocks billed by their restored source chars
|
|
224
|
+
* ('page' mode bills the whole body on a miss). Billed chars price as
|
|
225
|
+
* EST_CHARS_PER_KEY-char key-equivalents — rough by design, conservative
|
|
226
|
+
* for token-priced models. Because the ladder is mirrored, a re-fire
|
|
227
|
+
* after a resilient-ladder '[EN]' fallback prices as ONLY its
|
|
228
|
+
* fallen-back blocks: successfully translated blocks were TM-stored and
|
|
229
|
+
* estimate as hits. All lookups are pure hash reads; no network, no TM
|
|
230
|
+
* mutation.
|
|
231
|
+
*
|
|
232
|
+
* @param {Array<[string, object]>} pairEntries - Resolved pair graph entries
|
|
233
|
+
* @param {object} config - Resolved config (localesDir, fallbackPrefix, forceKeys)
|
|
234
|
+
* @param {string} sourceLocaleDir - i18n/<inputLocale> absolute path
|
|
235
|
+
* @param {object} tm - The loaded Translation Memory this run will use
|
|
236
|
+
* @param {Array<object>} contentWorkItems - Pending items from scanDocusaurusContentWork
|
|
237
|
+
* @param {object} lockManifest - Phase-1 source-hash manifest (readManifest),
|
|
238
|
+
* namespaced "docusaurus:<relPath>:<flatKey>" — the same object Phase 1 uses
|
|
239
|
+
* @returns {Promise<import('./cost-report.js').CostEstimateSummary|null>}
|
|
240
|
+
* Structured estimate, or null when estimation itself failed (callers
|
|
241
|
+
* with --max-cost must fail safe: unknown ≠ free)
|
|
242
|
+
*/
|
|
243
|
+
async function printDocusaurusCostEstimate(pairEntries, config, sourceLocaleDir, tm, contentWorkItems, lockManifest, noTranslate) {
|
|
244
|
+
try {
|
|
245
|
+
const { estimateCost } = await import('./pairs.js');
|
|
246
|
+
const costEstimates = [];
|
|
247
|
+
let totalEstimatedCost = 0;
|
|
248
|
+
let hasUnknownCosts = false;
|
|
249
|
+
|
|
250
|
+
// ── Phase 1: JSON UI strings, TM-partitioned per pair ───────────
|
|
251
|
+
const sourceJSONFiles = discoverDocusaurusJSONFiles(sourceLocaleDir);
|
|
252
|
+
const missesByPair = new Map(); // pairKey → miss count
|
|
253
|
+
const hitsByPair = new Map(); // pairKey → TM hit count
|
|
254
|
+
|
|
255
|
+
for (const sourceFilePath of sourceJSONFiles) {
|
|
256
|
+
const relPath = path.relative(sourceLocaleDir, sourceFilePath);
|
|
257
|
+
const sourceRaw = JSON.parse(fs.readFileSync(sourceFilePath, 'utf-8'));
|
|
258
|
+
const sourceFlat = extractDocusaurusMessages(sourceRaw);
|
|
259
|
+
for (const key of Object.keys(sourceFlat)) {
|
|
260
|
+
if (isUnsafeKey(key)) delete sourceFlat[key];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Mirror Phase 1's changed-key detection (same manifest, same
|
|
264
|
+
// per-file un-namespacing) so edited English strings are priced.
|
|
265
|
+
const nsPrefix = `docusaurus:${relPath}:`;
|
|
266
|
+
const fileOldManifest = {};
|
|
267
|
+
for (const [nsKey, storedHash] of Object.entries(lockManifest)) {
|
|
268
|
+
if (nsKey.startsWith(nsPrefix)) {
|
|
269
|
+
fileOldManifest[nsKey.slice(nsPrefix.length)] = storedHash;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const changedKeys = detectChangedKeys(sourceFlat, fileOldManifest);
|
|
273
|
+
|
|
274
|
+
for (const [pairKey, pairConfig] of pairEntries) {
|
|
275
|
+
const code = pairConfig.target;
|
|
276
|
+
const targetFilePath = path.join(config.localesDir, code, relPath);
|
|
277
|
+
if (!isPathContained(targetFilePath, config.localesDir)) continue;
|
|
278
|
+
|
|
279
|
+
let existingFlat = {};
|
|
280
|
+
if (fs.existsSync(targetFilePath)) {
|
|
281
|
+
existingFlat = extractDocusaurusMessages(JSON.parse(fs.readFileSync(targetFilePath, 'utf-8')));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Same confirmed-echo suppression as the Phase-1 sync below, so the
|
|
285
|
+
// estimate prices exactly the keys the run will actually queue.
|
|
286
|
+
const estTmKey = tmMethodKey(pairConfig);
|
|
287
|
+
const diff = diffLocale(
|
|
288
|
+
sourceFlat, existingFlat, config.fallbackPrefix,
|
|
289
|
+
config._forceAllKeys ? Object.keys(sourceFlat) : config.forceKeys, changedKeys,
|
|
290
|
+
(key, sourceValue) => lookupTM(tm, sourceValue, code, estTmKey) === sourceValue,
|
|
291
|
+
noTranslate.active ? noTranslate.matches : null
|
|
292
|
+
);
|
|
293
|
+
const stringKeys = diff.toProcess.filter(k => typeof sourceFlat[k] === 'string');
|
|
294
|
+
if (stringKeys.length === 0) continue;
|
|
295
|
+
|
|
296
|
+
const { misses } = partitionByTM(tm, sourceFlat, stringKeys, code, tmMethodKey(pairConfig));
|
|
297
|
+
missesByPair.set(pairKey, (missesByPair.get(pairKey) || 0) + misses.length);
|
|
298
|
+
hitsByPair.set(pairKey, (hitsByPair.get(pairKey) || 0) + (stringKeys.length - misses.length));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const [pairKey, pairConfig] of pairEntries) {
|
|
303
|
+
const keysToTranslate = missesByPair.get(pairKey) || 0;
|
|
304
|
+
const tmHits = hitsByPair.get(pairKey) || 0;
|
|
305
|
+
if (keysToTranslate === 0 && tmHits === 0) continue;
|
|
306
|
+
|
|
307
|
+
if (keysToTranslate > 0) {
|
|
308
|
+
// eslint-disable-next-line no-await-in-loop — sequential is fine for cost queries (cached)
|
|
309
|
+
const estimate = await estimateCost(keysToTranslate, pairConfig);
|
|
310
|
+
if (estimate.estimatedCost !== null) {
|
|
311
|
+
totalEstimatedCost += estimate.estimatedCost;
|
|
312
|
+
} else {
|
|
313
|
+
hasUnknownCosts = true;
|
|
314
|
+
}
|
|
315
|
+
costEstimates.push({
|
|
316
|
+
pair: pairKey,
|
|
317
|
+
method: pairConfig.method || 'llm',
|
|
318
|
+
keys: keysToTranslate,
|
|
319
|
+
tmHits,
|
|
320
|
+
estimatedCost: estimate.estimatedCost,
|
|
321
|
+
source: estimate.source,
|
|
322
|
+
});
|
|
323
|
+
} else {
|
|
324
|
+
// Fully TM-covered: zero API calls → a KNOWN $0, even for
|
|
325
|
+
// unknown-pricing methods. Must not trip hasUnknownCosts.
|
|
326
|
+
costEstimates.push({
|
|
327
|
+
pair: pairKey,
|
|
328
|
+
method: pairConfig.method || 'llm',
|
|
329
|
+
keys: 0,
|
|
330
|
+
tmHits,
|
|
331
|
+
estimatedCost: 0,
|
|
332
|
+
source: 'translation-memory',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Phase 2: content work items, TM/block-aware ─────────────────
|
|
338
|
+
let content = null;
|
|
339
|
+
if (contentWorkItems.length > 0) {
|
|
340
|
+
// Body segmentation is deterministic on the source text and
|
|
341
|
+
// locale-independent — compute each file's restored translatable
|
|
342
|
+
// block sources once (only needed for 'block' mode misses).
|
|
343
|
+
const blockSourcesCache = new Map(); // sourcePath → string[]
|
|
344
|
+
const charsByPair = new Map(); // pairConfig (by reference) → billed source chars
|
|
345
|
+
const pendingSourceFiles = new Set();
|
|
346
|
+
|
|
347
|
+
for (const item of contentWorkItems) {
|
|
348
|
+
pendingSourceFiles.add(item.sourcePath);
|
|
349
|
+
const code = item.code;
|
|
350
|
+
const tmKey = tmMethodKey(item.pairConfig);
|
|
351
|
+
let chars = 0;
|
|
352
|
+
|
|
353
|
+
// Front-matter fields: each field is cached on its own source
|
|
354
|
+
// text — only TM misses reach the API.
|
|
355
|
+
for (const text of Object.values(item.fieldsToTranslate)) {
|
|
356
|
+
if (lookupTM(tm, text, code, tmKey) === null) chars += text.length;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Body: whole-body TM first (a revert or lock-loss re-run is
|
|
360
|
+
// free), then per-block in 'block' mode — the same ladder the
|
|
361
|
+
// sync runs below.
|
|
362
|
+
const body = item.parsed.body;
|
|
363
|
+
if (body.trim() && lookupTM(tm, body, code, tmKey) === null) {
|
|
364
|
+
const segMode = item.pairConfig.contentSegmentation || config.contentSegmentation || 'block';
|
|
365
|
+
if (segMode === 'page') {
|
|
366
|
+
chars += body.length;
|
|
367
|
+
} else {
|
|
368
|
+
if (!blockSourcesCache.has(item.sourcePath)) {
|
|
369
|
+
const { protectedBody, blocks } = protectBlocks(body);
|
|
370
|
+
blockSourcesCache.set(
|
|
371
|
+
item.sourcePath,
|
|
372
|
+
splitBlocks(protectedBody)
|
|
373
|
+
.filter(seg => seg.type === 'translatable')
|
|
374
|
+
.map(seg => restoreBlocks(seg.text, blocks))
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
for (const source of blockSourcesCache.get(item.sourcePath)) {
|
|
378
|
+
if (lookupTM(tm, source, code, tmKey) === null) chars += source.length;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (chars > 0) {
|
|
384
|
+
charsByPair.set(item.pairConfig, (charsByPair.get(item.pairConfig) || 0) + chars);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let contentCost = 0;
|
|
389
|
+
let contentUnknown = false;
|
|
390
|
+
for (const [, pairConfig] of pairEntries) {
|
|
391
|
+
const chars = charsByPair.get(pairConfig);
|
|
392
|
+
// Fully TM-covered pairs are a KNOWN $0 (zero API calls) — they
|
|
393
|
+
// must not consult estimateCost, whose unknown-pricing null would
|
|
394
|
+
// wrongly trip hasUnknownCosts and abort a free run under a cap.
|
|
395
|
+
if (!chars) continue;
|
|
396
|
+
const keyEquivalents = Math.ceil(chars / EST_CHARS_PER_KEY);
|
|
397
|
+
// eslint-disable-next-line no-await-in-loop — sequential is fine for cost queries (cached)
|
|
398
|
+
const estimate = await estimateCost(keyEquivalents, pairConfig);
|
|
399
|
+
if (estimate.estimatedCost !== null) {
|
|
400
|
+
contentCost += estimate.estimatedCost;
|
|
401
|
+
} else {
|
|
402
|
+
contentUnknown = true;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
content = {
|
|
407
|
+
files: pendingSourceFiles.size,
|
|
408
|
+
pendingTranslations: contentWorkItems.length,
|
|
409
|
+
estimatedCost: contentUnknown ? null : contentCost,
|
|
410
|
+
rough: true,
|
|
411
|
+
};
|
|
412
|
+
if (contentUnknown) {
|
|
413
|
+
hasUnknownCosts = true;
|
|
414
|
+
} else {
|
|
415
|
+
totalEstimatedCost += contentCost;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
printCostTable(costEstimates, content, totalEstimatedCost, hasUnknownCosts);
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
currency: 'USD',
|
|
423
|
+
pairs: costEstimates,
|
|
424
|
+
keyCost: content && content.estimatedCost !== null
|
|
425
|
+
? totalEstimatedCost - content.estimatedCost
|
|
426
|
+
: totalEstimatedCost,
|
|
427
|
+
content,
|
|
428
|
+
totalEstimatedCost,
|
|
429
|
+
hasUnknownCosts,
|
|
430
|
+
};
|
|
431
|
+
} catch (costError) {
|
|
432
|
+
// Non-blocking without a cap — warn and continue. Callers enforcing
|
|
433
|
+
// --max-cost must treat the null return as unknown (abort).
|
|
434
|
+
output.warn(`Cost estimation failed: ${costError.message}`);
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Run the Docusaurus-specific sync operation.
|
|
441
|
+
*
|
|
442
|
+
* Two phases:
|
|
443
|
+
* Phase 1: JSON UI strings — {message, description} files in i18n/{locale}/
|
|
444
|
+
* Phase 2: Markdown content — docs/ and blog/ mirrored into i18n/{locale}/
|
|
445
|
+
*
|
|
446
|
+
* @param {object} options - { dryRun, audit, cwd, cliArgs }
|
|
447
|
+
* @param {object} config - Resolved config from resolveConfig()
|
|
448
|
+
* @param {string} cwd - Working directory
|
|
449
|
+
* @param {Function} resolveRuntime - Injected from sync.js to avoid circular imports
|
|
450
|
+
*/
|
|
451
|
+
async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
452
|
+
const { dryRun = false, audit = false, cliArgs = {} } = options;
|
|
453
|
+
const forceContent = cliArgs['force-content'] || false;
|
|
454
|
+
|
|
455
|
+
// --force: re-queue every Phase-1 UI string (the whole-locale rebuild
|
|
456
|
+
// verb; scope with --pair). Docusaurus keys are per-FILE, so the
|
|
457
|
+
// expansion happens at each file's diff rather than globally. Markdown
|
|
458
|
+
// content keeps its own switch (--force-content) — the two lanes have
|
|
459
|
+
// different cost profiles and forcing one must not silently force the
|
|
460
|
+
// other. Stored on config so the cost estimator prices the same rebuild
|
|
461
|
+
// the sync will run.
|
|
462
|
+
config._forceAllKeys = !!cliArgs.force;
|
|
463
|
+
if (config._forceAllKeys) {
|
|
464
|
+
output.info(`--force: re-queuing all JSON UI string(s)${cliArgs.pair ? ' for the selected pair(s)' : ''}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// No-translate matcher — one compiled instance shared by the cost estimate
|
|
468
|
+
// and every Phase 1 diff, so exempt keys are excluded from the bill by the
|
|
469
|
+
// same decision that excludes them from translation. Phase 2 (Markdown
|
|
470
|
+
// bodies) has no key space to match against and is unaffected.
|
|
471
|
+
const noTranslate = compileNoTranslate(config);
|
|
472
|
+
if (noTranslate.patterns.length > 0) {
|
|
473
|
+
output.info(`No-translate patterns: ${noTranslate.patterns.join(', ')}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Wire CLI --concurrency into config so the content loop can pick it up
|
|
477
|
+
if (cliArgs.concurrency) {
|
|
478
|
+
config.concurrency = parseInt(cliArgs.concurrency, 10) || 48;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// --force-content: re-process every champollion-managed file × locale
|
|
482
|
+
// regardless of stored hashes. Hand-translated files (no lock entry, no
|
|
483
|
+
// '[EN] ' markers) are STILL preserved — force must never clobber human
|
|
484
|
+
// work. With the TM threaded through Phase 2 this is usually cheap:
|
|
485
|
+
// bodies/blocks and front matter fields translated by a TM-era sync come
|
|
486
|
+
// back as cache hits. Text the TM has never seen (e.g. translated before
|
|
487
|
+
// the TM existed, or after an eviction) is re-billed — which is exactly
|
|
488
|
+
// what the --max-cost gate below prices before anything runs. The lock
|
|
489
|
+
// file itself is never deleted: wiping it also destroyed the
|
|
490
|
+
// hand-translated-file adoption record and any Hugo content entries
|
|
491
|
+
// sharing the same lock.
|
|
492
|
+
if (forceContent) {
|
|
493
|
+
output.info('--force-content: ignoring the content lock — previously cached content is served from the Translation Memory.');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Verify i18n directory exists
|
|
497
|
+
if (!fs.existsSync(config.localesDir)) {
|
|
498
|
+
throw new Error(`Docusaurus i18n directory not found: ${config.localesDir}. Run \`npx docusaurus write-translations\` first.`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const inputLocale = config.inputLocale;
|
|
502
|
+
const sourceLocaleDir = path.join(config.localesDir, inputLocale);
|
|
503
|
+
|
|
504
|
+
if (!fs.existsSync(sourceLocaleDir)) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`Source locale directory not found: ${sourceLocaleDir}. ` +
|
|
507
|
+
`Run \`npx docusaurus write-translations\` to generate source strings.`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Thread dryRun/audit into cliArgs so the preflight check can skip
|
|
512
|
+
// when appropriate — same pattern as the main sync path in sync.js
|
|
513
|
+
const { apiKey, pairEntries } = await resolveRuntime(config, cwd, { ...cliArgs, dryRun, audit });
|
|
514
|
+
|
|
515
|
+
if (pairEntries.length === 0) {
|
|
516
|
+
output.info('No target languages configured. Add pairs to champollion.config.json.');
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Reject invalid segmentation modes up front — before ANY file (JSON or
|
|
521
|
+
// Markdown) is touched — rather than mid-sync. Mirrors content-sync.js.
|
|
522
|
+
assertSegmentationMode(config.contentSegmentation, 'champollion.config.json');
|
|
523
|
+
for (const [pairKey, pairConfig] of pairEntries) {
|
|
524
|
+
assertSegmentationMode(pairConfig.contentSegmentation, `pair "${pairKey}"`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
output.raw(`\n 🦕 Docusaurus sync — ${pairEntries.length} language(s)`);
|
|
528
|
+
output.raw(` Source: i18n/${inputLocale}/`);
|
|
529
|
+
if (dryRun) output.raw(' Mode: DRY RUN\n');
|
|
530
|
+
else output.raw('');
|
|
531
|
+
|
|
532
|
+
// Load Translation Memory for the Docusaurus path too.
|
|
533
|
+
// Loaded BEFORE the cost estimate so it can partition against the exact
|
|
534
|
+
// TM this run will use. --no-tm bypasses the cache entirely (see the
|
|
535
|
+
// standard sync path for details); the empty TM makes the estimator
|
|
536
|
+
// price every key and block too.
|
|
537
|
+
const noTM = cliArgs['no-tm'] || false;
|
|
538
|
+
const tm = noTM ? { _meta: { version: 1 } } : loadTM(cwd);
|
|
539
|
+
const tmInitialSize = tmSize(tm);
|
|
540
|
+
if (noTM) {
|
|
541
|
+
output.info('Translation Memory disabled (--no-tm)');
|
|
542
|
+
} else if (tmInitialSize > 0) {
|
|
543
|
+
output.info(`[TM] ${tmInitialSize} cached entries loaded`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Phase-1 source-hash manifest (.champollion.lock) — read up front
|
|
547
|
+
// because the cost estimator mirrors Phase 1's changed-key detection.
|
|
548
|
+
// Keys are namespaced per source file ("docusaurus:<relPath>:<flatKey>")
|
|
549
|
+
// because Docusaurus splits UI strings across many JSON files whose flat
|
|
550
|
+
// keys can collide (e.g. two plugins both defining "title"). Without the
|
|
551
|
+
// manifest, EDITING an English UI string never re-translated it: the
|
|
552
|
+
// diff only sees missing keys and [EN] fallbacks, so a changed source
|
|
553
|
+
// value looked "fully synced" forever.
|
|
554
|
+
const lockManifest = readManifest(cwd);
|
|
555
|
+
const updatedLockManifest = { ...lockManifest };
|
|
556
|
+
|
|
557
|
+
// ── Content discovery + pending-work scan ─────────────────────
|
|
558
|
+
// Done up front (not in Phase 2) because the cost estimate needs the
|
|
559
|
+
// pending work items. Phase 2 consumes this same scan — one source of
|
|
560
|
+
// truth for "what will be translated".
|
|
561
|
+
const docsDir = path.join(cwd, 'docs');
|
|
562
|
+
const blogDir = path.join(cwd, 'blog');
|
|
563
|
+
const contentSources = [];
|
|
564
|
+
if (fs.existsSync(docsDir)) {
|
|
565
|
+
contentSources.push({ dir: docsDir, plugin: 'docusaurus-plugin-content-docs' });
|
|
566
|
+
}
|
|
567
|
+
if (fs.existsSync(blogDir)) {
|
|
568
|
+
contentSources.push({ dir: blogDir, plugin: 'docusaurus-plugin-content-blog' });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Content hash manifest for change detection (shared with Hugo content
|
|
572
|
+
// sync). Manifest values are hashes of the RAW source file — same scheme
|
|
573
|
+
// as the Hugo twin (content-sync.js hashFileContent) and every lock ever
|
|
574
|
+
// shipped, so existing entries stay comparable. Raw hashing means a
|
|
575
|
+
// front-matter-noise edit (sidebar_position, draft, slug, tags) DOES
|
|
576
|
+
// re-process the file — deliberately: Docusaurus reads those fields
|
|
577
|
+
// per-locale, so they must propagate to every locale copy. The re-run
|
|
578
|
+
// is API-free when the TM has the file (unchanged fm fields and the
|
|
579
|
+
// whole body are cache hits); billing is bounded by the TM, not by
|
|
580
|
+
// this hash. Entries start as a full copy and are only advanced
|
|
581
|
+
// per-item on SUCCESS: a failed or interrupted run keeps each old
|
|
582
|
+
// entry, so the item re-fires on the next sync instead of being
|
|
583
|
+
// silently frozen.
|
|
584
|
+
const contentLockPath = path.join(cwd, CONTENT_LOCK_FILENAME);
|
|
585
|
+
let docuContentManifest = {};
|
|
586
|
+
if (fs.existsSync(contentLockPath)) {
|
|
587
|
+
try { docuContentManifest = JSON.parse(fs.readFileSync(contentLockPath, 'utf-8')); } catch { /* first run */ }
|
|
588
|
+
}
|
|
589
|
+
const contentScan = scanDocusaurusContentWork(
|
|
590
|
+
contentSources, pairEntries, config, docuContentManifest, forceContent
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
// ── Pre-sync cost estimation + --max-cost gate ────────────────
|
|
594
|
+
// Mirrors sync.js: without a cap the estimate is informational and
|
|
595
|
+
// failures are non-blocking; with a cap it is a GATE — over-cap or
|
|
596
|
+
// unknowable estimates abort before any API call (unknown ≠ free).
|
|
597
|
+
// Dry-runs are exempt: they make no API calls, and the preview is the
|
|
598
|
+
// exact thing a capped user needs to see.
|
|
599
|
+
const maxCost = parseMaxCost(cliArgs['max-cost']);
|
|
600
|
+
const costEstimate = await printDocusaurusCostEstimate(
|
|
601
|
+
pairEntries, config, sourceLocaleDir, tm, contentScan.workItems, lockManifest, noTranslate
|
|
602
|
+
);
|
|
603
|
+
if (maxCost !== null && !dryRun) {
|
|
604
|
+
if (!costEstimate) {
|
|
605
|
+
return abortForMaxCost(
|
|
606
|
+
maxCost, null,
|
|
607
|
+
'Cost estimation failed, so --max-cost cannot be enforced (unknown is not free).'
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (costEstimate.hasUnknownCosts) {
|
|
611
|
+
return abortForMaxCost(
|
|
612
|
+
maxCost, null,
|
|
613
|
+
'Some pairs have unknown pricing, so the total cost cannot be bounded (unknown is not free).'
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
if (costEstimate.totalEstimatedCost > maxCost) {
|
|
617
|
+
return abortForMaxCost(
|
|
618
|
+
maxCost, costEstimate.totalEstimatedCost,
|
|
619
|
+
'Estimated translation cost exceeds the --max-cost cap.'
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ── Phase 1: JSON UI strings ──────────────────────────────────
|
|
625
|
+
|
|
626
|
+
const sourceJSONFiles = discoverDocusaurusJSONFiles(sourceLocaleDir);
|
|
627
|
+
output.raw(` Phase 1: JSON strings (${sourceJSONFiles.length} file(s))\n`);
|
|
628
|
+
|
|
629
|
+
let totalJSONKeys = 0;
|
|
630
|
+
let totalJSONCopied = 0;
|
|
631
|
+
|
|
632
|
+
for (const sourceFilePath of sourceJSONFiles) {
|
|
633
|
+
const relPath = path.relative(sourceLocaleDir, sourceFilePath);
|
|
634
|
+
const sourceRaw = JSON.parse(fs.readFileSync(sourceFilePath, 'utf-8'));
|
|
635
|
+
const sourceFlat = extractDocusaurusMessages(sourceRaw);
|
|
636
|
+
|
|
637
|
+
// Extract developer-written context descriptions from Docusaurus format.
|
|
638
|
+
// These help the LLM disambiguate polysemous terms (e.g., "Post" as
|
|
639
|
+
// "submit" vs "blog post") by injecting the description alongside each key.
|
|
640
|
+
const descriptions = extractDocusaurusDescriptions(sourceRaw);
|
|
641
|
+
const keyCount = Object.keys(sourceFlat).length;
|
|
642
|
+
|
|
643
|
+
// Defense: remove unsafe keys
|
|
644
|
+
for (const key of Object.keys(sourceFlat)) {
|
|
645
|
+
if (isUnsafeKey(key)) delete sourceFlat[key];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Detect keys whose ENGLISH source value changed since the last sync.
|
|
649
|
+
// The manifest stores namespaced keys; detectChangedKeys expects the
|
|
650
|
+
// same key space as sourceFlat, so build a per-file un-namespaced view.
|
|
651
|
+
const nsPrefix = `docusaurus:${relPath}:`;
|
|
652
|
+
const fileOldManifest = {};
|
|
653
|
+
for (const [nsKey, storedHash] of Object.entries(lockManifest)) {
|
|
654
|
+
if (nsKey.startsWith(nsPrefix)) {
|
|
655
|
+
fileOldManifest[nsKey.slice(nsPrefix.length)] = storedHash;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const changedKeys = detectChangedKeys(sourceFlat, fileOldManifest);
|
|
659
|
+
|
|
660
|
+
// ── Parallel locale processing for this JSON file ───────────
|
|
661
|
+
// Each locale writes to its own target file, so zero data deps.
|
|
662
|
+
const jsonConcurrency = config.jsonConcurrency ?? DEFAULT_JSON_CONCURRENCY;
|
|
663
|
+
|
|
664
|
+
const pairResults = await pMap(pairEntries, async ([pairKey, pairConfig]) => {
|
|
665
|
+
const code = pairConfig.target;
|
|
666
|
+
const targetFilePath = path.join(config.localesDir, code, relPath);
|
|
667
|
+
|
|
668
|
+
// Security: verify target path stays within i18n directory
|
|
669
|
+
if (!isPathContained(targetFilePath, config.localesDir)) {
|
|
670
|
+
output.error(`${code}/${relPath} — refusing to write outside i18n directory`);
|
|
671
|
+
// Nothing was attempted, but nothing succeeded either: keep every
|
|
672
|
+
// to-be-processed key out of the "succeeded" set so its manifest
|
|
673
|
+
// hash is not advanced (an unwritable locale must re-fire).
|
|
674
|
+
return { keys: 0, failedKeys: Object.keys(sourceFlat) };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Load existing target if present
|
|
678
|
+
let existingFlat = {};
|
|
679
|
+
if (fs.existsSync(targetFilePath)) {
|
|
680
|
+
const existingRaw = JSON.parse(fs.readFileSync(targetFilePath, 'utf-8'));
|
|
681
|
+
existingFlat = extractDocusaurusMessages(existingRaw);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Diff against source — changedKeys makes edited English strings
|
|
685
|
+
// re-translate (they are otherwise invisible to the diff). Echo keys
|
|
686
|
+
// (target === source) the TM confirms as pipeline-produced are NOT
|
|
687
|
+
// requeued — see lib/diff.js isConfirmedEcho.
|
|
688
|
+
const tmKey = tmMethodKey(pairConfig);
|
|
689
|
+
const diff = diffLocale(
|
|
690
|
+
sourceFlat, existingFlat, config.fallbackPrefix,
|
|
691
|
+
config._forceAllKeys ? Object.keys(sourceFlat) : config.forceKeys, changedKeys,
|
|
692
|
+
(key, sourceValue) => lookupTM(tm, sourceValue, code, tmKey) === sourceValue,
|
|
693
|
+
noTranslate.active ? noTranslate.matches : null
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
if (diff.toProcess.length === 0 && diff.noTranslate.length === 0 && diff.extra.length === 0) {
|
|
697
|
+
return { keys: 0, failedKeys: [] };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
let keysProcessed = 0;
|
|
701
|
+
let keysCopied = 0;
|
|
702
|
+
const failedKeys = [];
|
|
703
|
+
|
|
704
|
+
if (diff.toProcess.length > 0 || diff.noTranslate.length > 0) {
|
|
705
|
+
output.info(`${code}/${relPath} — ${diffLabel(diff)}`);
|
|
706
|
+
|
|
707
|
+
if (!dryRun) {
|
|
708
|
+
// Merged output starts from what's on disk, then takes the verbatim
|
|
709
|
+
// no-translate copies. Applying them FIRST means a translation
|
|
710
|
+
// failure below can still flush them — they don't depend on the
|
|
711
|
+
// backend, so an outage must not strand a corrupted URL.
|
|
712
|
+
const mergedFlat = { ...existingFlat };
|
|
713
|
+
for (const key of diff.noTranslate) {
|
|
714
|
+
mergedFlat[key] = sourceFlat[key];
|
|
715
|
+
}
|
|
716
|
+
keysCopied = diff.noTranslate.length;
|
|
717
|
+
const writeMerged = () => {
|
|
718
|
+
const docuOutput = injectDocusaurusMessages(sourceRaw, mergedFlat);
|
|
719
|
+
fs.mkdirSync(path.dirname(targetFilePath), { recursive: true });
|
|
720
|
+
fs.writeFileSync(targetFilePath, JSON.stringify(docuOutput, null, 2) + '\n', 'utf-8');
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
if (keysCopied > 0) {
|
|
724
|
+
const sample = diff.noTranslate.slice(0, 3)
|
|
725
|
+
.map(k => `${k} (${noTranslate.reason(k, sourceFlat[k])})`)
|
|
726
|
+
.join(', ');
|
|
727
|
+
const more = keysCopied > 3 ? `, +${keysCopied - 3} more` : '';
|
|
728
|
+
output.info(`${code}/${relPath} — copied ${keysCopied} no-translate key(s) verbatim: ${sample}${more}`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
let translated = null;
|
|
732
|
+
const stringKeys = diff.toProcess.filter(k => typeof sourceFlat[k] === 'string');
|
|
733
|
+
|
|
734
|
+
if (stringKeys.length > 0) {
|
|
735
|
+
// Shared pipeline: TM partition → API call → TM store → quality gate
|
|
736
|
+
const result = await translateAndValidate(stringKeys, sourceFlat, pairConfig, pairKey, {
|
|
737
|
+
apiKey, tm, targetCode: code, descriptions,
|
|
738
|
+
});
|
|
739
|
+
translated = result.translated;
|
|
740
|
+
|
|
741
|
+
if (translated) {
|
|
742
|
+
output.progress(result.failures.length > 0 ? ` [OK] (${result.failures.length} failed gate)\n` : ' [OK]\n');
|
|
743
|
+
} else if (result.apiReturnedNull || (result.failures.length > 0 && !translated)) {
|
|
744
|
+
output.progress(result.apiReturnedNull ? ' [ERR] translation failed\n' : ' [ERR] all failed quality gate\n');
|
|
745
|
+
output.error(`${code}/${relPath}: Translation failed. Check API key and method configuration.`);
|
|
746
|
+
if (keysCopied > 0) writeMerged();
|
|
747
|
+
return { keys: 0, copied: keysCopied, failedKeys: stringKeys };
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Merge in the new translations. Script conversion mirrors the
|
|
752
|
+
// flat-lane rule in sync.js: only when this pair's resolution asked
|
|
753
|
+
// for it, fallbacks first, and a value with unmappable letters
|
|
754
|
+
// stays whole in the working script (warned, not failed).
|
|
755
|
+
const scriptConverterKey = pairConfig.scriptResolution?.converterKey || null;
|
|
756
|
+
for (const key of diff.toProcess) {
|
|
757
|
+
if (translated && key in translated) {
|
|
758
|
+
let value = translated[key];
|
|
759
|
+
if (scriptConverterKey && typeof value === 'string') {
|
|
760
|
+
const prepared = applyScriptFallback(value, pairConfig.scriptFallback);
|
|
761
|
+
const { converted, unmapped } = convertScript(prepared, scriptConverterKey);
|
|
762
|
+
if (unmapped.length === 0) {
|
|
763
|
+
value = converted;
|
|
764
|
+
} else {
|
|
765
|
+
output.warn(
|
|
766
|
+
`${code}/${relPath}: key "${key}" kept in working script — `
|
|
767
|
+
+ `unmapped letter(s): ${unmapped.join(', ')} (see "scriptFallback")`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
mergedFlat[key] = value;
|
|
772
|
+
} else if (typeof sourceFlat[key] === 'string') {
|
|
773
|
+
output.warn(`${code}/${relPath}: key "${key}" not translated — skipping`);
|
|
774
|
+
failedKeys.push(key);
|
|
775
|
+
} else {
|
|
776
|
+
mergedFlat[key] = sourceFlat[key];
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
keysProcessed = diff.toProcess.length;
|
|
781
|
+
|
|
782
|
+
// Inject back into Docusaurus format and write
|
|
783
|
+
writeMerged();
|
|
784
|
+
} else {
|
|
785
|
+
keysProcessed = diff.toProcess.length;
|
|
786
|
+
keysCopied = diff.noTranslate.length;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (diff.extra.length > 0) {
|
|
791
|
+
output.warn(`${code}/${relPath} — ${diff.extra.length} extra key(s)`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return { keys: keysProcessed, copied: keysCopied, failedKeys };
|
|
795
|
+
}, { concurrency: jsonConcurrency });
|
|
796
|
+
|
|
797
|
+
// Aggregate results for this JSON file
|
|
798
|
+
const fileFailedKeys = new Set();
|
|
799
|
+
for (const r of pairResults) {
|
|
800
|
+
totalJSONKeys += r.keys;
|
|
801
|
+
totalJSONCopied += r.copied || 0;
|
|
802
|
+
for (const k of r.failedKeys || []) fileFailedKeys.add(k);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Update the manifest for this file: record the current hash ONLY for
|
|
806
|
+
// keys that succeeded in every locale that attempted them. A failed
|
|
807
|
+
// key keeps its OLD hash (or none) so it is detected as changed and
|
|
808
|
+
// RE-FIRES on the next sync — advancing the hash for a failed key
|
|
809
|
+
// would silently mark the stale translation as current forever.
|
|
810
|
+
if (!dryRun) {
|
|
811
|
+
for (const nsKey of Object.keys(updatedLockManifest)) {
|
|
812
|
+
// Own-property check: `in` walks the prototype chain, so a source
|
|
813
|
+
// key literally named "toString"/"valueOf" would mis-resolve.
|
|
814
|
+
if (nsKey.startsWith(nsPrefix)
|
|
815
|
+
&& !Object.prototype.hasOwnProperty.call(sourceFlat, nsKey.slice(nsPrefix.length))) {
|
|
816
|
+
delete updatedLockManifest[nsKey]; // key removed from source
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
for (const [key, value] of Object.entries(sourceFlat)) {
|
|
820
|
+
const nsKey = nsPrefix + key;
|
|
821
|
+
if (fileFailedKeys.has(key)) {
|
|
822
|
+
// Restore/keep the pre-sync state for failed keys.
|
|
823
|
+
if (Object.prototype.hasOwnProperty.call(lockManifest, nsKey)) {
|
|
824
|
+
updatedLockManifest[nsKey] = lockManifest[nsKey];
|
|
825
|
+
} else {
|
|
826
|
+
delete updatedLockManifest[nsKey];
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
updatedLockManifest[nsKey] = hashValue(value);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Persist the Phase 1 source-hash manifest (skip in dry-run — a preview
|
|
836
|
+
// must not mark changed keys as resolved).
|
|
837
|
+
if (!dryRun && sourceJSONFiles.length > 0) {
|
|
838
|
+
writeManifest(cwd, updatedLockManifest);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const copiedNote = totalJSONCopied > 0
|
|
842
|
+
? ` (+${totalJSONCopied} copied verbatim, no-translate)`
|
|
843
|
+
: '';
|
|
844
|
+
if (totalJSONKeys > 0) {
|
|
845
|
+
const action = dryRun ? 'Would process' : 'Synced';
|
|
846
|
+
output.ok(`${action} ${totalJSONKeys} JSON key(s)${copiedNote}`);
|
|
847
|
+
} else if (totalJSONCopied > 0) {
|
|
848
|
+
output.ok(`All JSON files fully synced${copiedNote}`);
|
|
849
|
+
} else {
|
|
850
|
+
output.ok('All JSON files fully synced');
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ── Phase 2: Markdown content (docs + blog) ───────────────────
|
|
854
|
+
|
|
855
|
+
if (contentSources.length === 0) {
|
|
856
|
+
output.info('No docs/ or blog/ directories found — skipping content sync.');
|
|
857
|
+
} else {
|
|
858
|
+
let totalContent = 0;
|
|
859
|
+
let totalContentRetranslated = 0;
|
|
860
|
+
|
|
861
|
+
// Pending work comes from the up-front scan (also used by the cost
|
|
862
|
+
// estimate). Hand-translated files it discovered get their hashes
|
|
863
|
+
// folded into the manifest here so they persist.
|
|
864
|
+
const { workItems, recordedHashes } = contentScan;
|
|
865
|
+
const totalContentSkipped = contentScan.totalContentSkipped;
|
|
866
|
+
const updatedDocuManifest = { ...docuContentManifest, ...recordedHashes };
|
|
867
|
+
|
|
868
|
+
// Concurrency for parallel content translation. Configurable via
|
|
869
|
+
// --content-concurrency flag or config.contentConcurrency, defaults to 48.
|
|
870
|
+
// Content calls are heavier (full markdown docs) so lower concurrency
|
|
871
|
+
// than JSON (which defaults to 50) to avoid overwhelming the API.
|
|
872
|
+
const concurrency = config.contentConcurrency || 48;
|
|
873
|
+
|
|
874
|
+
const totalWork = workItems.length;
|
|
875
|
+
let contentFailures = 0;
|
|
876
|
+
// Warn at most once per source file about front matter fields we can't
|
|
877
|
+
// translate (arrays / nested blocks like `related:`). The check + add are
|
|
878
|
+
// synchronous (no await between), so this is race-free under pMap.
|
|
879
|
+
const warnedFrontMatter = new Set();
|
|
880
|
+
output.raw(`\n Phase 2: content (${totalWork} translation(s) to process, ${totalContentSkipped} skipped, concurrency: ${concurrency})\n`);
|
|
881
|
+
|
|
882
|
+
if (totalWork === 0) {
|
|
883
|
+
output.ok('All content files are up to date.');
|
|
884
|
+
} else {
|
|
885
|
+
// ── Translate all work items in a single flat pool ──────────
|
|
886
|
+
let completed = 0;
|
|
887
|
+
const syncStartTime = Date.now();
|
|
888
|
+
|
|
889
|
+
// Incremental manifest persistence — write every N completions
|
|
890
|
+
// so killing the process doesn't lose all progress.
|
|
891
|
+
const MANIFEST_WRITE_INTERVAL = 10;
|
|
892
|
+
let manifestDirty = false;
|
|
893
|
+
|
|
894
|
+
const writeManifestIfDirty = () => {
|
|
895
|
+
if (!dryRun && manifestDirty) {
|
|
896
|
+
const sorted = {};
|
|
897
|
+
for (const key of Object.keys(updatedDocuManifest).sort()) {
|
|
898
|
+
sorted[key] = updatedDocuManifest[key];
|
|
899
|
+
}
|
|
900
|
+
fs.writeFileSync(contentLockPath, JSON.stringify(sorted, null, 2) + '\n', 'utf-8');
|
|
901
|
+
manifestDirty = false;
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
await pMap(workItems, async (item) => {
|
|
906
|
+
const {
|
|
907
|
+
parsed, fieldsToTranslate, pageTitle, sourceHash,
|
|
908
|
+
relPath, dirName, pairConfig, code,
|
|
909
|
+
targetPath, manifestKey, action,
|
|
910
|
+
} = item;
|
|
911
|
+
|
|
912
|
+
try {
|
|
913
|
+
if (action === 'changed') {
|
|
914
|
+
totalContentRetranslated++;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (dryRun) {
|
|
918
|
+
const targetRel = path.relative(config.localesDir, targetPath);
|
|
919
|
+
output.raw(` [DRY] ${dirName}/${relPath} → ${code}`);
|
|
920
|
+
totalContent++;
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const { rawFrontMatter, body, hasFrontMatter, frontMatterFormat } = parsed;
|
|
925
|
+
|
|
926
|
+
// Never silently drop translatable-looking nested/array front matter
|
|
927
|
+
// (e.g. `related:` lists). The flat parser can't reach them — surface
|
|
928
|
+
// them once per source file so the omission is visible.
|
|
929
|
+
if (hasFrontMatter && !warnedFrontMatter.has(item.sourcePath)) {
|
|
930
|
+
warnedFrontMatter.add(item.sourcePath);
|
|
931
|
+
const skipped = findUntranslatableNestedFields(rawFrontMatter);
|
|
932
|
+
if (skipped.length > 0) {
|
|
933
|
+
output.warn(
|
|
934
|
+
`${dirName}/${relPath}: front matter field(s) [${skipped.join(', ')}] are arrays/nested — ` +
|
|
935
|
+
`left untranslated. Flatten them to top-level strings to translate, or translate by hand.`
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// TM entries are keyed on the full method key (method|model|
|
|
941
|
+
// register|coaching) — switching any of those must re-translate,
|
|
942
|
+
// not re-serve. Mirrors content-sync.js.
|
|
943
|
+
const tmKey = tmMethodKey(pairConfig);
|
|
944
|
+
|
|
945
|
+
// Translate front matter fields — TM first, API only for misses.
|
|
946
|
+
// Each field is cached on its own source text (exactly like a
|
|
947
|
+
// key-value sync key): a title edit re-pays only the title.
|
|
948
|
+
const translatedFields = {};
|
|
949
|
+
if (hasFrontMatter && Object.keys(fieldsToTranslate).length > 0) {
|
|
950
|
+
const { hits: fmHits, misses: fmMisses } = partitionByTM(
|
|
951
|
+
tm, fieldsToTranslate, Object.keys(fieldsToTranslate), code, tmKey
|
|
952
|
+
);
|
|
953
|
+
// Validate cached hits BEFORE serving — an entry stored by a
|
|
954
|
+
// gateless pipeline (hollowed titles were cached here) must not
|
|
955
|
+
// outlive the gate. Failing hits are evicted and re-billed.
|
|
956
|
+
for (const [field, cachedValue] of Object.entries(fmHits)) {
|
|
957
|
+
if (checkContentPreservation(fieldsToTranslate[field], cachedValue)) {
|
|
958
|
+
evictTM(tm, fieldsToTranslate[field], code, tmKey);
|
|
959
|
+
delete fmHits[field];
|
|
960
|
+
fmMisses.push(field);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
Object.assign(translatedFields, fmHits);
|
|
964
|
+
|
|
965
|
+
if (fmMisses.length > 0) {
|
|
966
|
+
if (!apiKey) {
|
|
967
|
+
// No API key — fail loud
|
|
968
|
+
throw new Error(
|
|
969
|
+
`Docusaurus content sync for ${code}: no API key available.\n` +
|
|
970
|
+
' Set OPENROUTER_API_KEY in .env.local to translate content.'
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
const fmResult = await translateBatch(
|
|
974
|
+
fmMisses, fieldsToTranslate, pairConfig,
|
|
975
|
+
{ apiKey, model: pairConfig.model, batchSize: pairConfig.batchSize || 30 },
|
|
976
|
+
);
|
|
977
|
+
if (fmResult) {
|
|
978
|
+
// Content-preservation gate — Phase 2 front matter reached
|
|
979
|
+
// disk AND the TM with no validation at all, so a hollowed
|
|
980
|
+
// page title was written silently and then cached forever.
|
|
981
|
+
// Throwing skips the file and leaves its lock entry alone,
|
|
982
|
+
// so the next sync retries it.
|
|
983
|
+
for (const [field, value] of Object.entries(fmResult)) {
|
|
984
|
+
const sourceValue = fieldsToTranslate[field];
|
|
985
|
+
if (typeof value !== 'string' || typeof sourceValue !== 'string') continue;
|
|
986
|
+
const hollowed = checkContentPreservation(sourceValue, value);
|
|
987
|
+
if (hollowed) {
|
|
988
|
+
throw new Error(
|
|
989
|
+
`Docusaurus content sync for ${code}: front matter "${field}" — ${hollowed.reason}.\n` +
|
|
990
|
+
` source: ${JSON.stringify(sourceValue)}\n` +
|
|
991
|
+
` got: ${JSON.stringify(value)}\n` +
|
|
992
|
+
' Nothing was written or cached. If this is a low-coverage target\n' +
|
|
993
|
+
' language, the model has no vocabulary for this string.'
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
Object.assign(translatedFields, fmResult);
|
|
998
|
+
// Cache only what the API actually returned (the same
|
|
999
|
+
// non-null check that gates writing it to the target file).
|
|
1000
|
+
for (const [field, value] of Object.entries(fmResult)) {
|
|
1001
|
+
if (typeof value === 'string' && typeof fieldsToTranslate[field] === 'string') {
|
|
1002
|
+
storeTM(tm, fieldsToTranslate[field], code, tmKey, value);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
} else {
|
|
1006
|
+
// Front matter translation failed — loud error
|
|
1007
|
+
throw new Error(
|
|
1008
|
+
`Docusaurus content sync for ${code}: front matter translation returned no results.\n` +
|
|
1009
|
+
' Check your API key and method configuration.'
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Translate body — whole-body TM first (a revert or a lock-loss
|
|
1016
|
+
// re-run is free), then block-level TM + ONE batched API call
|
|
1017
|
+
// for the missed blocks ('block' mode), or the whole-page prompt
|
|
1018
|
+
// ('page' mode). If ANY part fails, the file fails whole:
|
|
1019
|
+
// nothing partial is written, nothing is cached.
|
|
1020
|
+
let translatedBody = body;
|
|
1021
|
+
let bodyUsedFallback = false;
|
|
1022
|
+
if (body.trim()) {
|
|
1023
|
+
const wholeBodyCached = lookupTMValidated(tm, body, code, tmKey,
|
|
1024
|
+
(src, cached) => !checkContentPreservation(src, cached));
|
|
1025
|
+
if (wholeBodyCached !== null) {
|
|
1026
|
+
translatedBody = wholeBodyCached;
|
|
1027
|
+
} else {
|
|
1028
|
+
const segMode = pairConfig.contentSegmentation || config.contentSegmentation || 'block';
|
|
1029
|
+
const { protectedBody, blocks } = protectBlocks(body);
|
|
1030
|
+
const promptOptions = {
|
|
1031
|
+
sourceLanguageName: DEFAULT_REGISTERS[inputLocale]?.name || inputLocale,
|
|
1032
|
+
promptContext: pairConfig.promptContext || null,
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
// Block stores are deferred until the reassembled body passes
|
|
1036
|
+
// the orphaned-placeholder check — the TM must never hold a
|
|
1037
|
+
// value that would fail the gate on re-serve.
|
|
1038
|
+
const pendingBlockStores = [];
|
|
1039
|
+
|
|
1040
|
+
if (segMode === 'page') {
|
|
1041
|
+
// Whole-page prompt — today's single-call behavior.
|
|
1042
|
+
if (!apiKey) {
|
|
1043
|
+
throw new Error(
|
|
1044
|
+
`Docusaurus body translation for ${code}: no API key available.\n` +
|
|
1045
|
+
' Set OPENROUTER_API_KEY in .env.local to translate content.'
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const prompt = buildContentPrompt(protectedBody, pairConfig, promptOptions);
|
|
1049
|
+
const bodyResult = await translateRawContent(prompt, { apiKey, pairConfig });
|
|
1050
|
+
if (!bodyResult) {
|
|
1051
|
+
throw new Error(
|
|
1052
|
+
`Docusaurus body for ${code}: translation returned no results.\n` +
|
|
1053
|
+
' Check your API key and method configuration.'
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
translatedBody = restoreBlocks(bodyResult, blocks);
|
|
1057
|
+
} else {
|
|
1058
|
+
// Block mode: segment the PROTECTED body (placeholders are
|
|
1059
|
+
// single tokens, so they can never be split), serve blocks
|
|
1060
|
+
// from the TM, and batch the misses into one API call.
|
|
1061
|
+
const segments = splitBlocks(protectedBody);
|
|
1062
|
+
|
|
1063
|
+
// TM keys use each block's RESTORED source text: placeholder
|
|
1064
|
+
// numbering is positional per file, so the protected text of
|
|
1065
|
+
// an identical paragraph differs across files/edits, while
|
|
1066
|
+
// the restored text is stable and self-contained. Cached
|
|
1067
|
+
// values are likewise restored — they must never carry
|
|
1068
|
+
// another file's placeholder ids.
|
|
1069
|
+
const rendered = segments.map(seg => ({
|
|
1070
|
+
seg,
|
|
1071
|
+
source: restoreBlocks(seg.text, blocks),
|
|
1072
|
+
out: null,
|
|
1073
|
+
}));
|
|
1074
|
+
|
|
1075
|
+
const missed = [];
|
|
1076
|
+
for (const r of rendered) {
|
|
1077
|
+
if (r.seg.type !== 'translatable') {
|
|
1078
|
+
// Separators + passthrough blocks: copied verbatim, never billed.
|
|
1079
|
+
r.out = r.source;
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
const cached = lookupTMValidated(tm, r.source, code, tmKey,
|
|
1083
|
+
(src, c) => !checkContentPreservation(src, c));
|
|
1084
|
+
if (cached !== null) {
|
|
1085
|
+
r.out = cached;
|
|
1086
|
+
} else {
|
|
1087
|
+
missed.push(r);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (missed.length > 0) {
|
|
1092
|
+
if (!apiKey) {
|
|
1093
|
+
throw new Error(
|
|
1094
|
+
`Docusaurus body translation for ${code}: no API key available.\n` +
|
|
1095
|
+
' Set OPENROUTER_API_KEY in .env.local to translate content.'
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
// Self-repair ladder (translateBlockBatchResilient): full
|
|
1099
|
+
// batch → one missing-segments-only retry → honest
|
|
1100
|
+
// '[EN] '-prefixed source for anything still missing. A
|
|
1101
|
+
// duplicate/unknown marker or an empty first response
|
|
1102
|
+
// still fails the file whole.
|
|
1103
|
+
const { blocks: translatedBlocks, fellBack } =
|
|
1104
|
+
await translateBlockBatchResilient({
|
|
1105
|
+
texts: missed.map(r => r.seg.text),
|
|
1106
|
+
buildPrompt: (texts) => buildBlockBatchPrompt(
|
|
1107
|
+
texts, pairConfig, { ...promptOptions, pageTitle }),
|
|
1108
|
+
callModel: (prompt) => translateRawContent(prompt, { apiKey, pairConfig }),
|
|
1109
|
+
fallbackPrefix: config.fallbackPrefix,
|
|
1110
|
+
});
|
|
1111
|
+
const fellBackSet = new Set(fellBack);
|
|
1112
|
+
if (fellBack.length > 0) {
|
|
1113
|
+
bodyUsedFallback = true;
|
|
1114
|
+
output.warn(
|
|
1115
|
+
`Docusaurus body for ${code}: ${fellBack.length} of ${missed.length} ` +
|
|
1116
|
+
`block(s) missing from the model response after a retry — written as ` +
|
|
1117
|
+
`'${config.fallbackPrefix}'-prefixed source. Not cached, lock not ` +
|
|
1118
|
+
`advanced: the next sync retries just those block(s).`
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
missed.forEach((r, i) => {
|
|
1122
|
+
r.out = restoreBlocks(translatedBlocks[i], blocks);
|
|
1123
|
+
// Fallen-back segments are never TM-cached — an error
|
|
1124
|
+
// cached is an error forever (they re-bill next sync).
|
|
1125
|
+
if (!fellBackSet.has(i)) {
|
|
1126
|
+
pendingBlockStores.push({ source: r.source, translation: r.out });
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Reassemble in order with the source's exact separators.
|
|
1132
|
+
translatedBody = rendered.map(r => r.out).join('');
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Orphaned-placeholder check on the REASSEMBLED body — the
|
|
1136
|
+
// same gate for both modes.
|
|
1137
|
+
if (hasOrphanedPlaceholders(translatedBody)) {
|
|
1138
|
+
throw new Error(
|
|
1139
|
+
`Docusaurus body for ${code}: placeholder corruption detected.\n` +
|
|
1140
|
+
' Code blocks were corrupted during translation.'
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Content-preservation check on the REASSEMBLED body — same
|
|
1145
|
+
// lane, same reasoning as the front matter above. Skipped for a
|
|
1146
|
+
// fallback body: it deliberately carries '[EN] '-prefixed
|
|
1147
|
+
// source text and is neither cached nor lock-advanced already.
|
|
1148
|
+
const bodyHollowed = !bodyUsedFallback && checkContentPreservation(body, translatedBody);
|
|
1149
|
+
if (bodyHollowed) {
|
|
1150
|
+
throw new Error(
|
|
1151
|
+
`Docusaurus body for ${code}: ${bodyHollowed.reason}.\n` +
|
|
1152
|
+
' Nothing was written or cached. If this is a low-coverage target\n' +
|
|
1153
|
+
' language, the model has no vocabulary for this text.'
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Store per-block AND whole-body entries only after the check
|
|
1158
|
+
// passes (whole-body makes reverts/lock-loss re-runs free). A
|
|
1159
|
+
// fallback body is NEVER stored whole — it contains
|
|
1160
|
+
// untranslated '[EN] ' text.
|
|
1161
|
+
for (const s of pendingBlockStores) {
|
|
1162
|
+
storeTM(tm, s.source, code, tmKey, s.translation);
|
|
1163
|
+
}
|
|
1164
|
+
if (!bodyUsedFallback) {
|
|
1165
|
+
storeTM(tm, body, code, tmKey, translatedBody);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Reassemble and write
|
|
1171
|
+
const contentOutput = reassembleContentFile({
|
|
1172
|
+
rawFrontMatter, translatedFields, translatedBody,
|
|
1173
|
+
hasFrontMatter, frontMatterFormat,
|
|
1174
|
+
});
|
|
1175
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
1176
|
+
fs.writeFileSync(targetPath, contentOutput, 'utf-8');
|
|
1177
|
+
|
|
1178
|
+
totalContent++;
|
|
1179
|
+
// Advance the manifest entry ONLY here, on success. A failed item
|
|
1180
|
+
// keeps its old entry (or none), so it re-fires next sync — as
|
|
1181
|
+
// does a fallback body (its re-fire is TM-cheap: only the
|
|
1182
|
+
// fallen-back segment re-bills).
|
|
1183
|
+
if (!bodyUsedFallback) {
|
|
1184
|
+
updatedDocuManifest[manifestKey] = sourceHash;
|
|
1185
|
+
manifestDirty = true;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
} catch (contentErr) {
|
|
1189
|
+
contentFailures++;
|
|
1190
|
+
output.error(`${dirName}/${relPath} → ${code} — ${contentErr.message}`);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Progress reporting
|
|
1194
|
+
completed++;
|
|
1195
|
+
const pct = Math.round(100 * completed / totalWork);
|
|
1196
|
+
const elapsedMs = Date.now() - syncStartTime;
|
|
1197
|
+
const msPerItem = elapsedMs / completed;
|
|
1198
|
+
const remainingMs = msPerItem * (totalWork - completed);
|
|
1199
|
+
const remainingSec = Math.ceil(remainingMs / 1000);
|
|
1200
|
+
const etaStr = remainingSec > 5 ? ` (~${remainingSec}s left)` : '';
|
|
1201
|
+
const tag = contentFailures > 0 && completed === totalWork
|
|
1202
|
+
? 'FAIL'
|
|
1203
|
+
: action === 're-translate' ? 'RE-TRANSLATE' : action === 'changed' ? 'CHANGED' : 'OK';
|
|
1204
|
+
// Show [FAIL] for items that just errored (contentErr was caught above)
|
|
1205
|
+
const itemFailed = updatedDocuManifest[manifestKey] !== sourceHash;
|
|
1206
|
+
const displayTag = itemFailed && !dryRun ? 'FAIL' : tag;
|
|
1207
|
+
output.raw(` [${completed}/${totalWork}] (${pct}%) ${dirName}/${relPath} → ${code} [${displayTag}]${etaStr}`);
|
|
1208
|
+
|
|
1209
|
+
// Incremental manifest write
|
|
1210
|
+
if (completed % MANIFEST_WRITE_INTERVAL === 0) {
|
|
1211
|
+
writeManifestIfDirty();
|
|
1212
|
+
}
|
|
1213
|
+
}, { concurrency });
|
|
1214
|
+
|
|
1215
|
+
// Final manifest write
|
|
1216
|
+
writeManifestIfDirty();
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Also persist any skipped-file hash recordings
|
|
1220
|
+
if (!dryRun) {
|
|
1221
|
+
const sorted = {};
|
|
1222
|
+
for (const key of Object.keys(updatedDocuManifest).sort()) {
|
|
1223
|
+
sorted[key] = updatedDocuManifest[key];
|
|
1224
|
+
}
|
|
1225
|
+
fs.writeFileSync(contentLockPath, JSON.stringify(sorted, null, 2) + '\n', 'utf-8');
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (totalContent > 0 || totalContentSkipped > 0 || totalContentRetranslated > 0) {
|
|
1229
|
+
const action = dryRun ? 'Would create' : 'Created';
|
|
1230
|
+
const retranslateNote = totalContentRetranslated > 0 ? ` (${totalContentRetranslated} re-translated)` : '';
|
|
1231
|
+
output.ok(`${action} ${totalContent} content file(s)${retranslateNote}, ${totalContentSkipped} unchanged`);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Fail loud if any content translations failed — do NOT exit 0
|
|
1235
|
+
if (contentFailures > 0) {
|
|
1236
|
+
throw new Error(
|
|
1237
|
+
`${contentFailures} content translation(s) failed. ` +
|
|
1238
|
+
`Re-run sync to retry failed files (completed files are cached).`
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Save TM if it was mutated during this Docusaurus sync (stores OR
|
|
1244
|
+
// evictions — a size check would miss eviction-only runs and same-key
|
|
1245
|
+
// replacements). Skip when --no-tm is active.
|
|
1246
|
+
if (!dryRun && !noTM && isTMDirty(tm)) {
|
|
1247
|
+
const tmFinalSize = tmSize(tm);
|
|
1248
|
+
const delta = tmFinalSize - tmInitialSize;
|
|
1249
|
+
saveTM(cwd, tm);
|
|
1250
|
+
output.info(`[TM] Saved ${tmFinalSize} entries (${delta >= 0 ? '+' + delta : delta} this sync)`);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
output.raw('');
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
export { runDocusaurusSync, discoverDocusaurusJSONFiles };
|