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,731 @@
1
+ /**
2
+ * Content sync — translates Hugo Markdown content files.
3
+ *
4
+ * WHY THIS EXISTS: This was extracted from sync.js to reduce the
5
+ * god-module's line count and give content translation its own
6
+ * testable, focused module.
7
+ *
8
+ * v3 PAIR GRAPH: This module now accepts the resolved pair Map
9
+ * (from pairs.js + plugins.js) rather than the v2 `languages` object.
10
+ * Each pair carries its method, model, register, and name — the same
11
+ * pairConfig that the key-value sync path uses. This ensures method
12
+ * dispatch is consistent across both key-value and content translation.
13
+ *
14
+ * Pipeline for each source file × target pair:
15
+ * 1. Check if translated version already exists (skip if so)
16
+ * 2. Parse front matter and body
17
+ * 3. Protect code blocks, shortcodes, and HTML
18
+ * 4. Translate front matter fields + body via pair's configured method
19
+ * (Translation Memory consulted first; body segmented into blocks
20
+ * by default — see below)
21
+ * 5. Check for placeholder corruption (orphaned ⟦PROTECTED_N⟧ tokens)
22
+ * 6. Reassemble and write the target file
23
+ *
24
+ * TRANSLATION MEMORY: The change-detection lockfile is per-FILE (SHA-256 of
25
+ * the whole source file), so editing one front-matter field used to re-pay
26
+ * the API for the entire document — front matter AND body. The TM makes the
27
+ * unchanged parts free: front-matter fields are cached per field source text
28
+ * (exactly like key-value sync keys) and the body is cached at TWO
29
+ * granularities. A whole-body entry makes a reverted or duplicate body free;
30
+ * beneath it, the body is split into top-level blocks (segment.js) and each
31
+ * block is cached on its own restored source text — a one-paragraph edit
32
+ * re-pays only that paragraph, with all missed blocks batched into ONE
33
+ * translateRawContent call ('block' mode, the default;
34
+ * contentSegmentation: 'page' keeps the single whole-body prompt). Only
35
+ * results that pass the existing checks (non-null API result; structural
36
+ * block-batch validation; body placeholder-corruption check on the
37
+ * REASSEMBLED body) are stored, and --no-tm bypasses the cache entirely.
38
+ */
39
+
40
+ import fs from 'node:fs';
41
+ import path from 'node:path';
42
+ import crypto from 'node:crypto';
43
+ import { translateBatch, translateRawContent, getMethod } from './translate.js';
44
+ import { checkContentPreservation } from './validate.js';
45
+ import { loadTM, saveTM, lookupTM, lookupTMValidated, storeTM, evictTM, isTMDirty, tmSize, tmMethodKey, partitionByTM } from './tm.js';
46
+ import { output } from './output.js';
47
+ import { DEFAULT_REGISTERS } from './registers.js';
48
+ import { isPathContained } from './security.js';
49
+ import { pMap } from './concurrent.js';
50
+ import {
51
+ discoverContentFiles,
52
+ getTargetContentPath,
53
+ parseContentFile,
54
+ protectBlocks,
55
+ restoreBlocks,
56
+ hasOrphanedPlaceholders,
57
+ buildContentPrompt,
58
+ reassembleContentFile,
59
+ findUntranslatableNestedFields,
60
+ DEFAULT_TRANSLATABLE_FIELDS,
61
+ } from './content.js';
62
+ import {
63
+ splitBlocks, buildBlockBatchPrompt, parseBlockBatchResponse,
64
+ translateBlockBatchResilient, assertSegmentationMode,
65
+ } from './segment.js';
66
+
67
+ /**
68
+ * Run content sync — translate Hugo Markdown content files.
69
+ *
70
+ * @param {object} options
71
+ * @param {string} options.contentDir - Path to Hugo content directory
72
+ * @param {string} options.sourceLocale - Source language code
73
+ * @param {Map<string, object>} options.pairs - Resolved pair graph (pairKey → pairConfig)
74
+ * @param {string[]|null} options.translatableFields - Front matter fields to translate
75
+ * @param {string|null} options.apiKey - OpenRouter API key. Only required for
76
+ * OpenRouter-routed methods (llm, llm-coached). Direct-provider methods
77
+ * (gemini, openai, anthropic, deepl, …) resolve their own env keys inside
78
+ * their method classes — a missing OpenRouter key must NOT block them.
79
+ * @param {boolean} options.dryRun - Whether to write files
80
+ * @param {boolean} [options.noTM] - Bypass the Translation Memory (--no-tm):
81
+ * every segment goes to the API and nothing is cached.
82
+ */
83
+ async function runContentSync(options) {
84
+ const {
85
+ contentDir,
86
+ sourceLocale,
87
+ pairs,
88
+ translatableFields,
89
+ apiKey,
90
+ dryRun = false,
91
+ noTM = false,
92
+ cwd = process.cwd(),
93
+ concurrency = 48,
94
+ // The honest-fallback marker (config.js default). Written in front of a
95
+ // block's SOURCE text when the model drops its segment twice — visible,
96
+ // never silent, never cached (see translateBlockBatchResilient).
97
+ fallbackPrefix = '[EN] ',
98
+ } = options;
99
+
100
+ if (!fs.existsSync(contentDir)) {
101
+ output.warn(`Content directory not found: ${contentDir}`);
102
+ return;
103
+ }
104
+
105
+ const sourceFiles = discoverContentFiles(contentDir, sourceLocale);
106
+ if (sourceFiles.length === 0) {
107
+ output.info('No source content files found.');
108
+ return;
109
+ }
110
+
111
+ const fieldsList = translatableFields || DEFAULT_TRANSLATABLE_FIELDS;
112
+
113
+ // Warn at most once per source file about translatable-looking front matter
114
+ // we can't reach (arrays / nested blocks). Never silently drop them. The
115
+ // check + add are synchronous (no await between), so this is race-free.
116
+ const warnedFrontMatter = new Set();
117
+
118
+ // Load content hash manifest for change detection.
119
+ // Maps "relPath:locale" → SHA-256 of source file at last sync time.
120
+ // When the source changes, the hash won't match and the target is re-translated.
121
+ const contentManifest = readContentManifest(cwd);
122
+ const updatedManifest = { ...contentManifest };
123
+
124
+ // Load Translation Memory — same store as key-value sync (.champollion/tm.json).
125
+ // Front-matter fields and bodies whose source text hasn't changed are served
126
+ // from cache instead of hitting the API. With --no-tm we use a throwaway
127
+ // in-memory object (everything misses, nothing is persisted) — mirroring
128
+ // the key-value path in sync.js.
129
+ //
130
+ // Safe under pMap concurrency: storeTM is a synchronous property assignment
131
+ // and Node is single-threaded, so writes can't interleave between awaits.
132
+ const tm = noTM ? { _meta: { version: 1 } } : loadTM(cwd);
133
+ const tmInitialSize = tmSize(tm);
134
+ if (noTM) {
135
+ output.info('Content sync: Translation Memory disabled (--no-tm)');
136
+ } else if (tmInitialSize > 0) {
137
+ output.info(`Content sync: Translation Memory loaded (${tmInitialSize} cached entries)`);
138
+ }
139
+ let tmSegmentHits = 0; // front-matter fields + bodies served from cache
140
+
141
+ // Per-pair readiness cache — GATE FIX for the prelaunch audit finding:
142
+ // the old gate hard-required the OpenRouter `apiKey` for every pair
143
+ // ("Set OPENROUTER_API_KEY in .env.local") and threw before ever trying
144
+ // the configured provider, even when the pair's method is a direct
145
+ // provider (gemini, openai, deepl, …) with its own valid env key.
146
+ //
147
+ // Instead, ask the pair's method what IT needs via checkReadiness():
148
+ // only OpenRouter-routed methods (llm, llm-coached) require the
149
+ // OpenRouter key; direct providers check their own env vars.
150
+ //
151
+ // Checked lazily (only when a pair actually has work to do) so keyless
152
+ // skip/dry-run flows keep working, and cached per pair so a readiness
153
+ // check that hits the network (apertium, libretranslate, external)
154
+ // runs at most once per pair — not once per file × pair.
155
+ const readinessCache = new Map();
156
+ const checkPairReadiness = (pairKey, pairConfig) => {
157
+ if (!readinessCache.has(pairKey)) {
158
+ const method = getMethod(pairConfig.method || 'llm', pairConfig);
159
+ readinessCache.set(pairKey, Promise.resolve(method.checkReadiness({ apiKey, cwd })));
160
+ }
161
+ return readinessCache.get(pairKey);
162
+ };
163
+
164
+ // Sort pair entries for deterministic output ordering
165
+ const pairEntries = [...pairs.entries()].sort(([a], [b]) => a.localeCompare(b));
166
+
167
+ // Segmentation mode is resolved per pair by pairs.js (langConfig/pair
168
+ // override → config fallback). Reject invalid values up front — before
169
+ // any file is touched — rather than mid-sync.
170
+ for (const [pairKey, pairConfig] of pairEntries) {
171
+ assertSegmentationMode(pairConfig.contentSegmentation, `pair "${pairKey}"`);
172
+ }
173
+
174
+ output.info(`Content sync: ${sourceFiles.length} source file(s) × ${pairEntries.length} language(s), concurrency: ${concurrency}`);
175
+ if (dryRun) output.info('Dry-run mode — no content files will be written.');
176
+
177
+ let translated = 0;
178
+ let retranslated = 0;
179
+ let skipped = 0;
180
+
181
+ const syncStartTime = Date.now();
182
+
183
+ for (let fileIdx = 0; fileIdx < sourceFiles.length; fileIdx++) {
184
+ const sourcePath = sourceFiles[fileIdx];
185
+ const relPath = path.relative(contentDir, sourcePath);
186
+ const fileNum = fileIdx + 1;
187
+ const totalFiles = sourceFiles.length;
188
+
189
+ // ETA calculation
190
+ let etaStr = '';
191
+ if (fileIdx > 0) {
192
+ const elapsedMs = Date.now() - syncStartTime;
193
+ const msPerFile = elapsedMs / fileIdx;
194
+ const remainingMs = msPerFile * (totalFiles - fileIdx);
195
+ const remainingMin = Math.ceil(remainingMs / 60000);
196
+ etaStr = remainingMin > 1 ? ` (~${remainingMin} min remaining)` : '';
197
+ }
198
+ output.info(`[${fileNum}/${totalFiles}] ${relPath}${etaStr}`);
199
+
200
+ // Read source file once — shared across all locale translations
201
+ const raw = fs.readFileSync(sourcePath, 'utf-8');
202
+ const currentSourceHash = hashFileContent(sourcePath);
203
+
204
+ // Parallelize across locales for this file
205
+ const perPairResults = await pMap(pairEntries, async ([pairKey, pairConfig]) => {
206
+ const code = pairConfig.target;
207
+ const result = { translated: false, fallback: false, skipped: false, retranslated: false };
208
+
209
+ const targetPath = getTargetContentPath(sourcePath, code, sourceLocale);
210
+
211
+ // Security: verify target path stays within content directory
212
+ if (!isPathContained(targetPath, contentDir)) {
213
+ output.error(`${code} — refusing to write outside content directory`);
214
+ return result;
215
+ }
216
+
217
+ // Change detection for existing files
218
+ const manifestKey = `${relPath}:${code}`;
219
+
220
+ if (fs.existsSync(targetPath)) {
221
+ const storedHash = contentManifest[manifestKey];
222
+ if (storedHash && storedHash === currentSourceHash) {
223
+ result.skipped = true;
224
+ return result;
225
+ }
226
+ if (storedHash && storedHash !== currentSourceHash) {
227
+ output.info(`${code} — source updated, re-translating`);
228
+ result.retranslated = true;
229
+ } else if (!storedHash) {
230
+ // No stored hash — check if this file was generated by a prior
231
+ // champollion run (contains [EN] fallback markers) or is a genuine
232
+ // hand-translated file that should be preserved.
233
+ //
234
+ // BUG FIX: Previously, this always skipped hashless files.
235
+ // This meant [EN] fallback files were permanently cached and
236
+ // never retried — the user had to manually delete them.
237
+ const existingContent = fs.readFileSync(targetPath, 'utf-8');
238
+ const isLegacyFallback = existingContent.includes('[EN] ');
239
+ if (!isLegacyFallback) {
240
+ updatedManifest[manifestKey] = currentSourceHash;
241
+ result.skipped = true;
242
+ return result;
243
+ }
244
+ output.info(`${code} — replacing [EN] fallback`);
245
+ }
246
+ }
247
+
248
+ if (dryRun) {
249
+ const targetRel = path.relative(contentDir, targetPath);
250
+ output.info(`Would create: ${targetRel}`);
251
+ result.translated = true;
252
+ return result;
253
+ }
254
+
255
+ // Key gate — require only what the pair's resolved method actually
256
+ // needs (see readinessCache above). Fail loud with the method's own
257
+ // reason instead of blaming OPENROUTER_API_KEY unconditionally.
258
+ const readiness = await checkPairReadiness(pairKey, pairConfig);
259
+ if (!readiness.ready) {
260
+ throw new Error(
261
+ `Content sync for ${code}: method "${pairConfig.method || 'llm'}" cannot run — no API key or unmet prerequisite.\n` +
262
+ ` ${readiness.reason}\n` +
263
+ ' Set the key it names in .env.local (or your environment) to translate content.'
264
+ );
265
+ }
266
+
267
+ // Parse source (shared raw content, parsing is stateless)
268
+ const { frontMatter, rawFrontMatter, body, hasFrontMatter, frontMatterFormat } = parseContentFile(raw);
269
+
270
+ // Never silently drop translatable-looking nested/array front matter
271
+ // (e.g. `related:` lists) — surface it once per source file.
272
+ if (hasFrontMatter && !warnedFrontMatter.has(sourcePath)) {
273
+ warnedFrontMatter.add(sourcePath);
274
+ const skipped = findUntranslatableNestedFields(rawFrontMatter);
275
+ if (skipped.length > 0) {
276
+ output.warn(
277
+ `${relPath}: front matter field(s) [${skipped.join(', ')}] are arrays/nested — ` +
278
+ `left untranslated. Flatten them to top-level strings to translate, or translate by hand.`
279
+ );
280
+ }
281
+ }
282
+
283
+ // TM entries are keyed on the full method key (method|model|register|
284
+ // coaching) — switching any of those must re-translate, not re-serve.
285
+ const tmKey = tmMethodKey(pairConfig);
286
+
287
+ // Translate front matter fields — TM first, API for the misses.
288
+ // Each field is cached on its own source text, exactly like a
289
+ // key-value sync key: a title edit re-pays only the title.
290
+ const translatedFields = {};
291
+ if (hasFrontMatter) {
292
+ const fieldsToTranslate = {};
293
+ for (const field of fieldsList) {
294
+ if (frontMatter[field] && typeof frontMatter[field] === 'string') {
295
+ fieldsToTranslate[field] = frontMatter[field];
296
+ }
297
+ }
298
+
299
+ const { hits: fmHits, misses: fmMisses } = partitionByTM(
300
+ tm, fieldsToTranslate, Object.keys(fieldsToTranslate), code, tmKey
301
+ );
302
+ // Validate cached hits BEFORE serving. A cache is a time machine: an
303
+ // entry stored before the content-preservation gate existed re-serves
304
+ // exactly what that gate now rejects — hollowed titles sat here and
305
+ // were re-served forever, because TM hits skipped every gate. A hit
306
+ // that fails today's gate is evicted and re-billed as a miss.
307
+ for (const [field, cachedValue] of Object.entries(fmHits)) {
308
+ if (checkContentPreservation(fieldsToTranslate[field], cachedValue)) {
309
+ evictTM(tm, fieldsToTranslate[field], code, tmKey);
310
+ delete fmHits[field];
311
+ fmMisses.push(field);
312
+ }
313
+ }
314
+ Object.assign(translatedFields, fmHits);
315
+ tmSegmentHits += Object.keys(fmHits).length;
316
+
317
+ if (fmMisses.length > 0) {
318
+ output.progress(` [SYNC] ${code} front matter (${pairConfig.method})...`);
319
+ const fmResult = await translateBatch(
320
+ fmMisses,
321
+ fieldsToTranslate,
322
+ pairConfig,
323
+ { apiKey, cwd, model: pairConfig.model, batchSize: pairConfig.batchSize || 30 },
324
+ );
325
+ if (fmResult) {
326
+ // Content-preservation gate. Front matter (title, description,
327
+ // summary) went from the API straight to disk AND into the TM
328
+ // with no validation of any kind — which is how a hollowed
329
+ // page title was written silently and then cached. Check before
330
+ // either. Failing the file is consistent with the null-result
331
+ // branch below: nothing written, manifest not advanced, retried.
332
+ for (const [field, value] of Object.entries(fmResult)) {
333
+ const sourceValue = fieldsToTranslate[field];
334
+ if (typeof value !== 'string' || typeof sourceValue !== 'string') continue;
335
+ const hollowed = checkContentPreservation(sourceValue, value);
336
+ if (hollowed) {
337
+ output.raw(' [ERR]');
338
+ throw new Error(
339
+ `Content sync for ${code}: front matter "${field}" — ${hollowed.reason}.\n` +
340
+ ` source: ${JSON.stringify(sourceValue)}\n` +
341
+ ` got: ${JSON.stringify(value)}\n` +
342
+ ' Nothing was written or cached. If this is a low-coverage target language,\n' +
343
+ ' the model has no vocabulary for this string — fix the prompt or the pair.'
344
+ );
345
+ }
346
+ }
347
+ Object.assign(translatedFields, fmResult);
348
+ // Cache only what the API actually returned (the same non-null
349
+ // check that gates writing it to the target file).
350
+ for (const [field, value] of Object.entries(fmResult)) {
351
+ if (typeof value === 'string' && typeof fieldsToTranslate[field] === 'string') {
352
+ storeTM(tm, fieldsToTranslate[field], code, tmKey, value);
353
+ }
354
+ }
355
+ output.raw(' [OK]');
356
+ } else {
357
+ // Front matter translation failed — loud error, skip this file
358
+ output.raw(' [ERR]');
359
+ throw new Error(
360
+ `Content sync for ${code}: front matter translation returned no results.\n` +
361
+ ' Check your API key and method configuration.'
362
+ );
363
+ }
364
+ }
365
+ }
366
+
367
+ // Translate body — whole-body TM first (a reverted or duplicate body
368
+ // is free), then block-level TM + ONE batched API call for the missed
369
+ // blocks ('block' mode, the default), or the whole-page prompt
370
+ // ('page' mode). A structural failure fails the file whole; a segment
371
+ // the model drops TWICE degrades to the honest '[EN] ' fallback for
372
+ // just that block (never cached, lock not advanced — re-fires next
373
+ // sync as a TM-cheap retry). Mirrors docusaurus-sync.js.
374
+ let translatedBody = body;
375
+ let bodyUsedFallback = false;
376
+ if (body.trim()) {
377
+ // Read-time validation: a whole-body entry cached by a gateless
378
+ // pipeline must not be re-served once the gate exists (evicts on fail).
379
+ const cachedBody = lookupTMValidated(tm, body, code, tmKey,
380
+ (src, cached) => !checkContentPreservation(src, cached));
381
+ if (cachedBody !== null) {
382
+ translatedBody = cachedBody;
383
+ tmSegmentHits += 1;
384
+ } else {
385
+ const segMode = pairConfig.contentSegmentation || 'block';
386
+ const { protectedBody, blocks } = protectBlocks(body);
387
+ const promptOptions = {
388
+ sourceLanguageName: DEFAULT_REGISTERS[sourceLocale]?.name || sourceLocale,
389
+ promptContext: pairConfig.promptContext || null,
390
+ };
391
+
392
+ // Block stores are deferred until the reassembled body passes the
393
+ // orphaned-placeholder check — the TM must never hold a value that
394
+ // would fail the gate on re-serve.
395
+ const pendingBlockStores = [];
396
+ let apiCalled = false;
397
+
398
+ if (segMode === 'page') {
399
+ // Whole-page prompt — the pre-segmentation single-call behavior.
400
+ output.progress(` [SYNC] ${code} body (${pairConfig.method})...`);
401
+ apiCalled = true;
402
+ const prompt = buildContentPrompt(protectedBody, pairConfig, promptOptions);
403
+ const bodyResult = await translateRawContent(prompt, {
404
+ apiKey,
405
+ cwd,
406
+ pairConfig,
407
+ });
408
+ if (!bodyResult) {
409
+ // Body translation returned null — loud error
410
+ output.raw(' [ERR]');
411
+ throw new Error(
412
+ `Content sync body for ${code}: translation returned no results.\n` +
413
+ ' Check your API key and method configuration.'
414
+ );
415
+ }
416
+ translatedBody = restoreBlocks(bodyResult, blocks);
417
+ } else {
418
+ // Block mode: segment the PROTECTED body (placeholders are
419
+ // single tokens, so they can never be split), serve blocks from
420
+ // the TM, and batch the misses into one API call.
421
+ const segments = splitBlocks(protectedBody);
422
+
423
+ // TM keys use each block's RESTORED source text: placeholder
424
+ // numbering is positional per file, so the protected text of an
425
+ // identical paragraph differs across files/edits, while the
426
+ // restored text is stable and self-contained. Cached values are
427
+ // likewise restored — they must never carry another file's
428
+ // placeholder ids.
429
+ const rendered = segments.map(seg => ({
430
+ seg,
431
+ source: restoreBlocks(seg.text, blocks),
432
+ out: null,
433
+ }));
434
+
435
+ const missed = [];
436
+ for (const r of rendered) {
437
+ if (r.seg.type !== 'translatable') {
438
+ // Separators + passthrough blocks: copied verbatim, never billed.
439
+ r.out = r.source;
440
+ continue;
441
+ }
442
+ const cached = lookupTMValidated(tm, r.source, code, tmKey,
443
+ (src, c) => !checkContentPreservation(src, c));
444
+ if (cached !== null) {
445
+ r.out = cached;
446
+ tmSegmentHits += 1;
447
+ } else {
448
+ missed.push(r);
449
+ }
450
+ }
451
+
452
+ if (missed.length > 0) {
453
+ output.progress(` [SYNC] ${code} body (${missed.length} block(s), ${pairConfig.method})...`);
454
+ apiCalled = true;
455
+ // Terminology context for the block-batch prompt: the page's
456
+ // title (front matter first, else the first H1 in the body).
457
+ const pageTitle =
458
+ (hasFrontMatter && typeof frontMatter.title === 'string' ? frontMatter.title : null)
459
+ || (body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null);
460
+ // Self-repair ladder (translateBlockBatchResilient): full
461
+ // batch → one missing-segments-only retry → honest
462
+ // '[EN] '-prefixed source for anything still missing. A
463
+ // duplicate/unknown marker (untrustworthy mapping) or an
464
+ // empty first response still fails the file whole.
465
+ let batchOutcome;
466
+ try {
467
+ batchOutcome = await translateBlockBatchResilient({
468
+ texts: missed.map(r => r.seg.text),
469
+ buildPrompt: (texts) => buildBlockBatchPrompt(
470
+ texts, pairConfig, { ...promptOptions, pageTitle }),
471
+ callModel: (prompt) => translateRawContent(prompt, {
472
+ apiKey,
473
+ cwd,
474
+ pairConfig,
475
+ }),
476
+ fallbackPrefix,
477
+ });
478
+ } catch (batchErr) {
479
+ output.raw(' [ERR]');
480
+ throw batchErr;
481
+ }
482
+ const { blocks: translatedBlocks, fellBack } = batchOutcome;
483
+ const fellBackSet = new Set(fellBack);
484
+ if (fellBack.length > 0) {
485
+ bodyUsedFallback = true;
486
+ output.raw(' [FALLBACK]');
487
+ output.warn(
488
+ `Content sync body for ${code}: ${fellBack.length} of ${missed.length} ` +
489
+ `block(s) missing from the model response after a retry — written as ` +
490
+ `'${fallbackPrefix}'-prefixed source. Not cached, lock not advanced: ` +
491
+ `the next sync retries just those block(s).`
492
+ );
493
+ }
494
+ missed.forEach((r, i) => {
495
+ r.out = restoreBlocks(translatedBlocks[i], blocks);
496
+ // Fallen-back segments are never TM-cached — an error cached
497
+ // is an error forever (they re-bill on the next sync).
498
+ if (!fellBackSet.has(i)) {
499
+ pendingBlockStores.push({ source: r.source, translation: r.out });
500
+ }
501
+ });
502
+ }
503
+
504
+ // Reassemble in order with the source's exact separators.
505
+ translatedBody = rendered.map(r => r.out).join('');
506
+ }
507
+
508
+ // Orphaned-placeholder check on the REASSEMBLED body — the same
509
+ // gate for both modes.
510
+ if (hasOrphanedPlaceholders(translatedBody)) {
511
+ // Placeholder corruption — loud error, skip this file
512
+ output.error('PLACEHOLDER CORRUPTION');
513
+ throw new Error(
514
+ `Content sync body for ${code}: placeholder corruption detected.\n` +
515
+ ' Code blocks were corrupted during translation. Retry or report this issue.'
516
+ );
517
+ }
518
+
519
+ // Content-preservation check on the REASSEMBLED body. This lane
520
+ // never ran the quality gate at all — translateBatch/
521
+ // translateRawContent output went straight to disk AND into the TM
522
+ // — so a body hollowed of its letters was written silently and then
523
+ // re-served from cache forever. Throwing here matches the
524
+ // placeholder gate above: the file is skipped, its manifest entry is
525
+ // not advanced, and nothing is cached, so the next sync retries.
526
+ const bodyHollowed = !bodyUsedFallback && checkContentPreservation(body, translatedBody);
527
+ if (bodyHollowed) {
528
+ output.error('CONTENT LOSS');
529
+ throw new Error(
530
+ `Content sync body for ${code}: ${bodyHollowed.reason}.\n` +
531
+ ' Nothing was written or cached. If this is a low-coverage target language,\n' +
532
+ ' the model has no vocabulary for this text — fix the prompt or the pair, not the gate.'
533
+ );
534
+ }
535
+
536
+ // Store per-block AND whole-body entries only AFTER the corruption
537
+ // check — the whole-body entry makes reverts/duplicates free. A
538
+ // fallback body is NEVER stored whole: it contains untranslated
539
+ // '[EN] ' text, and an error cached is an error forever.
540
+ for (const s of pendingBlockStores) {
541
+ storeTM(tm, s.source, code, tmKey, s.translation);
542
+ }
543
+ if (!bodyUsedFallback) {
544
+ storeTM(tm, body, code, tmKey, translatedBody);
545
+ }
546
+ if (apiCalled && !bodyUsedFallback) output.raw(' [OK]');
547
+ }
548
+ }
549
+
550
+ // Reassemble and write
551
+ const assembled = reassembleContentFile({
552
+ rawFrontMatter, translatedFields, translatedBody,
553
+ hasFrontMatter, frontMatterFormat,
554
+ });
555
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
556
+ fs.writeFileSync(targetPath, assembled, 'utf-8');
557
+
558
+ result.translated = true;
559
+ // A fallback body keeps its OLD manifest entry so the file re-fires
560
+ // next sync — every good block is a TM hit, only the fallen-back
561
+ // segment re-bills. Self-healing at bounded cost.
562
+ if (!bodyUsedFallback) {
563
+ updatedManifest[manifestKey] = currentSourceHash;
564
+ }
565
+
566
+ return result;
567
+ }, { concurrency });
568
+
569
+ // Aggregate per-pair results into totals
570
+ for (const r of perPairResults) {
571
+ if (r.translated) translated++;
572
+ if (r.skipped) skipped++;
573
+ if (r.retranslated) retranslated++;
574
+ }
575
+ }
576
+
577
+ // Write updated content manifest (skip in dry-run)
578
+ if (!dryRun) {
579
+ writeContentManifest(cwd, updatedManifest);
580
+ }
581
+
582
+ if (tmSegmentHits > 0) {
583
+ output.info(`[TM] ${tmSegmentHits} content segment(s) served from cache`);
584
+ }
585
+
586
+ // Persist TM if it was mutated during this content sync (same rationale as
587
+ // the key-value path in sync.js: dirty tracking, not size comparison).
588
+ // Skip when --no-tm is active — nothing was cached, nothing to save.
589
+ if (!dryRun && !noTM && isTMDirty(tm)) {
590
+ const tmFinalSize = tmSize(tm);
591
+ const delta = tmFinalSize - tmInitialSize;
592
+ saveTM(cwd, tm);
593
+ output.info(`[TM] Saved ${tmFinalSize} entries (${delta >= 0 ? '+' + delta : delta} this sync)`);
594
+ }
595
+
596
+ const totalCreated = translated;
597
+ if (totalCreated > 0 || skipped > 0 || retranslated > 0) {
598
+ const retranslateNote = retranslated > 0 ? ` (${retranslated} re-translated)` : '';
599
+ if (dryRun) {
600
+ output.info(`Would have created ${totalCreated} content file(s)${retranslateNote}, ${skipped} unchanged.`);
601
+ } else {
602
+ output.ok(`Created ${totalCreated} content file(s)${retranslateNote}, ${skipped} unchanged.`);
603
+ }
604
+ }
605
+ }
606
+
607
+ // -----------------------------------------------------------------
608
+ // Content hash manifest — SHA-256 change detection for content files
609
+ //
610
+ // Mirrors the key-value hash manifest (.champollion.lock) but tracks
611
+ // source content files instead of key-value pairs. Stored separately
612
+ // to keep concerns clean and avoid conflicts.
613
+ // -----------------------------------------------------------------
614
+
615
+ const CONTENT_LOCK_FILENAME = '.champollion-content.lock';
616
+
617
+ /**
618
+ * Read the content hash manifest from disk.
619
+ * Maps "sourceRelPath:targetLocale" → SHA-256 hash of source content.
620
+ * Returns empty object on first run.
621
+ *
622
+ * @param {string} cwd - Project root directory
623
+ * @returns {object} Hash manifest
624
+ */
625
+ function readContentManifest(cwd) {
626
+ const lockPath = path.join(cwd, CONTENT_LOCK_FILENAME);
627
+ if (!fs.existsSync(lockPath)) return {};
628
+ try {
629
+ return JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
630
+ } catch (err) {
631
+ output.warn(`Failed to parse content lock file: ${err.message}`);
632
+ return {};
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Write the content hash manifest to disk.
638
+ * Sorts keys for deterministic, diff-friendly output.
639
+ *
640
+ * @param {string} cwd - Project root directory
641
+ * @param {object} manifest - Hash manifest
642
+ */
643
+ function writeContentManifest(cwd, manifest) {
644
+ const lockPath = path.join(cwd, CONTENT_LOCK_FILENAME);
645
+ const sorted = {};
646
+ for (const key of Object.keys(manifest).sort()) {
647
+ sorted[key] = manifest[key];
648
+ }
649
+ fs.writeFileSync(lockPath, JSON.stringify(sorted, null, 2) + '\n', 'utf-8');
650
+ }
651
+
652
+ /**
653
+ * Compute SHA-256 hash of a file's content.
654
+ *
655
+ * @param {string} filePath - Absolute path to file
656
+ * @returns {string} Hex-encoded SHA-256 hash
657
+ */
658
+ function hashFileContent(filePath) {
659
+ const content = fs.readFileSync(filePath, 'utf-8');
660
+ return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
661
+ }
662
+
663
+ /**
664
+ * Count the (file × pair) content translations a sync would actually run.
665
+ *
666
+ * WHY: the pre-sync cost preview must include content files, but counting
667
+ * EVERY source file would over-report on a no-op re-run (fully-synced files
668
+ * are skipped by the hash manifest) — and with --max-cost that false
669
+ * estimate would abort a run that costs nothing. This replicates the exact
670
+ * skip logic of runContentSync's per-pair loop (target exists + manifest
671
+ * hash match → skip; hashless non-[EN] target → skip) WITHOUT making any
672
+ * API calls, so the preview counts only work that will be paid for.
673
+ *
674
+ * Local file I/O only — cheap relative to the API dollars it estimates.
675
+ *
676
+ * @param {string} contentDir - Path to Hugo content directory
677
+ * @param {string} sourceLocale - Source language code
678
+ * @param {Array<[string, object]>} pairEntries - Pair graph entries [pairKey, pairConfig]
679
+ * @param {string} cwd - Project root (for the content lock file)
680
+ * @returns {{ sourceFileCount: number, pendingTranslations: number, pendingSourceChars: number,
681
+ * byTarget: Object<string, { pendingTranslations: number, pendingSourceChars: number }> }}
682
+ * pendingSourceChars sums the source file characters of every pending
683
+ * (file × pair) translation — the rough basis for token estimation.
684
+ * byTarget breaks the same counts down per target code so the cost table
685
+ * can price each pair with its own method.
686
+ */
687
+ function countPendingContentTranslations(contentDir, sourceLocale, pairEntries, cwd) {
688
+ const result = { sourceFileCount: 0, pendingTranslations: 0, pendingSourceChars: 0, byTarget: {} };
689
+ if (!contentDir || !fs.existsSync(contentDir)) return result;
690
+
691
+ const sourceFiles = discoverContentFiles(contentDir, sourceLocale);
692
+ result.sourceFileCount = sourceFiles.length;
693
+ if (sourceFiles.length === 0) return result;
694
+
695
+ const contentManifest = readContentManifest(cwd);
696
+
697
+ for (const sourcePath of sourceFiles) {
698
+ const relPath = path.relative(contentDir, sourcePath);
699
+ const raw = fs.readFileSync(sourcePath, 'utf-8');
700
+ const currentSourceHash = crypto.createHash('sha256').update(raw, 'utf-8').digest('hex');
701
+
702
+ for (const [, pairConfig] of pairEntries) {
703
+ const code = pairConfig.target;
704
+ const targetPath = getTargetContentPath(sourcePath, code, sourceLocale);
705
+ if (!isPathContained(targetPath, contentDir)) continue;
706
+
707
+ const manifestKey = `${relPath}:${code}`;
708
+ if (fs.existsSync(targetPath)) {
709
+ const storedHash = contentManifest[manifestKey];
710
+ if (storedHash && storedHash === currentSourceHash) continue; // unchanged — skipped by sync
711
+ if (!storedHash) {
712
+ // Hashless target: sync preserves it unless it is a legacy [EN] fallback
713
+ const existingContent = fs.readFileSync(targetPath, 'utf-8');
714
+ if (!existingContent.includes('[EN] ')) continue;
715
+ }
716
+ }
717
+
718
+ result.pendingTranslations += 1;
719
+ result.pendingSourceChars += raw.length;
720
+ if (!result.byTarget[code]) {
721
+ result.byTarget[code] = { pendingTranslations: 0, pendingSourceChars: 0 };
722
+ }
723
+ result.byTarget[code].pendingTranslations += 1;
724
+ result.byTarget[code].pendingSourceChars += raw.length;
725
+ }
726
+ }
727
+
728
+ return result;
729
+ }
730
+
731
+ export { runContentSync, countPendingContentTranslations, readContentManifest, CONTENT_LOCK_FILENAME };