champollion 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
package/lib/pairs.js ADDED
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Language pair resolution — converts config into a directional pair graph.
3
+ *
4
+ * WHY: champollion v2 assumed English→X for everything. v3 models
5
+ * translation as directional pairs (en:fr, es:en, en:crk) where each
6
+ * pair can have its own method, model, quality tier, and cost profile.
7
+ *
8
+ * The pair model supports two config modes:
9
+ * 1. Simple: `languages: ["fr", "de"]` — all pairs use default method/model
10
+ * 2. Advanced: `pairs: { "en:crk": { method: "fst-gated" } }` — per-pair overrides
11
+ *
12
+ * Both can coexist: `pairs` overrides `languages` for specific language targets.
13
+ *
14
+ * PAIR KEY FORMAT: "source:target" (e.g., "en:fr")
15
+ * Canonical separator is the colon (:) — compact, ASCII-safe, and
16
+ * unambiguous since no locale code contains a colon. Legacy formats
17
+ * (→, ->) are still accepted by parsePairKey for backward compatibility.
18
+ */
19
+
20
+ import { DEFAULT_REGISTERS, getLanguageCard, resolveCode, DEFAULT_REGISTER_FALLBACK } from './registers.js';
21
+ import { resolveTargetScript, validateScriptFallback, converterKeyForLocale } from './scripts.js';
22
+ import { getMethod } from './translate.js';
23
+ import { DEFAULT_OPENROUTER_MODEL, DEFAULT_BATCH_SIZE, DEFAULT_MAX_RETRIES } from './config.js';
24
+
25
+ /**
26
+ * Quality tiers — define the expected reliability of a translation.
27
+ *
28
+ * These tiers aren't arbitrary labels. Each corresponds to a concrete
29
+ * verification level that determines how much the output can be trusted
30
+ * without human review.
31
+ */
32
+ const QUALITY_TIERS = {
33
+ standard: {
34
+ label: 'Standard',
35
+ description: 'Direct LLM translation. No post-processing verification.',
36
+ },
37
+ high: {
38
+ label: 'High',
39
+ description: 'LLM translation with grammar/dictionary coaching. Better for complex morphology.',
40
+ },
41
+ research: {
42
+ label: 'Research',
43
+ description: 'LLM + deterministic FST/grammar gate. Morphologically verified output.',
44
+ },
45
+ verified: {
46
+ label: 'Verified',
47
+ description: 'LLM draft flagged for human review. Highest confidence.',
48
+ },
49
+ };
50
+
51
+ /**
52
+ * Default method config — applied to all pairs unless overridden.
53
+ */
54
+ const PAIR_DEFAULTS = {
55
+ method: 'llm',
56
+ model: null, // null = inherit from top-level config.model
57
+ qualityTier: 'standard',
58
+ batchSize: null, // null = inherit from top-level config.batchSize
59
+ maxRetries: DEFAULT_MAX_RETRIES, // max cascade retries on batch parse failure (batch → half → individual)
60
+ };
61
+
62
+ /**
63
+ * Methods that connect directly to a provider API (not via OpenRouter).
64
+ *
65
+ * For these methods, the global config.model (which is an OpenRouter slug like
66
+ * "google/gemini-3.5-flash") would be wrong — each provider has its own model
67
+ * naming scheme. When model is not explicitly set, we pass null so the method
68
+ * class's _getDefaultModel() fires with the correct provider-specific slug.
69
+ */
70
+ const DIRECT_PROVIDER_METHODS = new Set([
71
+ 'gemini', 'openai', 'anthropic', 'deepl',
72
+ 'google-translate', 'microsoft-translator', 'libretranslate',
73
+ ]);
74
+
75
+ /**
76
+ * Resolve the model for a translation pair.
77
+ *
78
+ * - If the user explicitly set a model, use it (they know what they want).
79
+ * - If the method is a direct provider, return null — let the method class
80
+ * pick its own default via _getDefaultModel().
81
+ * - Otherwise (OpenRouter-style methods), use the global default.
82
+ *
83
+ * @param {string|null} explicitModel - Model from per-language or per-pair config
84
+ * @param {string} method - Translation method name
85
+ * @param {string} globalDefault - Global model from config (OpenRouter slug)
86
+ * @returns {string|null} Resolved model, or null for direct providers
87
+ */
88
+ function resolveModelForPair(explicitModel, method, globalDefault) {
89
+ if (explicitModel) return explicitModel;
90
+ if (DIRECT_PROVIDER_METHODS.has(method)) return null;
91
+ return globalDefault;
92
+ }
93
+
94
+ /**
95
+ * Resolve the full pair graph from config.
96
+ *
97
+ * Returns a Map of pairKey → pairConfig, where each pairConfig contains:
98
+ * - source: source locale code (e.g., "en")
99
+ * - target: target locale code (e.g., "fr")
100
+ * - method: translation method name (e.g., "llm", "llm-coached")
101
+ * - model: model identifier (e.g., "openai/gpt-4o-mini")
102
+ * - qualityTier: one of QUALITY_TIERS keys
103
+ * - batchSize: keys per API batch
104
+ * - register: target language register (tone/style instructions)
105
+ * - name: target language display name
106
+ * - dir: text directionality ('ltr' or 'rtl')
107
+ * - scripts: available script conversions (if any)
108
+ * - endpoint: API endpoint URL for the bare "api" method (if set)
109
+ *
110
+ * Pair keys use colon separator: "en:fr", "en:crk".
111
+ * Legacy arrow formats (en→fr, en->fr) in config.pairs are accepted
112
+ * by parsePairKey but stored internally in colon format.
113
+ *
114
+ * @param {import('./types.js').ChampollionConfig} config - Resolved config (post-migration, post-defaults)
115
+ * @returns {Map<string, import('./types.js').PairConfig>} Pair graph
116
+ */
117
+ function resolvePairs(config) {
118
+ const pairs = new Map();
119
+ const inputLocale = config.inputLocale;
120
+ const defaultModel = config.model || DEFAULT_OPENROUTER_MODEL;
121
+ const defaultBatchSize = config.batchSize || DEFAULT_BATCH_SIZE;
122
+ const defaultMethod = config.defaultMethod || PAIR_DEFAULTS.method;
123
+
124
+ // Step 1: Build pairs from the `languages` array (simple mode)
125
+ const languages = config.resolvedLanguages || {};
126
+ for (const [code, langConfig] of Object.entries(languages)) {
127
+ const pairKey = buildPairKey(inputLocale, code);
128
+ // Use language card for structured metadata (formality, gender, script).
129
+ // Falls back to backward-compat proxy for languages without cards.
130
+ const card = getLanguageCard(code);
131
+ const registerInfo = DEFAULT_REGISTERS[code] || {};
132
+
133
+ // Resolution order: language config > global config > defaults.
134
+ // Language-level fields (model, batchSize, maxRetries, script) set
135
+ // per-language defaults without requiring the verbose `pairs` syntax.
136
+ //
137
+ // _defaults tracks which fields were filled from system defaults rather
138
+ // than explicitly set by the user. resolvePluginForPair uses this to
139
+ // let plugin config override defaults while respecting explicit settings.
140
+ const _defaults = new Set();
141
+ const method = langConfig.method || defaultMethod;
142
+ const model = resolveModelForPair(langConfig.model, method, defaultModel);
143
+ if (!langConfig.model) _defaults.add('model');
144
+ const batchSize = langConfig.batchSize || defaultBatchSize;
145
+ if (!langConfig.batchSize) _defaults.add('batchSize');
146
+ const register = langConfig.register || registerInfo.register || DEFAULT_REGISTER_FALLBACK;
147
+ if (!langConfig.register) _defaults.add('register');
148
+
149
+ // Track fields from system defaults so plugins can override them.
150
+ // If the user didn't explicitly set these, they're defaults.
151
+ const temperature = langConfig.temperature ?? config.temperature ?? null;
152
+ if (langConfig.temperature == null) _defaults.add('temperature');
153
+ const coachingFile = langConfig.coachingFile ?? config.coachingFile ?? null;
154
+ if (langConfig.coachingFile == null) _defaults.add('coachingFile');
155
+ const coachingPrompt = langConfig.coachingPrompt ?? config.coachingPrompt ?? null;
156
+ if (langConfig.coachingPrompt == null) _defaults.add('coachingPrompt');
157
+ const promptContext = langConfig.promptContext ?? config.promptContext ?? null;
158
+ if (langConfig.promptContext == null) _defaults.add('promptContext');
159
+ const contentSegmentation = langConfig.contentSegmentation ?? config.contentSegmentation ?? null;
160
+ if (langConfig.contentSegmentation == null) _defaults.add('contentSegmentation');
161
+
162
+ pairs.set(pairKey, {
163
+ source: inputLocale,
164
+ target: code,
165
+ method,
166
+ model,
167
+ qualityTier: PAIR_DEFAULTS.qualityTier,
168
+ batchSize,
169
+ maxRetries: langConfig.maxRetries ?? PAIR_DEFAULTS.maxRetries,
170
+ register,
171
+ // Preset key name for consumers that need to look up preset-specific
172
+ // metadata (e.g., DeepL formality mapping). null means custom text.
173
+ registerPreset: langConfig.registerPreset || null,
174
+ name: langConfig.name || registerInfo.name || code,
175
+ // textDirection is the projected CLDR fact; `dir` was the old field name.
176
+ dir: (card?.textDirection === 'right-to-left' ? 'rtl' : null)
177
+ || card?.dir || registerInfo.dir || 'ltr',
178
+ scripts: card?.scriptConverter || registerInfo.scripts || null,
179
+ script: langConfig.script || null,
180
+ scriptFallback: langConfig.scriptFallback || null,
181
+ // Pair-level API endpoint for the bare "api" method (no plugin manifest).
182
+ // APIMethod falls back to pairConfig.endpoint — dropping it during
183
+ // normalization made { method: "api", endpoint: … } configs unusable.
184
+ endpoint: langConfig.endpoint || null,
185
+ // Structured formality info for method-specific behavior (e.g., DeepL)
186
+ formalitySystem: card?.formality?.system || null,
187
+ // Language-specific gender guidance for LLM prompts (e.g., écriture inclusive for French)
188
+ genderGuidance: card?.gender?.inclusiveGuidance || null,
189
+ // Global prompt context from config (e.g., "This is a developer tool README")
190
+ promptContext,
191
+ // Temperature: per-language → global config → null (method picks its own default)
192
+ temperature,
193
+ // Coaching: coaching file path and resolved prompt text
194
+ coachingFile,
195
+ coachingPrompt,
196
+ // Markdown body translation granularity ('block' | 'page') — consumed
197
+ // by docusaurus-sync.js Phase 2 (validated there, fail-loud).
198
+ contentSegmentation,
199
+ _defaults,
200
+ });
201
+ }
202
+
203
+ // Step 2: Apply overrides from `pairs` object (advanced mode)
204
+ // These can override simple-mode pairs or add entirely new ones
205
+ if (config.pairs && typeof config.pairs === 'object') {
206
+ for (const [rawPairKey, pairOverride] of Object.entries(config.pairs)) {
207
+ const { source: rawSource, target: rawTarget } = parsePairKey(rawPairKey);
208
+ if (!rawSource || !rawTarget) {
209
+ console.error(`[ERR] Invalid pair key "${rawPairKey}" — expected format "source:target" (e.g., "en:fr")`);
210
+ continue;
211
+ }
212
+
213
+ // Preserve the user's raw locale code as `target` — this determines
214
+ // file paths and must match the user's framework (e.g., Docusaurus
215
+ // expects `i18n/fil/`, not `i18n/tl/`).
216
+ //
217
+ // BUG FIX: Previously, resolveCode() replaced the target entirely
218
+ // (fil→tl), causing translations to be written to directories that
219
+ // the user's framework couldn't find.
220
+ //
221
+ // We don't need a separate canonical code because getLanguageCard()
222
+ // and DEFAULT_REGISTERS already resolve aliases internally — passing
223
+ // 'fil' to getLanguageCard() correctly returns the Tagalog card.
224
+ //
225
+ // IMPORTANT: Use rawSource directly, not resolveCode(rawSource).
226
+ // Step 1 builds keys from the raw inputLocale (e.g., 'en'). If we
227
+ // resolve source here (e.g., 'en' → 'eng'), the key won't match
228
+ // and the override won't apply to the Step 1 pair.
229
+ const source = rawSource;
230
+ const target = rawTarget;
231
+ const pairKey = buildPairKey(source, target);
232
+ const card = getLanguageCard(target);
233
+ const registerInfo = DEFAULT_REGISTERS[target] || {};
234
+ const existing = pairs.get(pairKey) || {};
235
+ const existingDefaults = existing._defaults || new Set();
236
+
237
+ // _defaults: a field is "defaulted" if NEITHER the pairOverride NOR
238
+ // the existing pair set it explicitly. If pairOverride sets a field,
239
+ // it clears the default flag; if it falls through to existing, it
240
+ // inherits that pair's default tracking.
241
+ const _defaults = new Set();
242
+ const resolvedMethod = pairOverride.method || existing.method || defaultMethod;
243
+ const model = resolveModelForPair(
244
+ pairOverride.model || existing.model,
245
+ resolvedMethod,
246
+ defaultModel
247
+ );
248
+ if (!pairOverride.model && existingDefaults.has('model')) _defaults.add('model');
249
+ if (!pairOverride.model && !existing.model) _defaults.add('model');
250
+ const batchSize = pairOverride.batchSize || existing.batchSize || defaultBatchSize;
251
+ if (!pairOverride.batchSize && existingDefaults.has('batchSize')) _defaults.add('batchSize');
252
+ if (!pairOverride.batchSize && !existing.batchSize) _defaults.add('batchSize');
253
+ const register = pairOverride.register || existing.register || registerInfo.register || DEFAULT_REGISTER_FALLBACK;
254
+ if (!pairOverride.register && existingDefaults.has('register')) _defaults.add('register');
255
+ if (!pairOverride.register && !existing.register) _defaults.add('register');
256
+
257
+ // Track temperature, coaching, and context fields for plugin override resolution
258
+ const temperature = pairOverride.temperature ?? existing.temperature ?? config.temperature ?? null;
259
+ if (pairOverride.temperature == null && existingDefaults.has('temperature')) _defaults.add('temperature');
260
+ if (pairOverride.temperature == null && existing.temperature == null) _defaults.add('temperature');
261
+ const coachingFile = pairOverride.coachingFile ?? existing.coachingFile ?? config.coachingFile ?? null;
262
+ if (pairOverride.coachingFile == null && existingDefaults.has('coachingFile')) _defaults.add('coachingFile');
263
+ if (pairOverride.coachingFile == null && !existing.coachingFile) _defaults.add('coachingFile');
264
+ const coachingPrompt = pairOverride.coachingPrompt ?? existing.coachingPrompt ?? config.coachingPrompt ?? null;
265
+ if (pairOverride.coachingPrompt == null && existingDefaults.has('coachingPrompt')) _defaults.add('coachingPrompt');
266
+ if (pairOverride.coachingPrompt == null && !existing.coachingPrompt) _defaults.add('coachingPrompt');
267
+ const promptContext = pairOverride.promptContext ?? existing.promptContext ?? config.promptContext ?? null;
268
+ if (pairOverride.promptContext == null && existingDefaults.has('promptContext')) _defaults.add('promptContext');
269
+ if (pairOverride.promptContext == null && !existing.promptContext) _defaults.add('promptContext');
270
+ const contentSegmentation = pairOverride.contentSegmentation ?? existing.contentSegmentation ?? config.contentSegmentation ?? null;
271
+ if (pairOverride.contentSegmentation == null && existingDefaults.has('contentSegmentation')) _defaults.add('contentSegmentation');
272
+ if (pairOverride.contentSegmentation == null && !existing.contentSegmentation) _defaults.add('contentSegmentation');
273
+
274
+ // Resolve the preset key for this pair. If the pair override specifies
275
+ // a register value, check if it's a known preset key (for DeepL, etc.).
276
+ // Otherwise inherit from the existing pair's preset tracking.
277
+ let registerPreset = existing.registerPreset || null;
278
+ if (pairOverride.register) {
279
+ // Check if the override is a preset key we should resolve
280
+ const isPresetKey = card?.registers?.[pairOverride.register] != null;
281
+ registerPreset = isPresetKey ? pairOverride.register : null;
282
+ }
283
+
284
+ pairs.set(pairKey, {
285
+ source,
286
+ target,
287
+ method: resolvedMethod,
288
+ model,
289
+ qualityTier: pairOverride.qualityTier || existing.qualityTier || PAIR_DEFAULTS.qualityTier,
290
+ batchSize,
291
+ maxRetries: pairOverride.maxRetries ?? existing.maxRetries ?? PAIR_DEFAULTS.maxRetries,
292
+ register,
293
+ registerPreset,
294
+ name: pairOverride.name || existing.name || registerInfo.name || target,
295
+ dir: (card?.textDirection === 'right-to-left' ? 'rtl' : null)
296
+ || card?.dir || existing.dir || registerInfo.dir || 'ltr',
297
+ scripts: card?.scriptConverter || existing.scripts || registerInfo.scripts || null,
298
+ script: pairOverride.script || existing.script || null,
299
+ scriptFallback: pairOverride.scriptFallback || existing.scriptFallback || null,
300
+ // Pair-level API endpoint for the bare "api" method (no plugin manifest).
301
+ // Preserved so APIMethod's documented pairConfig.endpoint fallback works.
302
+ endpoint: pairOverride.endpoint || existing.endpoint || null,
303
+ // Plugin reference — the plugin loader will merge its config into this pair
304
+ methodPlugin: pairOverride.methodPlugin || null,
305
+ formalitySystem: card?.formality?.system || existing.formalitySystem || null,
306
+ genderGuidance: card?.gender?.inclusiveGuidance || existing.genderGuidance || null,
307
+ // Global prompt context flows from config into every pair
308
+ promptContext,
309
+ temperature,
310
+ // Coaching: coaching file path and resolved prompt text
311
+ coachingFile,
312
+ coachingPrompt,
313
+ // Markdown body translation granularity ('block' | 'page')
314
+ contentSegmentation,
315
+ _defaults,
316
+ });
317
+ }
318
+ }
319
+
320
+ // Step 3: Script resolution — ONE decision per pair, attached here so every
321
+ // consumer (sync, serve, docusaurus, status, integrity, repair-script)
322
+ // reads the same answer, and an invalid `script:` or `scriptFallback` fails
323
+ // at pair-graph build time — before preflight, before any API spend, and in
324
+ // --dry runs too. The choice-required state (crk/sr-class dual real
325
+ // orthographies) is NOT a throw here: read-only lanes may proceed and
326
+ // display it; the translation lanes refuse in resolveRuntime.
327
+ for (const [pairKey, pc] of pairs) {
328
+ const card = getLanguageCard(pc.target);
329
+ try {
330
+ pc.scriptResolution = resolveTargetScript(pc.target, pc, card);
331
+ if (pc.scriptFallback != null) {
332
+ const registered = converterKeyForLocale(pc.target, card);
333
+ if (!registered) {
334
+ throw new Error(
335
+ `"scriptFallback" has no effect for ${pc.target} — no script converter is registered for this locale.`
336
+ );
337
+ }
338
+ validateScriptFallback(pc.scriptFallback, registered);
339
+ }
340
+ } catch (err) {
341
+ err.message = `${pairKey}: ${err.message}`;
342
+ throw err;
343
+ }
344
+ }
345
+
346
+ return pairs;
347
+ }
348
+
349
+ /**
350
+ * Parse a pair key into its source and target components.
351
+ *
352
+ * Supports three separator formats (checked in this order):
353
+ * 1. ":" — canonical format (e.g., "en:fr")
354
+ * 2. "→" — legacy Unicode arrow (e.g., "en→fr")
355
+ * 3. "->" — legacy ASCII arrow (e.g., "en->fr")
356
+ *
357
+ * WHY colon: Compact, ASCII-safe, and unambiguous — no locale code
358
+ * contains a colon, unlike underscores (pt_BR) or hyphens (zh-TW).
359
+ * Legacy arrow formats are accepted for backward compatibility with
360
+ * existing configs.
361
+ *
362
+ * @param {string} pairKey - Pair key to parse
363
+ * @returns {{ source: string|null, target: string|null }}
364
+ */
365
+ function parsePairKey(pairKey) {
366
+ // Canonical colon first, then legacy arrow formats
367
+ const separators = [':', '→', '->'];
368
+ for (const sep of separators) {
369
+ const idx = pairKey.indexOf(sep);
370
+ if (idx !== -1) {
371
+ const source = pairKey.slice(0, idx).trim();
372
+ const target = pairKey.slice(idx + sep.length).trim();
373
+ if (source && target) {
374
+ return { source, target };
375
+ }
376
+ }
377
+ }
378
+ return { source: null, target: null };
379
+ }
380
+
381
+ /**
382
+ * Build a pair key from source and target locale codes.
383
+ *
384
+ * Uses the canonical colon separator format.
385
+ *
386
+ * @param {string} source - Source locale code
387
+ * @param {string} target - Target locale code
388
+ * @returns {string} Pair key (e.g., "en:fr")
389
+ */
390
+ function buildPairKey(source, target) {
391
+ return `${source}:${target}`;
392
+ }
393
+
394
+ /**
395
+ * Filter a resolved pair graph down to the pair(s) named by `--pair`.
396
+ *
397
+ * Accepts a comma-separated list. Each entry is matched against the
398
+ * CONFIGURED pair graph — a value that doesn't name a configured pair is a
399
+ * hard error, never a silent no-op: translating every locale when the user
400
+ * asked for one is a money trap, and translating none is a silent failure.
401
+ *
402
+ * Accepted spellings per entry:
403
+ * - "en:fr" — canonical (also legacy "en→fr" / "en->fr")
404
+ * - "en>fr" — the leaderboard's separator, normalized here
405
+ * - "en-fr" — docs shorthand; resolved by trying every hyphen split
406
+ * against the configured pairs (so "en-pt-BR" works too),
407
+ * accepted only when exactly one configured pair matches
408
+ *
409
+ * @param {string} rawFlag - The raw --pair value (e.g. "en:fr,en:de")
410
+ * @param {Map<string, object>} pairs - Configured pair graph from resolvePairs
411
+ * @returns {Map<string, object>} New Map containing only the requested pairs
412
+ * @throws {Error} On a malformed entry or an entry naming no configured pair
413
+ */
414
+ function filterPairGraph(rawFlag, pairs) {
415
+ const configured = [...pairs.keys()].sort();
416
+ const configuredList = configured.length > 0 ? configured.join(', ') : '(none)';
417
+
418
+ const fail = (badValue, why) => {
419
+ const lines = [
420
+ '',
421
+ ' ┌─ UNKNOWN PAIR ──────────────────────────────────────────────────┐',
422
+ ' │ --pair names a pair this project does not configure. │',
423
+ ' └──────────────────────────────────────────────────────────────────┘',
424
+ '',
425
+ ` ✗ --pair ${badValue}: ${why}`,
426
+ '',
427
+ ` Configured pairs: ${configuredList}`,
428
+ '',
429
+ ' Next steps:',
430
+ ' 1. Pick a configured pair: champollion sync --pair ' + (configured[0] || 'en:fr'),
431
+ ' 2. Or add the pair to champollion.config.json ("languages" or "pairs").',
432
+ ];
433
+ throw new Error(lines.join('\n'));
434
+ };
435
+
436
+ const specs = String(rawFlag).split(',').map(s => s.trim()).filter(Boolean);
437
+ if (specs.length === 0) {
438
+ fail(JSON.stringify(rawFlag), 'empty value — expected "source:target" (e.g. en:fr)');
439
+ }
440
+
441
+ const selected = new Map();
442
+ for (const spec of specs) {
443
+ // Canonical + legacy separators first; ">" (the leaderboard separator)
444
+ // normalized to the canonical colon before parsing.
445
+ const { source, target } = parsePairKey(spec.replace('>', ':'));
446
+ let key = source && target ? buildPairKey(source, target) : null;
447
+
448
+ if (key && !pairs.has(key)) {
449
+ fail(spec, `"${key}" is not in the configured pair graph`);
450
+ }
451
+
452
+ if (!key) {
453
+ // Docs shorthand "en-fr": hyphens are ambiguous (pt-BR), so try every
454
+ // split and accept only an exact, unique match against configured pairs.
455
+ const candidates = [];
456
+ for (let i = spec.indexOf('-'); i !== -1; i = spec.indexOf('-', i + 1)) {
457
+ const candidate = buildPairKey(spec.slice(0, i).trim(), spec.slice(i + 1).trim());
458
+ if (pairs.has(candidate) && !candidates.includes(candidate)) candidates.push(candidate);
459
+ }
460
+ if (candidates.length === 1) {
461
+ key = candidates[0];
462
+ } else if (candidates.length > 1) {
463
+ fail(spec, `ambiguous — matches ${candidates.join(' and ')}; use the colon form`);
464
+ } else {
465
+ fail(spec, 'does not match any configured pair — expected "source:target" (e.g. en:fr)');
466
+ }
467
+ }
468
+
469
+ selected.set(key, pairs.get(key));
470
+ }
471
+
472
+ return selected;
473
+ }
474
+
475
+ /**
476
+ * Get all target locale codes from a pair graph.
477
+ *
478
+ * @param {Map<string, object>} pairs - Pair graph
479
+ * @returns {string[]} Unique target locale codes
480
+ */
481
+ function getTargetLocales(pairs) {
482
+ const targets = new Set();
483
+ for (const pair of pairs.values()) {
484
+ targets.add(pair.target);
485
+ }
486
+ return [...targets];
487
+ }
488
+
489
+ /**
490
+ * Get the pair config for a specific target locale.
491
+ * Searches for any pair where the target matches the given code.
492
+ *
493
+ * @param {Map<string, object>} pairs - Pair graph
494
+ * @param {string} targetCode - Target locale code
495
+ * @returns {object|null} Pair config, or null if not found
496
+ */
497
+ function getPairForTarget(pairs, targetCode) {
498
+ for (const pair of pairs.values()) {
499
+ if (pair.target === targetCode) {
500
+ return pair;
501
+ }
502
+ }
503
+ return null;
504
+ }
505
+
506
+ /**
507
+ * Estimate the cost of translating a set of keys for a given pair.
508
+ *
509
+ * Delegates to the pair's configured method class. Each method knows
510
+ * its own pricing model (or honestly returns null when it can't know).
511
+ *
512
+ * @param {number} keyCount - Number of keys to translate
513
+ * @param {object} pairConfig - Pair config with method and model
514
+ * @returns {{ estimatedCost: number|null, currency: string, source: string, note: string }}
515
+ */
516
+ async function estimateCost(keyCount, pairConfig) {
517
+ const methodName = pairConfig.method || 'llm';
518
+
519
+ // Delegate to the method's own cost estimate.
520
+ // WHY: We can't hardcode pricing here because each method has its own
521
+ // pricing model (or lack thereof). Google has documented rates ($20/1M chars),
522
+ // LLM varies by model, API is server-determined.
523
+ try {
524
+ const method = getMethod(methodName);
525
+ return await method.estimateCost(keyCount, pairConfig);
526
+ } catch {
527
+ // If method resolution fails, return an honest "unknown"
528
+ return {
529
+ estimatedCost: null,
530
+ currency: 'USD',
531
+ source: 'unknown',
532
+ note: `Could not resolve method "${methodName}" for cost estimation.`,
533
+ };
534
+ }
535
+ }
536
+
537
+ export {
538
+ resolvePairs,
539
+ parsePairKey,
540
+ buildPairKey,
541
+ filterPairGraph,
542
+ getTargetLocales,
543
+ getPairForTarget,
544
+ estimateCost,
545
+ QUALITY_TIERS,
546
+ PAIR_DEFAULTS,
547
+ };