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.
Files changed (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. 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 };