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,59 @@
1
+ /**
2
+ * Security utilities — filesystem guards and input sanitization.
3
+ *
4
+ * SECURITY SURFACE MAP:
5
+ *
6
+ * Guard │ What it protects │ Used by
7
+ * ───────────────────────┼────────────────────────────────┼──────────────────────────
8
+ * isPathContained() │ Filesystem path traversal │ sync.js, content-sync.js,
9
+ * │ ("../../../etc/passwd") │ docusaurus-sync.js
10
+ * isUnsafeKey() │ Prototype pollution in JSON │ translate.js, llm.js,
11
+ * │ response parsing (__proto__, │ llm-coached.js, sync.js,
12
+ * │ constructor, prototype) │ docusaurus-sync.js
13
+ * validateTranslations() │ Hallucination, wrong-script, │ validate.js (via
14
+ * │ length inflation, source echo │ translate-pair.js)
15
+ * sanitizeInput() │ (planned) Input sanitization │
16
+ * │ for user-provided config values│
17
+ *
18
+ * WHY THIS EXISTS: Locale codes, content paths, and translation keys all
19
+ * come from user config or LLM responses. A crafted code like
20
+ * "../../../etc/passwd" would resolve outside the expected directory, and
21
+ * a key like "__proto__" could trigger prototype pollution. This module
22
+ * centralizes these guards.
23
+ */
24
+
25
+ import path from 'node:path';
26
+
27
+ /**
28
+ * Verify that a resolved file path is contained within the expected
29
+ * parent directory. Prevents path traversal via crafted language codes
30
+ * or filenames in the config (e.g., "../../../etc/passwd.json").
31
+ *
32
+ * @param {string} filePath - Resolved absolute path to check
33
+ * @param {string} parentDir - Expected parent directory
34
+ * @returns {boolean} True if filePath is within parentDir
35
+ */
36
+ function isPathContained(filePath, parentDir) {
37
+ const resolved = path.resolve(filePath);
38
+ const parent = path.resolve(parentDir);
39
+ return resolved.startsWith(parent + path.sep) || resolved === parent;
40
+ }
41
+
42
+ /**
43
+ * Keys that could trigger prototype pollution if accepted from LLM output.
44
+ * Blocked in response validation as a defense-in-depth measure.
45
+ */
46
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
47
+
48
+ /**
49
+ * Check if a key path contains any unsafe segments that could cause
50
+ * prototype pollution when used with nested object assignment.
51
+ *
52
+ * @param {string} key - Dot-notation key path (e.g., "a.__proto__.b")
53
+ * @returns {boolean} True if the key contains unsafe segments
54
+ */
55
+ function isUnsafeKey(key) {
56
+ return key.split('.').some(segment => UNSAFE_KEYS.has(segment));
57
+ }
58
+
59
+ export { isPathContained, isUnsafeKey };
package/lib/segment.js ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * segment.js — Markdown block segmentation for translation.
3
+ *
4
+ * WHY THIS EXISTS: The Docusaurus content path used to translate every
5
+ * page as ONE whole-body API call. A one-paragraph edit re-billed the
6
+ * entire page for every locale. Splitting the body into top-level blocks
7
+ * lets the Translation Memory serve every unchanged block for free and
8
+ * bill only the blocks that actually changed.
9
+ *
10
+ * CONTRACT — whitespace-exact reassembly:
11
+ * joinBlocks(splitBlocks(x)) === x for EVERY input string.
12
+ *
13
+ * The splitter operates on the OUTPUT of protectBlocks() (content.js):
14
+ * fenced code blocks, MDX import/export lines, inline code, and HTML tags
15
+ * arrive already collapsed to ⟦PROTECTED_N⟧ placeholders (single tokens,
16
+ * no newlines), so splitting at blank-line boundaries can never cut a
17
+ * protected region in half. Splitting raw markdown also round-trips
18
+ * (the contract holds for any string) — it is only the *translation
19
+ * safety* of the blocks that depends on prior protection.
20
+ *
21
+ * Segment kinds:
22
+ * - 'separator': a run of blank lines between blocks. Never sent
23
+ * anywhere; reattached verbatim on reassembly.
24
+ * - 'passthrough': a block with no translatable text (pure placeholder
25
+ * lines, whitespace, MDX import/export statements,
26
+ * '---' thematic breaks, table rules). Never billed;
27
+ * copied verbatim.
28
+ * - 'translatable': everything else — the units the TM caches and the
29
+ * API translates.
30
+ */
31
+
32
+ import { PLACEHOLDER_PREFIX } from './content.js';
33
+
34
+ // Block separator: a newline followed by one or more blank lines
35
+ // (whitespace-only lines count as blank — CommonMark treats them as
36
+ // paragraph breaks). The capturing group makes String.split() KEEP the
37
+ // separators, which is what makes reassembly whitespace-exact.
38
+ const BLOCK_SEPARATOR_REGEX = /(\r?\n(?:[ \t]*\r?\n)+)/;
39
+
40
+ // A complete protected-block placeholder token (see content.js).
41
+ const PLACEHOLDER_TOKEN_REGEX = /⟦PROTECTED_\d+⟧/g;
42
+
43
+ // MDX import/export statement lines. protectBlocks() already collapses
44
+ // these to placeholders; this is defense-in-depth for callers that
45
+ // segment unprotected text — JS statements must never be classified
46
+ // translatable.
47
+ const IMPORT_EXPORT_LINE_REGEX = /^[ \t]*(?:import|export)\b.*$/gm;
48
+
49
+ // Segment markers for the block-batch prompt. Same Unicode-bracket family
50
+ // as ⟦PROTECTED_N⟧ (extremely unlikely in real content), but a DISTINCT
51
+ // prefix so the orphaned-placeholder check and the segment parser can
52
+ // never confuse the two.
53
+ const SEGMENT_MARKER_PREFIX = '⟦SEG_';
54
+ const SEGMENT_MARKER_SUFFIX = '⟧';
55
+
56
+ /**
57
+ * Does a block contain any text worth billing an API call for?
58
+ *
59
+ * A block is translatable when, after removing protected-block
60
+ * placeholders and import/export statement lines, at least one letter or
61
+ * digit remains. This classifies as passthrough: whitespace, pure
62
+ * placeholder lines, '---'/'***'/'___' thematic breaks, table alignment
63
+ * rules (| --- | --- |), and bare ':::' admonition fences — while keeping
64
+ * anything with prose (including ':::tip Title' lines) translatable.
65
+ * Conservative by design: when in doubt, translate — a false
66
+ * "translatable" costs a few tokens; a false "passthrough" ships
67
+ * untranslated prose.
68
+ *
69
+ * @param {string} text - Block text (protected or raw)
70
+ * @returns {boolean} True if the block has translatable content
71
+ */
72
+ function hasTranslatableText(text) {
73
+ const stripped = text
74
+ .replace(PLACEHOLDER_TOKEN_REGEX, '')
75
+ .replace(IMPORT_EXPORT_LINE_REGEX, '');
76
+ return /[\p{L}\p{N}]/u.test(stripped);
77
+ }
78
+
79
+ /**
80
+ * Split a (placeholder-protected) markdown body into ordered segments at
81
+ * top-level block boundaries.
82
+ *
83
+ * @param {string} protectedBody - Markdown body, ideally the output of
84
+ * protectBlocks() so fenced blocks are single placeholder tokens.
85
+ * @returns {Array<{text: string, type: 'separator'|'passthrough'|'translatable'}>}
86
+ * Ordered segments. joinBlocks() of the result reproduces the input
87
+ * byte-for-byte.
88
+ */
89
+ // A run of blank/whitespace-only lines at the START of a content piece
90
+ // (e.g. the single newline parseContentFile leaves before the first block).
91
+ const LEADING_NEWLINE_RUN = /^(?:[ \t]*\r?\n)+/;
92
+ // A trailing newline run at the END of a content piece (e.g. the file's
93
+ // final newline attached to the last block).
94
+ const TRAILING_NEWLINE_RUN = /(?:\r?\n[ \t]*)+$/;
95
+
96
+ function splitBlocks(protectedBody) {
97
+ // split() with a capturing group alternates [content, sep, content, …].
98
+ // Leading/trailing separators produce empty content strings — dropped,
99
+ // since '' contributes nothing to the join.
100
+ const parts = protectedBody.split(BLOCK_SEPARATOR_REGEX);
101
+ const segments = [];
102
+
103
+ for (let i = 0; i < parts.length; i++) {
104
+ let text = parts[i];
105
+ if (text === '') continue;
106
+ const isSeparator = i % 2 === 1;
107
+ if (isSeparator) {
108
+ segments.push({ text, type: 'separator' });
109
+ continue;
110
+ }
111
+
112
+ // Peel single-newline runs off the block's edges into separator
113
+ // segments (only string-edge pieces can carry them — inter-block runs
114
+ // are consumed by the separator regex). Without this, the parsed
115
+ // body's leading '\n' and the file's final '\n' would pollute the
116
+ // first/last block's TM key and API payload.
117
+ const lead = text.match(LEADING_NEWLINE_RUN);
118
+ if (lead) {
119
+ segments.push({ text: lead[0], type: 'separator' });
120
+ text = text.slice(lead[0].length);
121
+ }
122
+ let trail = null;
123
+ const trailMatch = text.match(TRAILING_NEWLINE_RUN);
124
+ if (trailMatch) {
125
+ trail = trailMatch[0];
126
+ text = text.slice(0, -trail.length);
127
+ }
128
+ if (text !== '') {
129
+ segments.push({
130
+ text,
131
+ type: hasTranslatableText(text) ? 'translatable' : 'passthrough',
132
+ });
133
+ }
134
+ if (trail !== null) {
135
+ segments.push({ text: trail, type: 'separator' });
136
+ }
137
+ }
138
+
139
+ return segments;
140
+ }
141
+
142
+ /**
143
+ * Reassemble segments into the original string.
144
+ *
145
+ * @param {Array<{text: string}>} segments - Segments from splitBlocks()
146
+ * @returns {string} Concatenation of every segment's text, in order
147
+ */
148
+ function joinBlocks(segments) {
149
+ let out = '';
150
+ for (const seg of segments) out += seg.text;
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * Build ONE prompt that translates a batch of markdown blocks.
156
+ *
157
+ * Same protected-placeholder discipline as buildContentPrompt()
158
+ * (content.js): the placeholder rule is only stated when a block actually
159
+ * contains ⟦PROTECTED_N⟧ tokens, because some models echo the literal
160
+ * token from the rules into placeholder-free output.
161
+ *
162
+ * @param {string[]} blockTexts - Protected block texts to translate, in order
163
+ * @param {object} langConfig - { name, register }
164
+ * @param {object} options - { sourceLanguageName, promptContext, pageTitle }
165
+ * pageTitle is the page's H1/front-matter title, given as terminology
166
+ * context so isolated blocks translate consistently with the page topic.
167
+ * @returns {string} Complete translation prompt
168
+ */
169
+ function buildBlockBatchPrompt(blockTexts, langConfig, options = {}) {
170
+ const sourceLanguageName = options.sourceLanguageName || 'English';
171
+
172
+ const contextBlock = options.promptContext
173
+ ? `\nContext: ${options.promptContext}\n`
174
+ : '';
175
+
176
+ const titleBlock = options.pageTitle
177
+ ? `\nThese segments come from a page titled "${options.pageTitle}" — use it as terminology context.\n`
178
+ : '';
179
+
180
+ const placeholderRule = blockTexts.some(t => t.includes(PLACEHOLDER_PREFIX))
181
+ ? '\n- DO NOT translate or modify anything inside ⟦PROTECTED_N⟧ placeholders. Leave them exactly as they appear.'
182
+ : '';
183
+
184
+ const numbered = blockTexts
185
+ .map((text, i) => `${SEGMENT_MARKER_PREFIX}${i}${SEGMENT_MARKER_SUFFIX}\n${text}`)
186
+ .join('\n\n');
187
+
188
+ return `You are translating Markdown content from ${sourceLanguageName} to ${langConfig.name}. The document was split into ${blockTexts.length} numbered segment(s); segments not shown are already translated.
189
+ ${contextBlock}${titleBlock}
190
+ Register/tone: ${langConfig.register}
191
+
192
+ Rules:
193
+ - Translate ALL human-readable text in every segment.
194
+ - Preserve ALL Markdown formatting: headers (#), bold (**), italic (*), links, images, lists, blockquotes, tables, admonitions (:::), etc.${placeholderRule}
195
+ - Preserve line breaks and structure WITHIN each segment.
196
+ - Proper nouns, product names, and technical terms should remain in the source language.
197
+ - Translate link text but preserve link URLs. For example: [Read more](url) → [Lire la suite](url)
198
+ - Echo each ${SEGMENT_MARKER_PREFIX}N${SEGMENT_MARKER_SUFFIX} marker on its own line, EXACTLY as given, before its translated segment.
199
+ - Do NOT merge, drop, reorder, renumber, or add segments.
200
+ - Return ONLY the markers and the translated segments. No code fences, no explanation, no preamble.
201
+
202
+ ${numbered}`;
203
+ }
204
+
205
+ // Parser for the model's response: captures the segment id so each piece
206
+ // can be matched back positionally AND validated.
207
+ const SEGMENT_SPLIT_REGEX = /⟦SEG_(\d+)⟧/g;
208
+
209
+ /**
210
+ * Parse the model's response to a block-batch prompt.
211
+ *
212
+ * FAIL-LOUD by default: any structural violation (missing segment, duplicate
213
+ * marker, unknown id, wrong count) throws — the caller fails the whole file
214
+ * and nothing partial is written or cached. This is the block-level analogue
215
+ * of the orphaned-placeholder check.
216
+ *
217
+ * LENIENT mode (`opts.lenient`) relaxes ONLY the missing-segment case: the
218
+ * caller gets back what did arrive plus the list of missing indexes, so it
219
+ * can retry just those (translateBlockBatchResilient below). Duplicate and
220
+ * unknown markers still throw in lenient mode — they mean the mapping itself
221
+ * cannot be trusted, and no partial result is safe to keep.
222
+ *
223
+ * @param {string} response - Raw model output
224
+ * @param {number} expectedCount - Number of blocks that were sent
225
+ * @param {{lenient?: boolean}} [opts]
226
+ * @returns {string[]|{blocks: string[], missing: number[]}} Strict mode:
227
+ * translated block texts, index-aligned with the input. Lenient mode:
228
+ * `blocks` (sparse where missing) + `missing` (ascending indexes).
229
+ * @throws {Error} On any structural violation (strict), or on duplicate/
230
+ * unknown markers (lenient)
231
+ */
232
+ function parseBlockBatchResponse(response, expectedCount, opts = {}) {
233
+ const parts = String(response).split(SEGMENT_SPLIT_REGEX);
234
+ // parts[0] is preamble before the first marker — models occasionally
235
+ // emit one despite instructions; it carries no segment and is dropped.
236
+ const seen = new Map();
237
+ for (let i = 1; i < parts.length; i += 2) {
238
+ const id = parseInt(parts[i], 10);
239
+ if (id >= expectedCount) {
240
+ throw new Error(
241
+ `block-batch response contains unknown segment marker ⟦SEG_${id}⟧ (sent ${expectedCount} segment(s))`
242
+ );
243
+ }
244
+ if (seen.has(id)) {
245
+ throw new Error(`block-batch response repeats segment marker ⟦SEG_${id}⟧`);
246
+ }
247
+ // Strip the newline that follows the marker line and trailing
248
+ // blank-line padding — inter-block separators are reattached from the
249
+ // SOURCE, never taken from the model. Leading indentation of the
250
+ // first content line is preserved.
251
+ const text = (parts[i + 1] ?? '').replace(/^\r?\n/, '').replace(/[\r\n]+[\s]*$/, '');
252
+ seen.set(id, text);
253
+ }
254
+
255
+ if (seen.size !== expectedCount) {
256
+ const missing = [];
257
+ for (let i = 0; i < expectedCount; i++) {
258
+ if (!seen.has(i)) missing.push(i);
259
+ }
260
+ if (!opts.lenient) {
261
+ throw new Error(
262
+ `block-batch response returned ${seen.size}/${expectedCount} segment(s) — missing marker(s): ` +
263
+ missing.map(i => `⟦SEG_${i}⟧`).join(', ')
264
+ );
265
+ }
266
+ const blocks = new Array(expectedCount);
267
+ for (const [id, text] of seen) blocks[id] = text;
268
+ return { blocks, missing };
269
+ }
270
+
271
+ const out = new Array(expectedCount);
272
+ for (const [id, text] of seen) out[id] = text;
273
+ return opts.lenient ? { blocks: out, missing: [] } : out;
274
+ }
275
+
276
+ /**
277
+ * Block-batch translation with a bounded self-repair ladder.
278
+ *
279
+ * Born from a real gate-brick (2026-07-11): one locale's response dropped a
280
+ * single ⟦SEG_N⟧ marker, deterministically, which hard-failed the whole file
281
+ * — so the content lock never advanced and EVERY subsequent sync re-billed
282
+ * every locale of that file. The ladder:
283
+ *
284
+ * 1. Send the full batch. Missing marker(s)? →
285
+ * 2. ONE retry with only the missing segments (a fresh, smaller batch —
286
+ * different neighbors, usually enough). Still missing? →
287
+ * 3. Honest fallback: `fallbackPrefix + source` for just those segments
288
+ * (the established '[EN] ' doctrine — visible, never silent).
289
+ *
290
+ * The CALLER's contract for fallen-back segments: never store them in the
291
+ * TM (an error cached is an error forever) and never advance the file's
292
+ * lock entry — so the file re-fires next sync, where every good block is a
293
+ * TM hit and only the failed segment re-bills. Self-healing, bounded cost.
294
+ *
295
+ * @param {object} p
296
+ * @param {string[]} p.texts - Protected block texts to translate
297
+ * @param {(texts: string[]) => string} p.buildPrompt - Batch prompt builder
298
+ * @param {(prompt: string) => Promise<string|null>} p.callModel - One API call
299
+ * @param {string} p.fallbackPrefix - e.g. '[EN] ' — prepended to source text
300
+ * @returns {Promise<{blocks: string[], fellBack: number[]}>} index-aligned
301
+ * translations; `fellBack` lists indexes that carry the fallback
302
+ * @throws {Error} If the FIRST call returns nothing at all, or on
303
+ * duplicate/unknown markers (untrustworthy mapping)
304
+ */
305
+ async function translateBlockBatchResilient({ texts, buildPrompt, callModel, fallbackPrefix }) {
306
+ const first = await callModel(buildPrompt(texts));
307
+ if (!first) {
308
+ throw new Error('block-batch translation returned no results');
309
+ }
310
+ const { blocks, missing } = parseBlockBatchResponse(first, texts.length, { lenient: true });
311
+
312
+ let stillMissing = missing;
313
+ if (stillMissing.length > 0) {
314
+ const retryTexts = stillMissing.map(i => texts[i]);
315
+ const second = await callModel(buildPrompt(retryTexts));
316
+ if (second) {
317
+ // Duplicate/unknown markers in the RETRY are treated like a miss for
318
+ // the retried segments, not a hard fail — the first response's good
319
+ // segments are already safely mapped.
320
+ try {
321
+ const parsed = parseBlockBatchResponse(second, retryTexts.length, { lenient: true });
322
+ stillMissing.forEach((origIdx, j) => {
323
+ if (parsed.blocks[j] !== undefined) blocks[origIdx] = parsed.blocks[j];
324
+ });
325
+ } catch { /* retry response corrupt — fall through to fallback */ }
326
+ }
327
+ stillMissing = [];
328
+ for (let i = 0; i < texts.length; i++) {
329
+ if (blocks[i] === undefined) stillMissing.push(i);
330
+ }
331
+ }
332
+
333
+ for (const i of stillMissing) {
334
+ blocks[i] = fallbackPrefix + texts[i];
335
+ }
336
+ return { blocks, fellBack: stillMissing };
337
+ }
338
+
339
+ /**
340
+ * Content segmentation modes for body translation — shared by the
341
+ * Docusaurus (docusaurus-sync.js) and Hugo/generic (content-sync.js) paths.
342
+ * 'block' (default): split the body into top-level blocks, serve
343
+ * unchanged blocks from the TM, and translate only the misses in one
344
+ * batched API call per file per locale.
345
+ * 'page': single-prompt whole-body behavior (still TM-threaded at the
346
+ * whole-body level).
347
+ */
348
+ const CONTENT_SEGMENTATION_MODES = new Set(['block', 'page']);
349
+
350
+ function assertSegmentationMode(value, where) {
351
+ if (value != null && !CONTENT_SEGMENTATION_MODES.has(value)) {
352
+ throw new Error(
353
+ `Invalid contentSegmentation "${value}" in ${where} — expected "block" or "page".`
354
+ );
355
+ }
356
+ }
357
+
358
+ export {
359
+ splitBlocks,
360
+ joinBlocks,
361
+ hasTranslatableText,
362
+ buildBlockBatchPrompt,
363
+ parseBlockBatchResponse,
364
+ translateBlockBatchResilient,
365
+ assertSegmentationMode,
366
+ CONTENT_SEGMENTATION_MODES,
367
+ SEGMENT_MARKER_PREFIX,
368
+ SEGMENT_MARKER_SUFFIX,
369
+ };