champollion 0.3.4 → 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 (132) 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 +83 -36
  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 +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 +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 +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 +194 -35
  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 +6 -1
  100. package/lib/seal.mjs +4 -3
  101. package/lib/sealed-qualifier.mjs +1 -1
  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 +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/method-registry.json +2 -0
  126. package/shared/metric-registry.json +96 -18
  127. package/shared/schemas/champollion-plugin.schema.json +4 -0
  128. package/shared/schemas/corpora-card.schema.json +8 -2
  129. package/shared/schemas/method-index-record.schema.json +67 -0
  130. package/shared/schemas/method-registry.schema.json +4 -0
  131. package/shared/schemas/metric-registry.schema.json +55 -1
  132. package/shared/docent/corpus.json +0 -11739
@@ -23,7 +23,9 @@
23
23
  * source_locale: "en",
24
24
  * target_locale: "crk",
25
25
  * method: "crk-coached-v1",
26
- * keys: { "hero.title": "Welcome", ... }
26
+ * keys: { "hero.title": "Welcome", ... },
27
+ * instructions: { "hero.title": "…" } // only when the endpoint declares
28
+ * // "acceptsInstructions": true
27
29
  * }
28
30
  *
29
31
  * RESPONSE FORMAT (what the API returns):
@@ -32,6 +34,12 @@
32
34
  * meta: { model, cost_usd, quality_tier, ... }
33
35
  * }
34
36
  *
37
+ * CONTENT (Markdown bodies) uses the SAME contract: the body pieces go out as
38
+ * keys ("segment.<N>" per block in block mode, "body" in page mode) with the
39
+ * optional field text_format: "markdown" so a server can tell document text
40
+ * from app strings (servers that do not know the field ignore it). Only the
41
+ * Markdown leaves — never the LLM instruction prompt the CLI wraps it in.
42
+ *
35
43
  * COST PROFILE: Varies by method — determined server-side
36
44
  * QUALITY TIER: Varies by method — read from plugin manifest
37
45
  */
@@ -44,10 +52,19 @@ import {
44
52
  import { pMap } from '../concurrent.js';
45
53
  import { DEFAULT_METHOD_CONCURRENCY } from '../config.js';
46
54
  import { output } from '../output.js';
55
+ import { SEGMENT_MARKER_PREFIX, SEGMENT_MARKER_SUFFIX } from '../segment.js';
56
+ import { parseContentPrompt } from './content-separator.js';
57
+ import { getEnvOrFileVar } from '../api-key.js';
58
+ import { isLoopbackEndpoint, localMachineCost } from './http-utils.js';
59
+ import { captureRequest, isCapturing, PREVIEW_KEY } from './request-capture.js';
47
60
 
48
61
  // Maximum keys per API request (server-side limit)
49
62
  const MAX_KEYS_PER_REQUEST = 100;
50
63
 
64
+ // The block/page parser lives in content-separator.js (shared with the
65
+ // raw-text engines); api sends the blocks as keys.
66
+ const contentRequest = parseContentPrompt;
67
+
51
68
  class APIMethod extends TranslationMethod {
52
69
  constructor(options = {}) {
53
70
  super('api', options);
@@ -58,6 +75,14 @@ class APIMethod extends TranslationMethod {
58
75
  this.methodVersion = options.methodVersion || null;
59
76
  this.qualityTier = options.qualityTier || 'standard';
60
77
  this.pluginProvenance = options.provenance || null;
78
+ // Per-key instructions (plural forms, a quality-gate retry's feedback)
79
+ // reach the endpoint only when it declares it follows them
80
+ // ("acceptsInstructions": true on the pair or in the plugin manifest) —
81
+ // they travel as an "instructions" object beside "keys". false (a trained
82
+ // NMT model, e.g. nmt-forge serve) and unknown send the text alone.
83
+ this.declaredInstructions = typeof options.acceptsInstructions === 'boolean' ? options.acceptsInstructions : null;
84
+ this.acceptsKeyInstructions = this.declaredInstructions === true;
85
+ this.supportsRequestPreview = true; // its transport reports to request-capture.js
61
86
  }
62
87
 
63
88
  /**
@@ -70,12 +95,13 @@ class APIMethod extends TranslationMethod {
70
95
  * @returns {object|null} Map of key → translated value, or null
71
96
  */
72
97
  async translate(keys, sourceFlat, pairConfig, options) {
73
- const apiKey = options.apiKey
74
- || process.env.CHAMPOLLION_API_KEY;
98
+ const endpointForKey = this.endpoint || pairConfig.endpoint || options.endpoint;
99
+ // A request preview needs no key: it is shown, never sent.
100
+ const apiKey = resolveApiMethodKey(pairConfig, endpointForKey, options.cwd) || (isCapturing() ? PREVIEW_KEY : null);
75
101
 
76
102
  if (!apiKey) {
77
- output.error('API method: No API key found.');
78
- output.error('Set CHAMPOLLION_API_KEY in your environment.');
103
+ output.error(`API method: no key for ${endpointForKey || 'this endpoint'}.`);
104
+ output.error('Set CHAMPOLLION_API_KEY, or "apiKey": "${YOUR_VAR}" on the pair.');
79
105
  return null;
80
106
  }
81
107
 
@@ -93,6 +119,9 @@ class APIMethod extends TranslationMethod {
93
119
  const sourceLocale = pairConfig.source || 'en';
94
120
  const targetLocale = pairConfig.target;
95
121
  const method = this.methodName || pairConfig.methodPlugin || 'default';
122
+ // 'markdown' for content-body pieces (translateContent below); unset for
123
+ // app strings, so key-value requests are unchanged.
124
+ const textFormat = options.textFormat || null;
96
125
 
97
126
  const allTranslated = {};
98
127
 
@@ -113,6 +142,17 @@ class APIMethod extends TranslationMethod {
113
142
 
114
143
  if (Object.keys(keysPayload).length === 0) return;
115
144
 
145
+ // Declared instruction-following endpoints get the per-key notes.
146
+ let instructions = null;
147
+ if (this.acceptsKeyInstructions && options.descriptions) {
148
+ for (const key of Object.keys(keysPayload)) {
149
+ if (typeof options.descriptions[key] === 'string' && options.descriptions[key]) {
150
+ instructions = instructions || {};
151
+ instructions[key] = options.descriptions[key];
152
+ }
153
+ }
154
+ }
155
+
116
156
  const result = await this._translateBatchWithRetry(
117
157
  keysPayload,
118
158
  sourceLocale,
@@ -121,6 +161,8 @@ class APIMethod extends TranslationMethod {
121
161
  endpoint,
122
162
  apiKey,
123
163
  idx + 1,
164
+ textFormat,
165
+ instructions,
124
166
  );
125
167
 
126
168
  if (result) {
@@ -132,14 +174,39 @@ class APIMethod extends TranslationMethod {
132
174
  }
133
175
 
134
176
  /**
135
- * Freeform content translation via the API.
177
+ * Content (Markdown body) translation via the API.
136
178
  *
137
- * The API method could support this in the future, but for now
138
- * we only support key-value translation. Return null so the
139
- * orchestrator falls back to the local LLM method.
179
+ * The content lane passes the prompt it built for an LLM (see
180
+ * contentRequest above). The Markdown inside it is sent over the same
181
+ * key → string contract with text_format "markdown", and the answer is
182
+ * rebuilt in the shape the content lane parses: one ⟦SEG_N⟧ marker per
183
+ * block (a block the server did not return is LEFT OUT, so the lane's
184
+ * self-repair ladder retries it and then marks it '[EN]' — visible, never
185
+ * silent), or the translated body in page mode.
186
+ *
187
+ * (This used to return null unconditionally "so the orchestrator falls
188
+ * back to the local LLM method" — no such fallback exists: every content
189
+ * file failed with "block-batch translation returned no results" and no
190
+ * reason. Synthetic users, 2026-10.)
140
191
  */
141
- async translateContent(_prompt, _pairConfig, _options) {
142
- return null;
192
+ async translateContent(prompt, pairConfig, options = {}) {
193
+ const request = contentRequest(prompt);
194
+ if (!request) {
195
+ output.error('API method: this content prompt carries neither ⟦SEG_N⟧ blocks nor a "---" body separator — nothing to send.');
196
+ return null;
197
+ }
198
+ const names = Object.keys(request.keys).filter((k) => request.keys[k].trim());
199
+ if (names.length === 0) return null;
200
+ const result = await this.translate(names, request.keys, pairConfig,
201
+ { ...options, textFormat: 'markdown' });
202
+ if (!result) return null;
203
+ if (request.mode === 'page') {
204
+ return typeof result.body === 'string' ? result.body : null;
205
+ }
206
+ const parts = request.ids
207
+ .filter((id) => typeof result[`segment.${id}`] === 'string')
208
+ .map((id) => `${SEGMENT_MARKER_PREFIX}${id}${SEGMENT_MARKER_SUFFIX}\n${result[`segment.${id}`]}`);
209
+ return parts.length > 0 ? parts.join('\n\n') : null;
143
210
  }
144
211
 
145
212
  /**
@@ -152,10 +219,26 @@ class APIMethod extends TranslationMethod {
152
219
  * @param {string} endpoint - API endpoint URL
153
220
  * @param {string} apiKey - Remote API key
154
221
  * @param {number} batchNum - Batch number for logging
222
+ * @param {string|null} [textFormat] - 'markdown' for content-body pieces
155
223
  * @returns {object|null} Map of key → translated value
156
224
  */
157
- async _translateBatchWithRetry(keysPayload, sourceLocale, targetLocale, method, endpoint, apiKey, batchNum) {
225
+ async _translateBatchWithRetry(keysPayload, sourceLocale, targetLocale, method, endpoint, apiKey, batchNum, textFormat = null, instructions = null) {
158
226
  const keyCount = Object.keys(keysPayload).length;
227
+ const headers = {
228
+ 'Authorization': `Bearer ${apiKey}`,
229
+ 'Content-Type': 'application/json',
230
+ 'User-Agent': 'champollion',
231
+ };
232
+ const body = {
233
+ source_locale: sourceLocale,
234
+ target_locale: targetLocale,
235
+ method,
236
+ keys: keysPayload,
237
+ ...(textFormat ? { text_format: textFormat } : {}),
238
+ ...(instructions ? { instructions } : {}),
239
+ };
240
+ // `sync --dry --show-prompt`: hand over the exact request, send nothing.
241
+ if (captureRequest({ url: endpoint, headers, body })) return null;
159
242
 
160
243
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
161
244
  try {
@@ -164,17 +247,8 @@ class APIMethod extends TranslationMethod {
164
247
 
165
248
  const response = await fetch(endpoint, {
166
249
  method: 'POST',
167
- headers: {
168
- 'Authorization': `Bearer ${apiKey}`,
169
- 'Content-Type': 'application/json',
170
- 'User-Agent': 'champollion',
171
- },
172
- body: JSON.stringify({
173
- source_locale: sourceLocale,
174
- target_locale: targetLocale,
175
- method,
176
- keys: keysPayload,
177
- }),
250
+ headers,
251
+ body: JSON.stringify(body),
178
252
  signal: controller.signal,
179
253
  });
180
254
 
@@ -286,7 +360,12 @@ class APIMethod extends TranslationMethod {
286
360
  * Cost estimation — API method pricing is determined by the remote server.
287
361
  * We cannot estimate cost without querying the endpoint.
288
362
  */
289
- estimateCost(keyCount) {
363
+ estimateCost(keyCount, pairConfig = {}) {
364
+ // An endpoint on this machine (nmt-forge serve, champollion serve on
365
+ // 127.0.0.1) has no API bill — say $0, and why. Anything else is priced
366
+ // by its server: unknown here, never $0.
367
+ const endpoint = pairConfig?.endpoint || this.endpoint;
368
+ if (isLoopbackEndpoint(endpoint)) return localMachineCost(endpoint);
290
369
  return {
291
370
  estimatedCost: null,
292
371
  currency: 'USD',
@@ -313,4 +392,38 @@ class APIMethod extends TranslationMethod {
313
392
  }
314
393
  }
315
394
 
316
- export { APIMethod };
395
+
396
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
397
+
398
+ /**
399
+ * The bearer token for an `api` endpoint — and ONLY a token meant for it.
400
+ *
401
+ * 1. the pair's own "apiKey": "${VAR}" (read from the environment or
402
+ * .env.local/.env), or a literal value;
403
+ * 2. CHAMPOLLION_API_KEY;
404
+ * 3. for a loopback endpoint (nmt-forge serve, champollion serve) with no
405
+ * token configured, a placeholder: those servers need none.
406
+ *
407
+ * It used to fall back to `options.apiKey` — the generic provider key sync
408
+ * resolves for the llm method — so a user's OPENROUTER_API_KEY was sent as
409
+ * the Bearer token to whatever endpoint a pair named (found 2026-10-03). The
410
+ * documented per-pair "apiKey" was never read at all.
411
+ *
412
+ * @returns {string|null}
413
+ */
414
+ function resolveApiMethodKey(pairConfig, endpoint, cwd) {
415
+ const own = pairConfig && typeof pairConfig.apiKey === 'string' ? pairConfig.apiKey.trim() : '';
416
+ if (own) {
417
+ const ref = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(own);
418
+ if (!ref) return own;
419
+ const v = getEnvOrFileVar(ref[1], cwd);
420
+ if (v) return v;
421
+ }
422
+ const shared = getEnvOrFileVar('CHAMPOLLION_API_KEY', cwd);
423
+ if (shared) return shared;
424
+ let host = '';
425
+ try { host = new URL(endpoint).hostname; } catch { /* no endpoint */ }
426
+ return LOOPBACK.has(host) ? 'local-no-token' : null;
427
+ }
428
+
429
+ export { APIMethod, resolveApiMethodKey };
@@ -33,6 +33,23 @@ class TranslationMethod {
33
33
  constructor(name, options = {}) {
34
34
  this.name = name;
35
35
  this.options = options;
36
+ // True for machine-translation engines that translate raw text and do
37
+ // not understand an LLM instruction prompt (Google, DeepL, Microsoft,
38
+ // LibreTranslate, Apertium, Tilde, Translated). translateRawContent sends
39
+ // them one Markdown block at a time instead of the block-batch prompt.
40
+ this.translatesRawText = false;
41
+ // True for methods that read per-key instructions (the "UI context"
42
+ // lines of the prompt): which plural form a generated i18next key needs,
43
+ // which CLDR categories an ICU plural must cover, a gettext msgctxt, a
44
+ // quality-gate retry's feedback. Only LLM methods do; a machine
45
+ // translation engine translates the text and nothing else, so sync must
46
+ // not claim it was asked for, e.g., French's "many" form.
47
+ this.acceptsKeyInstructions = false;
48
+ // True for methods whose transport hands its finished request to
49
+ // lib/methods/request-capture.js instead of sending it while a capture
50
+ // is on (`sync --dry --show-prompt`). Only those are previewed: running
51
+ // any other method's translate() under a preview could reach its API.
52
+ this.supportsRequestPreview = false;
36
53
  }
37
54
 
38
55
  /**
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The project's coaching file (.champollion/coaching/<locale>.json) and its
3
+ * glossary — read in ONE place for every method that uses them.
4
+ *
5
+ * THE FILE:
6
+ * {
7
+ * "grammar_rules": ["French adjectives agree in gender and number…"],
8
+ * "dictionary": { "dashboard": "tableau de bord" },
9
+ * "style_notes": "Prefer active voice."
10
+ * }
11
+ *
12
+ * WHO READS WHAT:
13
+ * - "dictionary" is the project's GLOSSARY. Every LLM method is told the
14
+ * glossary terms a batch contains (lib/methods/llm.js buildUserMessage),
15
+ * DeepL sends it as a glossary, and sync checks every method's output
16
+ * against it.
17
+ * - "grammar_rules" and "style_notes" are COACHING, read by the
18
+ * llm-coached method only (on any provider). The plain LLM methods
19
+ * (llm, openai, anthropic, gemini, local) build the same prompt as each
20
+ * other, so a pair gets the same instructions whichever of them runs it.
21
+ *
22
+ * WHY A MODULE OF ITS OWN: lib/methods/llm.js needs the glossary helpers and
23
+ * lib/methods/llm-coached.js imports llm.js — keeping these here avoids an
24
+ * import cycle. llm-coached.js re-exports them (the public names are unchanged).
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { output } from '../output.js';
30
+
31
+ /**
32
+ * Default coaching data directory, relative to project root.
33
+ */
34
+ const DEFAULT_COACHING_DIR = '.champollion/coaching';
35
+
36
+ /**
37
+ * Load coaching data for a locale from a JSON file, with caching.
38
+ *
39
+ * @param {string} coachingDir - Path to coaching data directory
40
+ * @param {string} locale - Target locale code (e.g., 'fr', 'crk')
41
+ * @param {Map} cache - Cache map to store loaded data (avoids re-reading files)
42
+ * @returns {object|null} Coaching data { grammar_rules, dictionary, style_notes }, or null
43
+ */
44
+ function loadCoachingData(coachingDir, locale, cache) {
45
+ if (!locale) return null;
46
+
47
+ const cacheKey = `${coachingDir}:${locale}`;
48
+ if (cache.has(cacheKey)) {
49
+ return cache.get(cacheKey);
50
+ }
51
+
52
+ const filePath = path.join(coachingDir, `${locale}.json`);
53
+
54
+ if (!fs.existsSync(filePath)) {
55
+ cache.set(cacheKey, null);
56
+ return null;
57
+ }
58
+
59
+ try {
60
+ const raw = fs.readFileSync(filePath, 'utf-8');
61
+ const data = JSON.parse(raw);
62
+
63
+ // Validate required structure — normalize missing fields to safe defaults
64
+ const coaching = {
65
+ grammar_rules: Array.isArray(data.grammar_rules) ? data.grammar_rules : [],
66
+ dictionary: (data.dictionary && typeof data.dictionary === 'object') ? data.dictionary : {},
67
+ style_notes: typeof data.style_notes === 'string' ? data.style_notes : '',
68
+ };
69
+
70
+ cache.set(cacheKey, coaching);
71
+ return coaching;
72
+ } catch (err) {
73
+ output.warn(`Failed to load coaching data: ${filePath}`);
74
+ output.warn(err.message);
75
+ cache.set(cacheKey, null);
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The project glossary for a pair: the term → translation map the pair is
82
+ * told about and checked against. sync loads it once for every pair
83
+ * (pairConfig.glossary, null when there is none); a caller that did not (a
84
+ * library user, `serve`) gets it from the coaching file in `cwd`.
85
+ *
86
+ * @param {object} pairConfig
87
+ * @param {{ cwd?: string }} [options]
88
+ * @param {Map} [cache] - loadCoachingData cache
89
+ * @returns {Object<string, string>|null}
90
+ */
91
+ function projectGlossary(pairConfig, options = {}, cache = new Map()) {
92
+ // Set by sync (null = the project has none for this locale).
93
+ if (pairConfig && Object.prototype.hasOwnProperty.call(pairConfig, 'glossary')) {
94
+ const g = pairConfig.glossary;
95
+ return g && typeof g === 'object' && Object.keys(g).length > 0 ? g : null;
96
+ }
97
+ const target = pairConfig && (pairConfig.target || pairConfig.locale);
98
+ if (!target) return null;
99
+ const data = loadCoachingData(path.join(options.cwd || process.cwd(), DEFAULT_COACHING_DIR), target, cache);
100
+ return data && Object.keys(data.dictionary).length > 0 ? data.dictionary : null;
101
+ }
102
+
103
+ /**
104
+ * Scan source values for dictionary term matches.
105
+ *
106
+ * Case-insensitive substring matching of each term against the batch's
107
+ * source values (the dictionary is usually small).
108
+ *
109
+ * @param {object} toTranslate - Key-value map to scan
110
+ * @param {object} dictionary - Term → translation map
111
+ * @returns {Array<{ term: string, translation: string }>} Matched hints
112
+ */
113
+ function findDictionaryMatches(toTranslate, dictionary) {
114
+ if (!dictionary || Object.keys(dictionary).length === 0) return [];
115
+
116
+ const matches = [];
117
+ const seen = new Set();
118
+ const values = Object.values(toTranslate).join(' ').toLowerCase();
119
+
120
+ for (const [term, translation] of Object.entries(dictionary)) {
121
+ if (seen.has(term)) continue;
122
+ if (values.includes(term.toLowerCase())) {
123
+ matches.push({ term, translation });
124
+ seen.add(term);
125
+ }
126
+ }
127
+
128
+ return matches;
129
+ }
130
+
131
+ /**
132
+ * The REQUIRED TERMINOLOGY block for a batch: the glossary terms its source
133
+ * values contain ('' when none).
134
+ *
135
+ * @param {object} toTranslate - Key-value map of the batch
136
+ * @param {Object<string, string>|null} glossary
137
+ * @returns {string}
138
+ */
139
+ function terminologyBlock(toTranslate, glossary) {
140
+ const hints = findDictionaryMatches(toTranslate, glossary);
141
+ if (hints.length === 0) return '';
142
+ return 'REQUIRED TERMINOLOGY (use these exact translations):\n'
143
+ + hints.map(h => ` • "${h.term}" → "${h.translation}"`).join('\n')
144
+ + '\n\n';
145
+ }
146
+
147
+ export {
148
+ DEFAULT_COACHING_DIR,
149
+ loadCoachingData,
150
+ projectGlossary,
151
+ findDictionaryMatches,
152
+ terminologyBlock,
153
+ };
@@ -20,6 +20,49 @@
20
20
  */
21
21
  export const CONTENT_SEPARATOR = '\n---\n';
22
22
 
23
+ import { SEGMENT_MARKER_PREFIX, SEGMENT_MARKER_SUFFIX } from '../segment.js';
24
+
25
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26
+
27
+ // One block-batch segment marker on its own line (segment.js
28
+ // buildBlockBatchPrompt writes "⟦SEG_N⟧\n<block>" separated by blank lines).
29
+ const SEGMENT_LINE = new RegExp(
30
+ `^${escapeRegExp(SEGMENT_MARKER_PREFIX)}(\\d+)${escapeRegExp(SEGMENT_MARKER_SUFFIX)}[ \\t]*$`,
31
+ 'gm',
32
+ );
33
+
34
+ /**
35
+ * The Markdown a content prompt carries, without the LLM instructions.
36
+ *
37
+ * The content lane builds prompts for an LLM: the block-batch prompt
38
+ * (instructions, then one ⟦SEG_N⟧ marker line per block) or the page prompt
39
+ * (instructions, "---", the body). A method that is not an LLM must send
40
+ * only the Markdown — never the instructions.
41
+ *
42
+ * @param {string} prompt
43
+ * @returns {{ mode: 'segments', keys: object, ids: string[] } | { mode: 'page', keys: object } | null}
44
+ */
45
+ export function parseContentPrompt(prompt) {
46
+ const text = String(prompt);
47
+ const marks = [...text.matchAll(SEGMENT_LINE)];
48
+ if (marks.length > 0) {
49
+ const keys = {};
50
+ marks.forEach((m, i) => {
51
+ const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
52
+ keys[`segment.${m[1]}`] = text
53
+ .slice(m.index + m[0].length, end)
54
+ .replace(/^\r?\n/, '')
55
+ .replace(/[\r\n]+\s*$/, '');
56
+ });
57
+ return { mode: 'segments', keys, ids: marks.map((m) => m[1]) };
58
+ }
59
+ const sep = text.indexOf(CONTENT_SEPARATOR);
60
+ if (sep !== -1) {
61
+ return { mode: 'page', keys: { body: text.slice(sep + CONTENT_SEPARATOR.length) } };
62
+ }
63
+ return null;
64
+ }
65
+
23
66
  /**
24
67
  * Extract the Markdown body from a content prompt.
25
68
  *
@@ -19,6 +19,7 @@ const DEEPL_REQUEST_TIMEOUT_MS = 15000;
19
19
  class DeepLMethod extends TranslationMethod {
20
20
  constructor(options = {}) {
21
21
  super('deepl', options);
22
+ this.translatesRawText = true; // see base.js
22
23
  this._coachingCache = new Map();
23
24
  }
24
25
 
@@ -31,7 +32,6 @@ class DeepLMethod extends TranslationMethod {
31
32
  */
32
33
  _resolveApiKey(options) {
33
34
  return options.deeplApiKey
34
- || getEnvOrFileVar('DEEPL_API_KEY')
35
35
  || getEnvOrFileVar('DEEPL_API_KEY', options.cwd);
36
36
  }
37
37