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
package/lib/security.js
ADDED
|
@@ -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
|
+
};
|