champollion 0.3.4 → 0.5.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 (138) hide show
  1. package/README.md +41 -26
  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 +82 -37
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +34 -0
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +286 -85
  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 +1709 -101
  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 +632 -125
  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 +15 -9
  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 +230 -17
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +284 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1136 -345
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +194 -35
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +727 -79
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +975 -262
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +1102 -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 +575 -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 +257 -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 +71 -129
  70. package/lib/methods/llm.js +87 -33
  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 +102 -55
  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 +6 -1
  100. package/lib/seal.mjs +4 -3
  101. package/lib/sealed-qualifier.mjs +1 -1
  102. package/lib/segment.js +24 -8
  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 +996 -21
  117. package/lib/verify.js +1332 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +1 -1
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/cards-fallback.json +1 -1
  123. package/shared/curated-orthography-conventions.json +26 -8
  124. package/shared/gettext-plural-forms.json +45 -0
  125. package/shared/licenses.json +99 -3
  126. package/shared/method-registry.json +2 -0
  127. package/shared/metric-registry.json +96 -18
  128. package/shared/retired-model-aliases.json +9 -0
  129. package/shared/schemas/champollion-plugin.schema.json +4 -0
  130. package/shared/schemas/corpora-card.schema.json +8 -2
  131. package/shared/schemas/human-services.schema.json +1 -1
  132. package/shared/schemas/method-index-record.schema.json +67 -0
  133. package/shared/schemas/method-registry.schema.json +4 -0
  134. package/shared/schemas/metric-registry.schema.json +55 -1
  135. package/shared/schemas/retired-model-aliases.schema.json +32 -0
  136. package/shared/docent/corpus.json +0 -11739
  137. package/shared/model-aliases.json +0 -7
  138. package/shared/schemas/model-aliases.schema.json +0 -27
package/lib/segment.js CHANGED
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import { PLACEHOLDER_PREFIX } from './content.js';
33
+ import { nameRules } from './name-rules.js';
33
34
 
34
35
  // Block separator: a newline followed by one or more blank lines
35
36
  // (whitespace-only lines count as blank — CommonMark treats them as
@@ -72,7 +73,10 @@ const SEGMENT_MARKER_SUFFIX = '⟧';
72
73
  function hasTranslatableText(text) {
73
74
  const stripped = text
74
75
  .replace(PLACEHOLDER_TOKEN_REGEX, '')
75
- .replace(IMPORT_EXPORT_LINE_REGEX, '');
76
+ .replace(IMPORT_EXPORT_LINE_REGEX, '')
77
+ // An HTML comment is a note to editors, never shown: not sent, not billed
78
+ // (dogfood 2026-10-05 — comment-only blocks were translated and gated).
79
+ .replace(/<!--[\s\S]*?-->/g, '');
76
80
  return /[\p{L}\p{N}]/u.test(stripped);
77
81
  }
78
82
 
@@ -185,15 +189,26 @@ function buildBlockBatchPrompt(blockTexts, langConfig, options = {}) {
185
189
  .map((text, i) => `${SEGMENT_MARKER_PREFIX}${i}${SEGMENT_MARKER_SUFFIX}\n${text}`)
186
190
  .join('\n\n');
187
191
 
192
+ // A second ask for segments the quality check refused (lib/fallback.js
193
+ // retryRefusedBlocks): the model is told why, and that text which is
194
+ // correct as written may come back unchanged.
195
+ const notes = langConfig.retryNotes instanceof Map
196
+ ? blockTexts.map((text, i) => (langConfig.retryNotes.has(text) ? `- Segment ${i}: ${langConfig.retryNotes.get(text)}` : null)).filter(Boolean)
197
+ : [];
198
+ const retryBlock = notes.length > 0
199
+ ? `\nYour previous translation of these segments was refused by an automatic quality check, for the reason given:\n${notes.join('\n')}\n`
200
+ + `Translate each one again. If a segment is correct exactly as written in ${sourceLanguageName} — a name, title, citation, identifier, code or data — return it unchanged.\n`
201
+ : '';
202
+
188
203
  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
204
  ${contextBlock}${titleBlock}
190
205
  Register/tone: ${langConfig.register}
191
-
206
+ ${retryBlock}
192
207
  Rules:
193
208
  - Translate ALL human-readable text in every segment.
194
209
  - Preserve ALL Markdown formatting: headers (#), bold (**), italic (*), links, images, lists, blockquotes, tables, admonitions (:::), etc.${placeholderRule}
195
210
  - Preserve line breaks and structure WITHIN each segment.
196
- - Proper nouns, product names, and technical terms should remain in the source language.
211
+ ${nameRules(options.protectedTerms)}
197
212
  - Translate link text but preserve link URLs. For example: [Read more](url) → [Lire la suite](url)
198
213
  - Echo each ${SEGMENT_MARKER_PREFIX}N${SEGMENT_MARKER_SUFFIX} marker on its own line, EXACTLY as given, before its translated segment.
199
214
  - Do NOT merge, drop, reorder, renumber, or add segments.
@@ -284,8 +299,8 @@ function parseBlockBatchResponse(response, expectedCount, opts = {}) {
284
299
  * 1. Send the full batch. Missing marker(s)? →
285
300
  * 2. ONE retry with only the missing segments (a fresh, smaller batch —
286
301
  * different neighbors, usually enough). Still missing? →
287
- * 3. Honest fallback: `fallbackPrefix + source` for just those segments
288
- * (the established '[EN] ' doctrine — visible, never silent).
302
+ * 3. The source block stands for just those segments, unmarked — the
303
+ * caller reports it, records it and asks again on the next sync.
289
304
  *
290
305
  * The CALLER's contract for fallen-back segments: never store them in the
291
306
  * TM (an error cached is an error forever) and never advance the file's
@@ -296,13 +311,12 @@ function parseBlockBatchResponse(response, expectedCount, opts = {}) {
296
311
  * @param {string[]} p.texts - Protected block texts to translate
297
312
  * @param {(texts: string[]) => string} p.buildPrompt - Batch prompt builder
298
313
  * @param {(prompt: string) => Promise<string|null>} p.callModel - One API call
299
- * @param {string} p.fallbackPrefix - e.g. '[EN] ' — prepended to source text
300
314
  * @returns {Promise<{blocks: string[], fellBack: number[]}>} index-aligned
301
315
  * translations; `fellBack` lists indexes that carry the fallback
302
316
  * @throws {Error} If the FIRST call returns nothing at all, or on
303
317
  * duplicate/unknown markers (untrustworthy mapping)
304
318
  */
305
- async function translateBlockBatchResilient({ texts, buildPrompt, callModel, fallbackPrefix }) {
319
+ async function translateBlockBatchResilient({ texts, buildPrompt, callModel }) {
306
320
  const first = await callModel(buildPrompt(texts));
307
321
  if (!first) {
308
322
  throw new Error('block-batch translation returned no results');
@@ -330,8 +344,10 @@ async function translateBlockBatchResilient({ texts, buildPrompt, callModel, fal
330
344
  }
331
345
  }
332
346
 
347
+ // Still missing after the retry: the source block stands, unmarked; the
348
+ // caller records it, does not cache it and does not advance the lock.
333
349
  for (const i of stillMissing) {
334
- blocks[i] = fallbackPrefix + texts[i];
350
+ blocks[i] = texts[i];
335
351
  }
336
352
  return { blocks, fellBack: stillMissing };
337
353
  }
package/lib/seo.js CHANGED
@@ -93,6 +93,22 @@ function generateHreflangTags({ baseUrl, urlPattern, inputLocale, locales, pageP
93
93
  return lines.join('\n');
94
94
  }
95
95
 
96
+ /**
97
+ * The target codes in `languages`, in either documented form — a list
98
+ * (["fr","de"]) or an object keyed by code ({ "fr": "formal-vous", … }, the
99
+ * form `champollion init` writes when it records each language's register).
100
+ * The object form used to be skipped here, so its locales got no hreflang.
101
+ *
102
+ * @param {object} config
103
+ * @returns {string[]}
104
+ */
105
+ function configLanguageCodes(config) {
106
+ const langs = config.languages;
107
+ if (Array.isArray(langs)) return [...langs];
108
+ if (langs && typeof langs === 'object') return Object.keys(langs);
109
+ return [];
110
+ }
111
+
96
112
  /**
97
113
  * Generate hreflang tags for all configured pages.
98
114
  *
@@ -108,9 +124,7 @@ function generateAllHreflangTags(config) {
108
124
 
109
125
  // Collect all target locales from config
110
126
  const locales = [];
111
- if (config.languages && Array.isArray(config.languages)) {
112
- locales.push(...config.languages);
113
- }
127
+ locales.push(...configLanguageCodes(config));
114
128
  if (config.pairs) {
115
129
  for (const [pairKey] of Object.entries(config.pairs)) {
116
130
  const { target } = parsePairKey(pairKey);
@@ -163,9 +177,7 @@ function generateSitemap(config) {
163
177
 
164
178
  // Collect all locales
165
179
  const targetLocales = [];
166
- if (config.languages && Array.isArray(config.languages)) {
167
- targetLocales.push(...config.languages);
168
- }
180
+ targetLocales.push(...configLanguageCodes(config));
169
181
  if (config.pairs) {
170
182
  for (const [pairKey] of Object.entries(config.pairs)) {
171
183
  const { target } = parsePairKey(pairKey);
@@ -224,9 +236,7 @@ function generateJsonLd(config) {
224
236
 
225
237
  // Collect all locales
226
238
  const targetLocales = [];
227
- if (config.languages && Array.isArray(config.languages)) {
228
- targetLocales.push(...config.languages);
229
- }
239
+ targetLocales.push(...configLanguageCodes(config));
230
240
  if (config.pairs) {
231
241
  for (const [pairKey] of Object.entries(config.pairs)) {
232
242
  const { target } = parsePairKey(pairKey);
package/lib/serve.js CHANGED
@@ -20,8 +20,10 @@
20
20
  *
21
21
  * NO FORKED TRANSLATION LOGIC:
22
22
  * Requests run through the SAME pipeline `sync` uses — resolveRuntime for
23
- * config/plugin/preflight resolution and translateAndValidate for the
24
- * TM partition → API call → deterministic quality gate → TM store sequence.
23
+ * config/plugin/preflight resolution and translateWithFallback for the
24
+ * TM partition → API call → deterministic quality gate → TM store sequence
25
+ * (plus the pair's fallback method, when one is configured, for what the
26
+ * pair's own method could not translate safely).
25
27
  * TM hits are served from cache at $0; gate failures come back as
26
28
  * structured per-key errors, never as silently degraded output.
27
29
  *
@@ -49,7 +51,8 @@ import { createRequire } from 'node:module';
49
51
  import { resolveConfig } from './config.js';
50
52
  import { resolveRuntime } from './sync.js';
51
53
  import { loadTM, saveTM, isTMDirty, partitionByTM, tmMethodKey } from './tm.js';
52
- import { translateAndValidate } from './translate-pair.js';
54
+ import { translateWithFallback } from './translate-pair.js';
55
+ import { createFallbackBudget, fallbackSummary } from './fallback.js';
53
56
  import { estimateCost, QUALITY_TIERS } from './pairs.js';
54
57
  import { getMethod } from './translate.js';
55
58
  import { isUnsafeKey } from './security.js';
@@ -502,7 +505,7 @@ async function handleTranslate(ctx, body, res) {
502
505
  const { misses } = partitionByTM(runtime.tm, payload, keyNames, targetLocale, tmKey);
503
506
  let estimate = null;
504
507
  if (misses.length > 0) {
505
- estimate = await estimateCost(misses.length, pairConfig);
508
+ estimate = await estimateCost(misses.length, pairConfig, { cwd: runtime.cwd });
506
509
  const estimated = estimate?.estimatedCost ?? null;
507
510
 
508
511
  if (maxCostPerRequest !== null || maxSessionCost !== null) {
@@ -525,26 +528,47 @@ async function handleTranslate(ctx, body, res) {
525
528
  }
526
529
  }
527
530
 
531
+ // ── The pair's fallback method, under the same caps ────────────────
532
+ // What the pair's own method cannot translate safely goes to its
533
+ // fallback (if configured). Under a cap, that batch is priced before it
534
+ // runs and must fit in what the primary's estimate left of the tighter of
535
+ // the per-request cap and the remaining session ceiling (lib/fallback.js).
536
+ const capsLeft = [
537
+ maxCostPerRequest,
538
+ maxSessionCost !== null ? maxSessionCost - session.spendUsd : null,
539
+ ].filter(v => v !== null);
540
+ const primaryEstimate = misses.length > 0 ? (estimate?.estimatedCost ?? 0) : 0;
541
+ const fallbackBudget = createFallbackBudget({
542
+ maxCost: capsLeft.length > 0 ? Math.min(...capsLeft) : null,
543
+ committed: primaryEstimate,
544
+ cwd: runtime.cwd,
545
+ });
546
+
528
547
  // ── The real pipeline: TM partition → API → quality gate → TM store ──
529
548
  let result;
530
549
  try {
531
- result = await translateAndValidate(keyNames, payload, pairConfig, pairKey, {
550
+ result = await translateWithFallback(keyNames, payload, pairConfig, pairKey, {
532
551
  apiKey: runtime.apiKey,
552
+ cwd: runtime.cwd,
533
553
  tm: runtime.tm,
534
554
  targetCode: targetLocale,
535
555
  onProgress: null,
556
+ budget: fallbackBudget,
536
557
  });
537
558
  } catch (err) {
538
559
  output.error(`[serve] ${pairKey}: pipeline error — ${err.message}`);
539
560
  return sendError(res, 500, 'internal_error', 'Translation pipeline failed. See server logs.');
540
561
  }
541
- if (result.apiCalled) output.progress(' done\n');
562
+ if (result.apiCalled) output.progressDone(`[serve] ${pairKey}`, 'done');
542
563
 
543
564
  // Session ledger: count the estimate for any request that actually hit
544
565
  // the upstream API (conservative — estimates deliberately err high).
545
566
  if (result.apiCalled && estimate?.estimatedCost != null) {
546
567
  session.spendUsd += estimate.estimatedCost;
547
568
  }
569
+ // Fallback batches approved under a cap carry their estimate too.
570
+ const fallbackSpend = fallbackBudget.committed - primaryEstimate;
571
+ if (fallbackSpend > 0) session.spendUsd += fallbackSpend;
548
572
 
549
573
  // Persist TM mutations (stores AND evictions) after every request so a
550
574
  // crash never loses paid, gate-validated translations. Atomic write.
@@ -616,6 +640,17 @@ async function handleTranslate(ctx, body, res) {
616
640
  costUsd = estimate.estimatedCost;
617
641
  costBasis = 'estimate';
618
642
  }
643
+ // A fallback that ran adds its own spend: priced when a cap made us price
644
+ // it, otherwise unknown — never silently left out of a known figure.
645
+ if (result.fallback?.attempted > 0) {
646
+ if (fallbackBudget.maxCost !== null && costUsd !== null) {
647
+ costUsd += fallbackSpend;
648
+ costBasis = 'estimate';
649
+ } else {
650
+ costUsd = null;
651
+ costBasis = 'unknown';
652
+ }
653
+ }
619
654
 
620
655
  const meta = {
621
656
  served_by: `champollion-serve/${CLI_VERSION}`,
@@ -629,6 +664,8 @@ async function handleTranslate(ctx, body, res) {
629
664
  translated: translatedCount,
630
665
  failed: errorCount,
631
666
  tm_hits: result.tmHitCount,
667
+ // The pair's fallback method and what it did (pairs with a fallback).
668
+ ...(result.fallback && { fallback: fallbackSummary(result.fallback) }),
632
669
  // Script conversion status for this pair: which script was written and
633
670
  // how many values stayed in the working script (unmapped letters).
634
671
  script_conversion: {
@@ -0,0 +1,164 @@
1
+ /**
2
+ * shared-output-seed.js — what a locale's shared-output index starts a sync
3
+ * with: the translations already on disk that this run leaves alone.
4
+ *
5
+ * WHY: `verify` checks one locale's whole state for a model repeating one
6
+ * memorized sentence (lib/verify.js sharedOutputErrors): every key value of
7
+ * every file AND every Markdown page, counted together. Sync's gate
8
+ * (lib/validate.js SharedOutputIndex) saw less — only what this run accepted,
9
+ * plus, per key-value file, what that file kept, added when the file was
10
+ * reached. So a new answer repeating a sentence that sat in an untouched
11
+ * newsletter page, in a key-value file with nothing to translate (it returns
12
+ * before it seeds), or in a namespace file processed later, was written —
13
+ * and `verify` then flagged it (Round 10, school persona). Seeding the index
14
+ * with what the run leaves alone, before anything is checked, gives the gate
15
+ * the same scope `verify` reads.
16
+ *
17
+ * "Left alone" is what the run will not re-translate:
18
+ * - a key value Champollion recorded writing for the CURRENT source text
19
+ * (the lock's written record matches the source and the value), or one
20
+ * with no record whose source did not change — never an '[EN] '
21
+ * placeholder, a pending key, a key named for a redo, or any key under a
22
+ * bulk `--redo all`;
23
+ * - a Markdown page whose content lock says it was made from the current
24
+ * source, when no --redo files:/content, --force-content or --retranslate
25
+ * scope reaches it.
26
+ * Values the run replaces are left out, so their old text never counts
27
+ * against the new one. Read-only.
28
+ */
29
+
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import crypto from 'node:crypto';
33
+ import { expectedForTarget, readLocaleFlat, lockKey, discoverLocaleLayout, loadSourceUnits } from './locale-layout.js';
34
+ import { originKey } from './plurals.js';
35
+ import { decodeWritten, shortSourceHash, valueHash, LockState } from './locale-state.js';
36
+ import { SharedOutputIndex, sharedOutputItems } from './validate.js';
37
+ import { readLock } from './hash.js';
38
+ import { discoverContentFiles, getTargetContentPath } from './content.js';
39
+ import { contentItemsFor } from './verify.js';
40
+ import { readContentManifest } from './content-sync.js';
41
+
42
+ /**
43
+ * Items (lib/validate.js sharedOutputItems shape) for the translations of
44
+ * `code` on disk that this run leaves alone.
45
+ *
46
+ * @param {object} p
47
+ * @param {object} p.config - Resolved config (inputLocale, contentDir, format, fallbackPrefix, …)
48
+ * @param {object} p.layout - Locale layout (lib/locale-layout.js)
49
+ * @param {Array<object>} p.units - Source units (loadSourceUnits)
50
+ * @param {string} p.code - Target locale
51
+ * @param {{ written: object, pending: object }} p.localeState - LockState.peek(code)
52
+ * @param {string[]} [p.namedKeys] - Keys named for a redo (source key space, namespaced)
53
+ * @param {boolean} [p.bulk] - `--redo all`: every key is re-translated
54
+ * @param {object|null} [p.fileScope] - lib/file-scope.js (--redo files: / --files / --retranslate)
55
+ * @param {boolean} [p.forceContent] - --force-content / --redo content
56
+ * @param {string} p.cwd - Project directory
57
+ * @returns {Array<{ key: string, source: string, value: string }>}
58
+ */
59
+ export function itemsLeftAlone({
60
+ config, layout, units, code, localeState, namedKeys = [], bulk = false, fileScope = null, forceContent = false, cwd,
61
+ }) {
62
+ const items = [];
63
+ const fallbackPrefix = config.fallbackPrefix || '[EN] ';
64
+ const named = new Set(namedKeys);
65
+ const written = localeState?.written || {};
66
+ const pending = localeState?.pending || {};
67
+
68
+ if (!bulk) {
69
+ for (const unit of units || []) {
70
+ const file = layout.fileFor(code, unit.ns);
71
+ if (!fs.existsSync(file.path)) continue;
72
+ let target;
73
+ try { target = readLocaleFlat(file) || {}; } catch { continue; }
74
+ const { flat: expected, expansion } = expectedForTarget(unit, config.inputLocale, code);
75
+ const changed = new Set(unit.changedKeys || []);
76
+ for (const [k, v] of Object.entries(target)) {
77
+ const src = expected[k];
78
+ if (typeof v !== 'string' || typeof src !== 'string' || v.startsWith(fallbackPrefix)) continue;
79
+ const lk = lockKey(layout, unit.ns, k);
80
+ const origin = originKey(k, expansion);
81
+ if (pending[lk] !== undefined || named.has(lk) || named.has(lockKey(layout, unit.ns, origin))) continue;
82
+ const record = decodeWritten(written[lk]);
83
+ if (record) {
84
+ if (record.source !== shortSourceHash(src) || record.value !== valueHash(v)) continue;
85
+ } else if (changed.has(origin)) {
86
+ continue;
87
+ }
88
+ items.push(...sharedOutputItems(lk, src, v));
89
+ }
90
+ }
91
+ }
92
+
93
+ if (config.contentDir && config.format !== 'docusaurus') {
94
+ const contentDir = path.resolve(cwd, config.contentDir);
95
+ const manifest = readContentManifest(cwd);
96
+ let sources = [];
97
+ try { sources = fs.existsSync(contentDir) ? discoverContentFiles(contentDir, config.inputLocale) : []; } catch { sources = []; }
98
+ const leftAlone = new Set();
99
+ for (const sourcePath of sources) {
100
+ const relPath = path.relative(contentDir, sourcePath);
101
+ if (!fs.existsSync(getTargetContentPath(sourcePath, code, config.inputLocale))) continue;
102
+ if (fileScope && !fileScope.includes(relPath)) { leftAlone.add(sourcePath); continue; }
103
+ if (forceContent || (fileScope && fileScope.retranslates(relPath))) continue;
104
+ let hash;
105
+ try { hash = crypto.createHash('sha256').update(fs.readFileSync(sourcePath, 'utf-8'), 'utf-8').digest('hex'); } catch { continue; }
106
+ if (manifest[`${relPath}:${code}`] === hash) leftAlone.add(sourcePath);
107
+ }
108
+ if (leftAlone.size > 0) items.push(...contentItemsFor(config, cwd, code, { only: (p) => leftAlone.has(p) }));
109
+ }
110
+ return items;
111
+ }
112
+
113
+ /**
114
+ * The shared-output index a sync of `code` starts with — ONE construction for
115
+ * sync's gate and for anything else that must refuse what sync refuses (the
116
+ * MCP translate tool with a project_dir): the sentences an earlier sync caught
117
+ * a model repeating (the cache's `_meta.memorized`, refused from their first
118
+ * source on), plus every translation of the locale on disk that the run
119
+ * leaves alone (itemsLeftAlone). Round 10, school persona: the translate tool
120
+ * returned the sentence the project already knew to be memorized, because
121
+ * its index held only that call's answers.
122
+ *
123
+ * @param {object} p
124
+ * @param {object} p.config - Resolved config
125
+ * @param {string} p.cwd - Project directory
126
+ * @param {string} p.code - Target locale
127
+ * @param {object|null} [p.tm] - The project's cache (lib/tm.js loadTM)
128
+ * @param {object} [p.layout] - Locale layout (discovered when absent)
129
+ * @param {Array<object>} [p.units] - Source units (loaded when absent)
130
+ * @param {object} [p.localeState] - LockState.peek(code) (read from the lock when absent)
131
+ * @param {string[]} [p.namedKeys] - Keys named for a redo this run
132
+ * @param {boolean} [p.bulk] - `--redo all`
133
+ * @param {object|null} [p.fileScope] - --redo files: / --files / --retranslate
134
+ * @param {boolean} [p.forceContent] - --force-content / --redo content
135
+ * @returns {SharedOutputIndex}
136
+ */
137
+ export function projectSharedOutputIndex({
138
+ config, cwd, code, tm = null, layout = null, units = null, localeState = null,
139
+ namedKeys = [], bulk = false, fileScope = null, forceContent = false,
140
+ }) {
141
+ const index = new SharedOutputIndex({ protectedTerms: config.protectedTerms || [] });
142
+ index.markMemorized(tm?._meta?.memorized?.[code] || []);
143
+ let lay = layout;
144
+ let us = units;
145
+ let state = localeState;
146
+ // A project whose key-value files cannot be read (a Docusaurus site, a
147
+ // missing locales folder) still has its pages and its memorized sentences.
148
+ if (!lay || !us) {
149
+ try {
150
+ lay = discoverLocaleLayout(config, { cwd });
151
+ us = loadSourceUnits(lay);
152
+ } catch {
153
+ lay = null;
154
+ us = [];
155
+ }
156
+ }
157
+ if (!state) {
158
+ try { state = new LockState(readLock(cwd).locales).peek(code); } catch { state = { written: {}, pending: {} }; }
159
+ }
160
+ index.add(itemsLeftAlone({
161
+ config, layout: lay, units: lay ? us : [], code, localeState: state, namedKeys, bulk, fileScope, forceContent, cwd,
162
+ }));
163
+ return index;
164
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * source-contexts.js — which gettext contexts (msgctxt) a project's source
3
+ * gives each text.
4
+ *
5
+ * Sync keys a gettext entry with a context as `msgctxt\u0004msgid`, and caches
6
+ * its translation under the context folded into the text (lib/tm-evict.js
7
+ * tmSourceText): "Cancel" the button and "Cancel" the verb are two entries.
8
+ * A caller that translates a bare text in a project (the MCP translate tool
9
+ * with project_dir) needs to know when that text exists there ONLY with a
10
+ * context: a context-free cache entry for it is never read by sync, and only
11
+ * shadows the context-keyed ones (Round 13, Django persona).
12
+ */
13
+
14
+ import { discoverLocaleLayout, loadSourceUnits } from './locale-layout.js';
15
+ import { CONTEXT_SEPARATOR } from './tm-evict.js';
16
+
17
+ /**
18
+ * Every source text of the project, with whether it has an entry without a
19
+ * context and the contexts it has.
20
+ *
21
+ * @param {object} config - A resolved config (lib/config.js resolveConfig)
22
+ * @param {{ cwd: string }} options
23
+ * @returns {Map<string, { plain: boolean, contexts: string[] }>} source text → its entries
24
+ */
25
+ export function sourceTextContexts(config, { cwd }) {
26
+ const layout = discoverLocaleLayout(config, { cwd });
27
+ const out = new Map();
28
+ for (const unit of loadSourceUnits(layout)) {
29
+ for (const [key, value] of Object.entries(unit.flat)) {
30
+ if (typeof value !== 'string') continue;
31
+ if (!out.has(value)) out.set(value, { plain: false, contexts: [] });
32
+ const entry = out.get(value);
33
+ const at = key.indexOf(CONTEXT_SEPARATOR);
34
+ if (at < 0) entry.plain = true;
35
+ else if (!entry.contexts.includes(key.slice(0, at))) entry.contexts.push(key.slice(0, at));
36
+ }
37
+ }
38
+ return out;
39
+ }
package/lib/submit.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * submit — declarative model behind `champollion submit`.
2
+ * submit — declarative model behind `champollion network submit`.
3
3
  *
4
4
  * The CLI front door to Champollion's REVIEW-GATED community submission path.
5
5
  * It gathers the fields a submission needs and hands the contributor a
@@ -22,6 +22,8 @@
22
22
  * @see .github/ISSUE_TEMPLATE/
23
23
  */
24
24
 
25
+ import { parseLanguagePair, formatLanguagePair } from './language-pair.js';
26
+
25
27
  /**
26
28
  * The compliance attestation every submission must carry. A single source of
27
29
  * truth so the CLI, the issue forms, and the docs all say the SAME thing.
@@ -30,6 +32,14 @@ export const ATTESTATION_TEXT =
30
32
  'I confirm this is publicly listable, contains NO corpus content or personal data, ' +
31
33
  "and respects the source's license and any community/sovereignty restrictions.";
32
34
 
35
+ /**
36
+ * The label of every "language pair(s)" field. Three types asked for pairs in
37
+ * two spellings (eng-crk in one, source→target in two); the reader below
38
+ * takes any spelling lib/language-pair.js accepts and the submission carries
39
+ * each pair as source>target, the form the leaderboard writes.
40
+ */
41
+ export const PAIRS_LABEL = 'Language pair(s), ISO 639-3, one per line, source>target (e.g. eng>crk; eng-crk works too)';
42
+
33
43
  /** Repo the issue forms live in. Overridable, but this is the real default. */
34
44
  export const DEFAULT_REPO_BASE = 'https://github.com/gamedaysuits/Champollion';
35
45
 
@@ -37,6 +47,7 @@ export const DEFAULT_REPO_BASE = 'https://github.com/gamedaysuits/Champollion';
37
47
  // input — single-line free text
38
48
  // text — multi-line free text (textarea)
39
49
  // choice — one-of a fixed list (`choices`)
50
+ // (a text field with `pairs: true` holds language pairs, one per line — readPairLines)
40
51
  // bool — a required confirmation (attestation / consent); never sent in the
41
52
  // pre-fill URL (GitHub can't pre-check a checkbox), only recorded
42
53
  // locally and enforced by validation.
@@ -57,7 +68,7 @@ export const SUBMISSION_TYPES = [
57
68
  blurb: 'An evaluation corpus or benchmark — metadata + a fetch-from-source pointer only, never the corpus content.',
58
69
  fields: [
59
70
  { id: 'dataset-name', label: 'Dataset name', kind: 'input', required: true, primary: true },
60
- { id: 'pairs', label: 'Language pair(s) (ISO 639-3, e.g. eng-crk; one per line)', kind: 'text', required: true },
71
+ { id: 'pairs', label: PAIRS_LABEL, kind: 'text', pairs: true, required: true },
61
72
  { id: 'license', label: 'License', kind: 'choice', required: true,
62
73
  choices: ['CC-BY-4.0', 'CC-BY-SA-4.0', 'CC0-1.0', 'CC-BY-NC-4.0', 'Proprietary / all rights reserved', 'Other / unsure'] },
63
74
  { id: 'license-url', label: 'License URL', kind: 'input' },
@@ -135,7 +146,7 @@ export const SUBMISSION_TYPES = [
135
146
  { id: 'display-name', label: 'Provider display name (consented public name)', kind: 'input', required: true, primary: true },
136
147
  { id: 'provider-type', label: 'Provider type', kind: 'choice', required: true,
137
148
  choices: ['community_org', 'agency', 'individual'] },
138
- { id: 'pairs', label: 'Language pair(s) (ISO 639-3 source→target, one per line)', kind: 'text', required: true },
149
+ { id: 'pairs', label: PAIRS_LABEL, kind: 'text', pairs: true, required: true },
139
150
  { id: 'variety', label: 'Variety / script (e.g. SRO, Latn)', kind: 'input' },
140
151
  { id: 'dispatch-channel', label: 'Dispatch channel (the address itself is PII — omit it)', kind: 'choice',
141
152
  choices: ['email', 'webhook', 'api'] },
@@ -158,7 +169,7 @@ export const SUBMISSION_TYPES = [
158
169
  blurb: 'A published result from another system/paper — cited, never re-hosted or re-ranked as our own measurement.',
159
170
  fields: [
160
171
  { id: 'system-name', label: 'System / method name', kind: 'input', required: true, primary: true },
161
- { id: 'pairs', label: 'Language pair(s) (ISO 639-3 source→target, one per line)', kind: 'text', required: true },
172
+ { id: 'pairs', label: PAIRS_LABEL, kind: 'text', pairs: true, required: true },
162
173
  { id: 'dataset', label: 'Dataset / benchmark', kind: 'input', required: true },
163
174
  { id: 'metric', label: 'Metric', kind: 'choice', required: true,
164
175
  choices: ['chrF++', 'chrF', 'BLEU', 'COMET', 'TER', 'Other'] },
@@ -252,7 +263,7 @@ export function repoBaseFromRepository(repoUrl) {
252
263
  */
253
264
  export function validateSubmission(type, values = {}) {
254
265
  const errors = [];
255
- if (!type) return { ok: false, errors: ['unknown submission type — run `champollion submit --list`'] };
266
+ if (!type) return { ok: false, errors: ['unknown submission type — run `champollion network submit --list`'] };
256
267
  for (const f of type.fields) {
257
268
  const v = values[f.id];
258
269
  if (f.kind === 'bool') {
@@ -266,10 +277,51 @@ export function validateSubmission(type, values = {}) {
266
277
  const empty = v == null || String(v).trim() === '';
267
278
  if (empty) errors.push(`missing required field: ${f.id} (${f.label})`);
268
279
  }
280
+ if (f.pairs && v != null && String(v).trim() !== '') {
281
+ for (const e of readPairLines(v).errors) errors.push(`${f.id}: ${e}`);
282
+ }
269
283
  }
270
284
  return { ok: errors.length === 0, errors };
271
285
  }
272
286
 
287
+ /**
288
+ * Read a "language pair(s)" field: one pair per line, each in any spelling
289
+ * lib/language-pair.js accepts. Blank lines are skipped.
290
+ *
291
+ * @param {*} value
292
+ * @returns {{pairs: string[], errors: string[]}} pairs written source>target
293
+ */
294
+ export function readPairLines(value) {
295
+ const pairs = [];
296
+ const errors = [];
297
+ String(value).split(/\r?\n/).forEach((line, i) => {
298
+ if (!line.trim()) return;
299
+ const p = parseLanguagePair(line, { label: `line ${i + 1}:` });
300
+ if (p.ok) pairs.push(formatLanguagePair(p));
301
+ else errors.push(p.error);
302
+ });
303
+ return { pairs, errors };
304
+ }
305
+
306
+ /**
307
+ * The values with every pair field written the one way (source>target, one
308
+ * per line), for the issue URL and the local record. A field that does not
309
+ * read cleanly is left as typed — validateSubmission reports it.
310
+ *
311
+ * @param {object} type one of SUBMISSION_TYPES
312
+ * @param {Record<string,*>} values
313
+ * @returns {Record<string,*>} a new object; `values` is not changed
314
+ */
315
+ export function normalizeSubmissionValues(type, values = {}) {
316
+ const out = { ...values };
317
+ for (const f of type.fields) {
318
+ if (!f.pairs || out[f.id] == null || String(out[f.id]).trim() === '') continue;
319
+ const r = readPairLines(out[f.id]);
320
+ if (r.errors.length === 0) out[f.id] = r.pairs.join('\n');
321
+ }
322
+ return out;
323
+ }
324
+
273
325
  /** The field whose value seeds the issue title (the `primary` one). */
274
326
  function primaryField(type) {
275
327
  return type.fields.find((f) => f.primary) || null;