champollion 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- package/shared/schemas/source-snapshot.schema.json +96 -0
|
@@ -0,0 +1,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 };
|