champollion 0.3.3 → 0.4.0

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 (142) hide show
  1. package/README.md +52 -37
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +51 -3
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +289 -88
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +649 -130
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +16 -10
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +197 -38
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +193 -106
  100. package/lib/seal.mjs +6 -5
  101. package/lib/sealed-qualifier.mjs +2 -2
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +3 -2
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/DATA-SOVEREIGNTY.md +19 -20
  123. package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
  124. package/shared/cards-fallback.json +1 -1
  125. package/shared/catalogue/card-config.json +1 -1
  126. package/shared/curated-orthography-conventions.json +26 -8
  127. package/shared/docent/faq.en.json +14 -16
  128. package/shared/docent/system-prompt.md +17 -19
  129. package/shared/explainers/tc-features.json +15 -15
  130. package/shared/gettext-plural-forms.json +45 -0
  131. package/shared/human-services.json +1 -1
  132. package/shared/method-registry.json +2 -0
  133. package/shared/metric-registry.json +96 -18
  134. package/shared/schemas/champollion-plugin.schema.json +4 -0
  135. package/shared/schemas/corpora-card.schema.json +20 -10
  136. package/shared/schemas/human-services.schema.json +2 -2
  137. package/shared/schemas/language-card.schema.json +1 -1
  138. package/shared/schemas/method-card.schema.json +1 -1
  139. package/shared/schemas/method-index-record.schema.json +67 -0
  140. package/shared/schemas/method-registry.schema.json +4 -0
  141. package/shared/schemas/metric-registry.schema.json +55 -1
  142. package/shared/docent/corpus.json +0 -11333
@@ -0,0 +1,96 @@
1
+ /**
2
+ * content-estimate.js — what a content file will actually BILL.
3
+ *
4
+ * The cost gate (--max-cost) must price what the run will pay for, not the
5
+ * whole file: front-matter fields and body blocks the Translation Memory
6
+ * already holds are served at $0 by the sync. Pricing whole files made a
7
+ * $100 cap refuse runs whose real cost was ~$30, and operators raised the
8
+ * cap past the honest number to get work done (dogfood 2026-08-28, finding
9
+ * 1) — the guardrail stopped guarding.
10
+ *
11
+ * This walks the SAME ladder both content lanes run at sync time:
12
+ * 1. each front-matter field, cached on its own source text;
13
+ * 2. the whole body (a revert or lock-loss re-run is free);
14
+ * 3. in 'block' segmentation, each translatable block.
15
+ * One implementation for the Hugo and Docusaurus estimators, so the two can
16
+ * no longer disagree about what is billable.
17
+ *
18
+ * Pure lookups (lookupTM, never the evicting lookupTMValidated): an
19
+ * estimate must not change the cache it is estimating against.
20
+ */
21
+
22
+ import { lookupTM } from './tm.js';
23
+ import { splitBlocks } from './segment.js';
24
+ import { protectBlocks, restoreBlocks } from './content.js';
25
+
26
+ /**
27
+ * Restored source texts of a body's translatable blocks — the exact unit
28
+ * block-mode sync caches and bills.
29
+ *
30
+ * @param {string} body - Markdown body (front matter stripped)
31
+ * @returns {string[]}
32
+ */
33
+ export function translatableBlockSources(body) {
34
+ const { protectedBody, blocks } = protectBlocks(body);
35
+ return splitBlocks(protectedBody)
36
+ .filter(seg => seg.type === 'translatable')
37
+ .map(seg => restoreBlocks(seg.text, blocks));
38
+ }
39
+
40
+ /**
41
+ * Billable vs total source characters for one (file × locale) work item.
42
+ *
43
+ * @param {object} args
44
+ * @param {object} args.tm - Loaded TM (carry-over settings apply)
45
+ * @param {string} args.code - Target locale
46
+ * @param {string} args.tmKey - tmMethodKey(pairConfig)
47
+ * @param {string|null} [args.fallbackTmKey] - tmMethodKey(pairConfig.fallback)
48
+ * when the pair has a fallback: the sync serves the fallback's cached text
49
+ * before asking the primary (lib/fallback.js), so it is free here too
50
+ * @param {Record<string, string>} args.fields - Front-matter fields to translate
51
+ * @param {string} args.body - Markdown body
52
+ * @param {'block'|'page'} args.segMode - Content segmentation mode
53
+ * @param {() => string[]} args.blockSources - Lazily computed block sources
54
+ * (callers cache per source file: segmentation is locale-independent)
55
+ * @param {ReturnType<import('./content-refusals.js').contentHolds>|null} [args.holds] -
56
+ * What the quality gate refused before (lib/content-refusals.js): a block
57
+ * or field the pair's method refused is not sent to it (held, or the
58
+ * fallback's — which this estimate does not price); a held front-matter
59
+ * field — or a held page body in 'page' segmentation — holds its whole
60
+ * page, so nothing of it is billed
61
+ * @returns {{ billedChars: number, totalChars: number }}
62
+ */
63
+ export function billableContentChars({ tm, code, tmKey, fallbackTmKey = null, fields, body, segMode, blockSources, holds = null }) {
64
+ let billedChars = 0;
65
+ let totalChars = 0;
66
+ let pageHeld = false;
67
+ const cached = (text) => lookupTM(tm, text, code, tmKey) !== null
68
+ || (fallbackTmKey !== null && lookupTM(tm, text, code, fallbackTmKey) !== null);
69
+
70
+ for (const [field, text] of Object.entries(fields)) {
71
+ totalChars += text.length;
72
+ if (cached(text)) continue;
73
+ const hold = holds ? holds.field(field, text) : 'send';
74
+ if (hold === 'held') pageHeld = true;
75
+ else if (hold === 'send') billedChars += text.length;
76
+ }
77
+
78
+ if (body.trim()) {
79
+ totalChars += body.length;
80
+ if (!cached(body)) {
81
+ if (segMode === 'page') {
82
+ // A page translated whole and refused before: held (its page is
83
+ // not processed — nothing of it is billed), or the fallback's.
84
+ const hold = holds ? holds.page(body) : 'send';
85
+ if (hold === 'held') pageHeld = true;
86
+ else if (hold === 'send') billedChars += body.length;
87
+ } else {
88
+ for (const source of blockSources()) {
89
+ if (!cached(source) && (!holds || holds.block(source) === 'send')) billedChars += source.length;
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ return { billedChars: pageHeld ? 0 : billedChars, totalChars };
96
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * content-refusals.js — Markdown blocks and front-matter fields the quality
3
+ * gate refused, remembered so a plain sync does not send them to the same
4
+ * model again. The key-value rule (lib/locale-state.js, Round 4) for the two
5
+ * content lanes (lib/content-sync.js, lib/docusaurus-sync.js).
6
+ *
7
+ * WHY: a block the gate refused (lib/validate.js contentGateFault) was
8
+ * written as the '[EN] ' last resort, never cached, and its file's lock was
9
+ * not advanced — so EVERY sync sent it to the same paid model again, which
10
+ * answered the same way and was refused again. A refused front-matter field
11
+ * failed its page, and every sync re-sent the page's fields. With a paid
12
+ * model and no fallback that repeated the charge on each run, unlike keys,
13
+ * which are held back.
14
+ *
15
+ * THE RECORD — in .champollion-content.lock (committed, beside the lane's
16
+ * own entries), one entry per (page × locale) that has refusals:
17
+ *
18
+ * "refused:<manifestKey>": {
19
+ * "block:<hash12>": { "source": "<hash12>", "methods": ["<method key>"], "on": "2026-10-04" },
20
+ * "field:title": { "source": "<hash12>", "methods": ["<method key>"], "on": "2026-10-04" },
21
+ * "page": { "source": "<hash12>", "methods": ["<method key>"], "on": "2026-10-04" }
22
+ * }
23
+ *
24
+ * <manifestKey> is the lane's own key for the translation ("posts/a.md:fr",
25
+ * "docusaurus:docs/intro.md:fr"). A block is named by the hash of its source
26
+ * text, so an edited paragraph is a new block (the hold lifts); `source` is
27
+ * the 12-hex SHA-256 the key-value record uses, `methods` the TM method keys
28
+ * (method|model|register|coaching) whose answers the gate refused.
29
+ *
30
+ * "page" is the whole body of a page translated in one prompt
31
+ * (contentSegmentation: "page"): refused when the answer damages a protected
32
+ * placeholder or hollows the page (the whole-body checks), its `source` the
33
+ * hash of the body text. Editing the body — or switching to block
34
+ * segmentation — lifts it. A held page is not written, as a held field's
35
+ * page is not: there is no '[EN] ' text for a page translated whole.
36
+ *
37
+ * PRECEDENCE — the key-value rule, unit by unit:
38
+ * 1. Named for a redo — `--redo files:<page>`, `--redo content`
39
+ * (= --force-content), `--retranslate`, or anything under `--fresh`:
40
+ * always sent.
41
+ * 2. Refused before by the pair's method, for the unit's current source
42
+ * text: held back — not sent to that method again. A fallback method
43
+ * that has not refused it still gets it. A held block keeps its
44
+ * '[EN] ' text until it is filled; a page whose front-matter field is
45
+ * held is not written (the field gate fails a page whole, as it always
46
+ * did) — and nothing of it is sent.
47
+ * 3. A model or method change lifts the hold (the refusal was that method
48
+ * key's), as does a new source text.
49
+ * The cache is always consulted first (free): holding back only stops a
50
+ * paid call. A unit filled any other way — a fallback, the cache, a
51
+ * paragraph written by hand — drops its record.
52
+ */
53
+
54
+ import { tmMethodKey, lookupTM } from './tm.js';
55
+ import { holdState, shortSourceHash } from './locale-state.js';
56
+ import { contentRedoCommand } from './verify.js';
57
+ import { translatableBlockSources } from './content-estimate.js';
58
+
59
+ /** The content-lock key of one page × locale's refusals. */
60
+ export function refusalRecordKey(manifestKey) {
61
+ return `refused:${manifestKey}`;
62
+ }
63
+
64
+ /** A block's unit id: the hash of its (restored) source text. */
65
+ export const blockUnit = (source) => `block:${shortSourceHash(source)}`;
66
+
67
+ /** A front-matter field's unit id. */
68
+ export const fieldUnit = (field) => `field:${field}`;
69
+
70
+ /** The unit id of a page body translated whole (contentSegmentation: "page"). */
71
+ export const PAGE_UNIT = 'page';
72
+
73
+ /** How a held page body is named in messages and summaries. */
74
+ export const PAGE_NAME = 'the whole page body';
75
+
76
+ /**
77
+ * One page × locale's refusal record (entries that do not parse are dropped).
78
+ *
79
+ * @param {object} manifest - The content lock
80
+ * @param {string} manifestKey
81
+ * @returns {Record<string, { source: string, methods: string[], on?: string }>}
82
+ */
83
+ export function readRefusals(manifest, manifestKey) {
84
+ const rec = manifest?.[refusalRecordKey(manifestKey)];
85
+ if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return {};
86
+ const out = {};
87
+ for (const [id, r] of Object.entries(rec)) {
88
+ if (r && typeof r === 'object' && typeof r.source === 'string' && Array.isArray(r.methods)) out[id] = r;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * What a run may do with each unit of one page: 'send' (ask the pair's
95
+ * method), 'fallback-only' (the pair's method refused it; its fallback has
96
+ * not), 'held' (not sent at all).
97
+ *
98
+ * @param {object} units - readRefusals()
99
+ * @param {object} pairConfig
100
+ * @param {{ redo?: boolean }} [opts] - redo: the page was named for a redo (always sent)
101
+ * @returns {{ block: (source: string) => 'send'|'fallback-only'|'held',
102
+ * field: (field: string, source: string) => 'send'|'fallback-only'|'held',
103
+ * page: (body: string) => 'send'|'fallback-only'|'held' }}
104
+ */
105
+ export function contentHolds(units, pairConfig, { redo = false } = {}) {
106
+ // The key-value rule (lib/locale-state.js holdState), unit by unit.
107
+ const stateOf = (id, source) => (redo ? 'send' : holdState(units[id], source, pairConfig));
108
+ return {
109
+ block: (source) => stateOf(blockUnit(source), source),
110
+ field: (field, source) => stateOf(fieldUnit(field), source),
111
+ page: (body) => stateOf(PAGE_UNIT, body),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * One page's record after a run processed it: earlier refusals that still
117
+ * apply (same source text, not filled this run), plus this run's.
118
+ *
119
+ * @param {object} prior - readRefusals()
120
+ * @param {object} p
121
+ * @param {string[]} p.blockSources - The page's current translatable block sources ([] in page mode)
122
+ * @param {string|null} [p.pageSource] - Its body, in page mode (null in block mode)
123
+ * @param {Record<string, string>} p.fields - Its current translatable front-matter fields
124
+ * @param {Array<{ unit: string, source: string, methods: string[] }>} p.refused - Refused this run, left unfilled
125
+ * @param {Set<string>} p.filled - Units filled this run (any method, the cache, a person)
126
+ * @returns {object|null} The record, or null when nothing is left to hold
127
+ */
128
+ export function nextRefusals(prior, { blockSources = [], pageSource = null, fields = {}, refused = [], filled = new Set() }) {
129
+ const live = new Map();
130
+ for (const s of blockSources) live.set(blockUnit(s), shortSourceHash(s));
131
+ if (typeof pageSource === 'string' && pageSource.trim()) live.set(PAGE_UNIT, shortSourceHash(pageSource));
132
+ for (const [f, v] of Object.entries(fields)) if (typeof v === 'string') live.set(fieldUnit(f), shortSourceHash(v));
133
+ const out = {};
134
+ for (const [id, r] of Object.entries(prior)) {
135
+ if (filled.has(id) || live.get(id) !== r.source) continue;
136
+ out[id] = r;
137
+ }
138
+ const on = new Date().toISOString().slice(0, 10);
139
+ for (const { unit, source, methods } of refused) {
140
+ if (!methods || methods.length === 0 || filled.has(unit)) continue;
141
+ const src = shortSourceHash(source);
142
+ const known = out[unit] && out[unit].source === src ? out[unit].methods : [];
143
+ out[unit] = { source: src, methods: [...new Set([...known, ...methods])], on };
144
+ }
145
+ return Object.keys(out).length > 0 ? out : null;
146
+ }
147
+
148
+ /** Write (or remove) one page's record in the content lock object. */
149
+ export function storeRefusals(manifest, manifestKey, record) {
150
+ const key = refusalRecordKey(manifestKey);
151
+ if (record) manifest[key] = record;
152
+ else delete manifest[key];
153
+ }
154
+
155
+ /**
156
+ * The warning for one page's held units — both lanes say it the same way.
157
+ *
158
+ * @param {object} p
159
+ * @param {string} p.file - The page as sync names it (what `--redo files:` matches)
160
+ * @param {string} p.code
161
+ * @param {string} p.pairKey
162
+ * @param {object} p.pairConfig
163
+ * @param {string[]} p.names - "paragraph 2", 'front matter "title"'
164
+ * @param {boolean} [p.pageHeld] - A front-matter field (or the page body, in
165
+ * page mode) is held: the page is not written
166
+ * @param {boolean} [p.handWritten] - The lane keeps a paragraph written by hand (contentDir)
167
+ * @param {string} [p.fallbackPrefix]
168
+ * @returns {string}
169
+ */
170
+ export function describeHeld({ file, code, pairKey, pairConfig, names, pageHeld = false, handWritten = false, fallbackPrefix = '[EN] ' }) {
171
+ const shown = names.slice(0, 5).join(', ') + (names.length > 5 ? `, +${names.length - 5} more` : '');
172
+ const fb = pairConfig.fallback;
173
+ const who = fb ? `${pairConfig.method}'s and its fallback's (${fb.method})` : `${pairConfig.method}'s`;
174
+ const left = !pageHeld
175
+ ? `The '${fallbackPrefix.trim()}' text stays until they are filled.`
176
+ : names.includes(PAGE_NAME)
177
+ ? 'A page translated whole has no \'[EN]\' text to keep, so it is not written (and nothing of it is sent) until it is filled.'
178
+ : 'A front-matter field the gate refuses fails its page, so the page is not written (and nothing of it is sent) until it is filled.';
179
+ const other = fb
180
+ ? 'another "fallback" method on the pair'
181
+ : `a "fallback" method on the pair (it is asked for what ${pairConfig.method} refused)`;
182
+ const hand = handWritten && !pageHeld ? ', or write those paragraphs by hand (a hand-written paragraph is kept)' : '';
183
+ return `${file} → ${code}: ${names.length} block(s)/field(s) held back (${shown}) — the quality gate refused ${who} translation of their `
184
+ + `current text before; not sent, not billed. ${left} Ask again: \`${contentRedoCommand(file, { pair: pairKey })}\`; `
185
+ + `or fill them another way — ${other}${hand}.`;
186
+ }
187
+
188
+ /**
189
+ * Said with a refusal this run recorded: what the next sync does with it.
190
+ *
191
+ * @param {object} p
192
+ * @param {string} p.file
193
+ * @param {string} p.pairKey
194
+ * @param {object} p.pairConfig
195
+ * @param {number} p.count - Units refused (and left unfilled) this run
196
+ * @param {boolean} [p.fallbackAsked] - Some of them still go to the fallback
197
+ * (it has not refused them — skipped by --max-cost, or no answer)
198
+ * @returns {string}
199
+ */
200
+ export function describeNewHold({ file, pairKey, pairConfig, count, fallbackAsked = false, lead = 'Remembered:' }) {
201
+ const them = count === 1 ? 'it' : `these ${count}`;
202
+ const fb = pairConfig.fallback;
203
+ const where = fb && fallbackAsked
204
+ ? `${pairConfig.method} again (its fallback, ${fb.method}, is asked for what it has not refused)`
205
+ : `${pairConfig.method}${fb ? ` or its fallback (${fb.method})` : ''} again`;
206
+ return `${lead} the next sync does not send ${them} to ${where} — not billed again; `
207
+ + `\`${contentRedoCommand(file, { pair: pairKey })}\` asks again.`;
208
+ }
209
+
210
+ /**
211
+ * The end of a lane's "N block(s) … written as '[EN] '-prefixed source" line:
212
+ * what the next sync does with them — asks again for a block that got no
213
+ * usable answer, holds back one the gate refused.
214
+ *
215
+ * @param {object} p
216
+ * @param {string} p.file
217
+ * @param {string} p.pairKey
218
+ * @param {object} p.pairConfig
219
+ * @param {number} p.fellBack - Blocks written as '[EN] ' this run
220
+ * @param {number[]} p.newlyHeld - Of those (indices into the batch), the ones the gate refused
221
+ * @param {string[][]} [p.refusedBy] - translateBlocksWithFallback().refusedBy
222
+ * @returns {string}
223
+ */
224
+ export function describeFallenBack({ file, pairKey, pairConfig, fellBack, newlyHeld, refusedBy = [] }) {
225
+ if (newlyHeld.length === 0) return 'Not cached, lock not advanced: the next sync retries just those block(s).';
226
+ const retry = fellBack - newlyHeld.length;
227
+ const fbKey = pairConfig.fallback ? tmMethodKey(pairConfig.fallback) : null;
228
+ const fallbackAsked = !!fbKey && newlyHeld.some(i => !(refusedBy[i] || []).includes(fbKey));
229
+ const n = newlyHeld.length;
230
+ return 'Not cached, lock not advanced. '
231
+ + (retry > 0 ? `The next sync asks again for the ${retry} that got no usable answer. ` : '')
232
+ + describeNewHold({
233
+ file, pairKey, pairConfig, count: n, fallbackAsked,
234
+ lead: `The ${n === 1 ? 'one' : n} the quality gate refused ${n === 1 ? 'is' : 'are'} remembered:`,
235
+ });
236
+ }
237
+
238
+ /**
239
+ * What a real run would hold back for one page — a dry run says so instead
240
+ * of "would create". The cache is consulted as the sync consults it (pure
241
+ * lookups: a preview changes nothing): only what it does not hold is held.
242
+ *
243
+ * @param {object} p
244
+ * @param {object} p.tm
245
+ * @param {string} p.code
246
+ * @param {object} p.pairConfig
247
+ * @param {Record<string, string>} p.fields - Translatable front-matter fields
248
+ * @param {string} p.body
249
+ * @param {'block'|'page'} p.segMode
250
+ * @param {ReturnType<typeof contentHolds>} p.holds
251
+ * @returns {{ names: string[], pageHeld: boolean }}
252
+ */
253
+ export function previewHeld({ tm, code, pairConfig, fields, body, segMode, holds }) {
254
+ const keys = [tmMethodKey(pairConfig), ...(pairConfig.fallback ? [tmMethodKey(pairConfig.fallback)] : [])];
255
+ const cached = (text) => keys.some(k => lookupTM(tm, text, code, k) !== null);
256
+ const names = [];
257
+ for (const [field, text] of Object.entries(fields || {})) {
258
+ if (typeof text === 'string' && !cached(text) && holds.field(field, text) === 'held') names.push(`front matter "${field}"`);
259
+ }
260
+ if (names.length > 0) return { names, pageHeld: true };
261
+ if (segMode === 'page' && typeof body === 'string' && body.trim() && !cached(body) && holds.page(body) === 'held') {
262
+ return { names: [PAGE_NAME], pageHeld: true };
263
+ }
264
+ if (segMode === 'block' && typeof body === 'string' && body.trim() && !cached(body)) {
265
+ translatableBlockSources(body).forEach((source, i) => {
266
+ if (!cached(source) && holds.block(source) === 'held') names.push(`paragraph ${i + 1}`);
267
+ });
268
+ }
269
+ return { names, pageHeld: false };
270
+ }