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
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Bounded concurrency utilities — zero-dependency.
3
+ *
4
+ * WHY: The content sync pipeline makes hundreds of sequential API calls
5
+ * (32 files × 12 locales = 384 translations). Parallelizing the inner
6
+ * locale loop with a concurrency cap of 4-6 gives ~5-6x speedup without
7
+ * overwhelming the API's rate limits.
8
+ *
9
+ * This module provides a `pMap` function similar to the popular `p-map`
10
+ * npm package, but with zero dependencies — consistent with champollion's
11
+ * zero-dependency policy.
12
+ *
13
+ * WORKER POOL PATTERN:
14
+ * Instead of launching all tasks and throttling with a semaphore,
15
+ * we spawn exactly `concurrency` worker coroutines that pull from
16
+ * a shared index. This naturally limits in-flight work and avoids
17
+ * the thundering-herd problem on completion.
18
+ *
19
+ * USAGE:
20
+ * import { pMap } from './concurrent.js';
21
+ *
22
+ * // Translate all locales for a file, max 6 at a time
23
+ * await pMap(pairEntries, async ([, pairConfig]) => {
24
+ * await translateFile(sourcePath, pairConfig);
25
+ * }, { concurrency: 6 });
26
+ */
27
+
28
+ /**
29
+ * Map over an iterable with bounded concurrency.
30
+ *
31
+ * Executes `fn` for each item, but never more than `concurrency`
32
+ * invocations run simultaneously. Results are returned in the same
33
+ * order as the input items (not completion order).
34
+ *
35
+ * Errors in individual items are collected but do NOT abort other
36
+ * in-flight work. After all items complete, the first error (if any)
37
+ * is thrown. For content sync, the caller wraps each item in its own
38
+ * try/catch, so errors are handled per-item rather than here.
39
+ *
40
+ * @param {Array} items - Items to iterate over
41
+ * @param {Function} fn - Async function (item, index) => result
42
+ * @param {object} [options]
43
+ * @param {number} [options.concurrency=6] - Max simultaneous executions
44
+ * @returns {Promise<Array>} Results in input order
45
+ */
46
+ async function pMap(items, fn, { concurrency = 6 } = {}) {
47
+ // Validate concurrency: a value < 1 (0, negative, NaN) would spawn zero
48
+ // workers via Math.min below, silently process nothing, and return a
49
+ // hole-filled array — the caller would "succeed" having written nothing.
50
+ // Reject loudly instead.
51
+ if (!Number.isFinite(concurrency) || concurrency < 1) {
52
+ throw new Error(`pMap: concurrency must be a finite integer >= 1 (got ${concurrency})`);
53
+ }
54
+
55
+ const results = new Array(items.length);
56
+ let nextIndex = 0;
57
+ // Collect per-item errors WITHOUT aborting siblings — this honors the
58
+ // docstring's per-item-isolation contract. An exception in one item used to
59
+ // reject the whole Promise.all and discard every already-computed sibling
60
+ // result (e.g. already-paid translations for other locales). We now finish
61
+ // every item and rethrow the first error afterward.
62
+ let firstError = null;
63
+
64
+ async function worker() {
65
+ while (nextIndex < items.length) {
66
+ // Claim the next index atomically (single-threaded JS — no race)
67
+ const i = nextIndex++;
68
+ try {
69
+ results[i] = await fn(items[i], i);
70
+ } catch (err) {
71
+ if (firstError === null) firstError = err;
72
+ }
73
+ }
74
+ }
75
+
76
+ // Spawn exactly `concurrency` workers (or fewer if items < concurrency)
77
+ const workerCount = Math.min(concurrency, items.length);
78
+ await Promise.all(
79
+ Array.from({ length: workerCount }, () => worker())
80
+ );
81
+
82
+ if (firstError !== null) throw firstError;
83
+
84
+ return results;
85
+ }
86
+
87
+ export { pMap };
package/lib/config.js ADDED
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Config resolution — finds and merges configuration from multiple sources.
3
+ *
4
+ * Priority (highest to lowest):
5
+ * 1. CLI flags (--source, --dir, --model, --method, etc.)
6
+ * 2. Config file (champollion.config.json)
7
+ * 3. Sensible defaults
8
+ *
9
+ * WHY: The goal is zero-config for simple cases (just drop your locale
10
+ * files in a folder and go) while allowing full customization for
11
+ * complex setups with custom registers, models, and batch sizes.
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { DEFAULT_REGISTERS, getLanguageCard, getRegister, resolveCode } from './registers.js';
17
+ import { resolveModel } from './models.js';
18
+ import { validateNoTranslateConfig } from './no-translate.js';
19
+
20
+ const CONFIG_FILENAMES = ['champollion.config.json'];
21
+
22
+ // Canonical defaults — import these in any module that needs a fallback
23
+ // instead of hardcoding the string/number inline.
24
+ const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3.5-flash';
25
+ const DEFAULT_BATCH_SIZE = 80;
26
+ // Max parallel API calls for JSON key-value translation. 50 is kind to
27
+ // free/low-tier keys on a zero-config first run (200 would hammer 429s).
28
+ // Single source of truth — sync.js + docusaurus-sync.js + the --json-concurrency
29
+ // help text all reference this so the documented default can't drift.
30
+ const DEFAULT_JSON_CONCURRENCY = 50;
31
+ const DEFAULT_TEMPERATURE = 0.3;
32
+ const DEFAULT_COACHED_TEMPERATURE = 0.2;
33
+ const DEFAULT_MAX_RETRIES = 3; // Max cascade retries on batch parse failure (batch → half → individual)
34
+
35
+ /**
36
+ * Default concurrency for method-internal API call parallelism.
37
+ *
38
+ * Controls how many pMap workers run within a single pair's translate().
39
+ * This is SEPARATE from sync-level concurrency (jsonConcurrency /
40
+ * contentConcurrency in sync.js) which controls how many locale pairs
41
+ * translate in parallel.
42
+ *
43
+ * Lower values = kinder to rate limits. Higher values = faster batches.
44
+ * Configurable via `methodConcurrency` in champollion.config.json.
45
+ */
46
+ const DEFAULT_METHOD_CONCURRENCY = 4;
47
+
48
+ // Cost estimation heuristics — shared by all provider estimateCost() methods
49
+ // AND by the OpenRouter estimator (methods/openrouter-pricing.js). This is
50
+ // the ONE exported constant pair: the two used to disagree (60/10 here vs
51
+ // 200/30 in openrouter-pricing.js), so the same sync printed different
52
+ // estimates depending on which engine handled a pair.
53
+ //
54
+ // 200 in / 30 out is the defensible pair: real batch prompts carry the
55
+ // system message, register/style instructions, and the JSON envelope
56
+ // amortized across keys — measured payloads land near 200 input tokens per
57
+ // key, not 60. The cost preview also feeds the --max-cost fail-safe cap, so
58
+ // a 3x underestimate would let a capped run overspend; erring high is the
59
+ // safe direction for a pre-run gate.
60
+ // Character-based (API providers): ~25 chars per key average across UI strings.
61
+ const EST_INPUT_TOKENS_PER_KEY = 200;
62
+ const EST_OUTPUT_TOKENS_PER_KEY = 30;
63
+ const EST_CHARS_PER_KEY = 25;
64
+
65
+ const DEFAULTS = {
66
+ version: 3,
67
+ inputLocale: 'en',
68
+ baseUrl: '',
69
+ localesDir: './locales',
70
+ contentDir: null, // Hugo content directory (e.g. './content'). null = disabled.
71
+ // Markdown body translation granularity: 'block' (default — segment the
72
+ // body, TM-cache per block, one batched API call for the misses) or
73
+ // 'page' (single whole-body prompt, still TM-threaded). Overridable per
74
+ // pair. Validated in docusaurus-sync.js — anything else fails loud.
75
+ contentSegmentation: 'block',
76
+ promptContext: null, // Global context injected into all translation prompts (e.g. "This is a developer tool README")
77
+ translatableFields: null, // Override DEFAULT_TRANSLATABLE_FIELDS from content.js
78
+ languages: [],
79
+ // Keys whose correct translation is the source value, verbatim: dot-paths
80
+ // and/or globs (e.g. ["**.url", "pages.software.*.repo"]). Matching keys are
81
+ // copied to every target and never sent to a backend, gated, or billed.
82
+ // See lib/no-translate.js for the pattern grammar and the reasoning.
83
+ noTranslate: [],
84
+ // Auto-detect source values that are nothing but a `scheme://` URL and
85
+ // treat them as no-translate. On by default: a URL's correct translation
86
+ // is the URL, but the source-echo gate rejects exactly that, so the default
87
+ // behaviour has no correct outcome. Set false to translate URL-valued keys.
88
+ noTranslateUrls: true,
89
+ pairs: null, // Advanced per-pair overrides (see pairs.js)
90
+ model: DEFAULT_OPENROUTER_MODEL,
91
+ defaultMethod: 'llm', // Global default: llm, llm-coached, google-translate, api, deepl, microsoft-translator, libretranslate, openai, anthropic, gemini
92
+ batchSize: DEFAULT_BATCH_SIZE,
93
+ temperature: null, // null = use method default (0.3 standard, 0.2 coached)
94
+ coachingFile: null, // Path to free-text coaching prompt file (relative to cwd)
95
+ coachingPrompt: null, // Resolved coaching prompt text (read from coachingFile at runtime)
96
+ fallbackPrefix: '[EN] ',
97
+ apiKeyEnvVar: 'OPENROUTER_API_KEY',
98
+ format: 'auto',
99
+ lint: {
100
+ srcDir: null, // Auto-detected from framework
101
+ ignore: ['node_modules', '.next', 'dist', 'build', '.git', 'public', '.vercel'],
102
+ minLength: 2, // Minimum string length to flag
103
+ },
104
+ seo: {
105
+ urlPattern: '/:locale/:path',
106
+ pages: null, // null = auto-detect from locale keys or explicit list
107
+ },
108
+ typegen: {
109
+ output: null, // null = disabled. e.g., './locales.d.ts'
110
+ autoGenerate: false,
111
+ },
112
+ };
113
+
114
+ /**
115
+ * Parse and validate a concurrency CLI flag value.
116
+ *
117
+ * Rejects anything that isn't a finite integer >= 1. An invalid value
118
+ * (0, negative, NaN, "abc") would otherwise reach the pMap worker pool,
119
+ * spawn zero workers, write nothing, and silently "succeed".
120
+ *
121
+ * @param {string} raw - Raw flag value
122
+ * @param {string} flagName - Flag name for the error message (e.g. '--json-concurrency')
123
+ * @returns {number} Validated concurrency integer
124
+ */
125
+ function parseConcurrency(raw, flagName) {
126
+ const val = parseInt(raw, 10);
127
+ if (!Number.isInteger(val) || val < 1) {
128
+ throw new Error(`${flagName} must be a positive integer (got "${raw}").`);
129
+ }
130
+ return val;
131
+ }
132
+
133
+ /**
134
+ * Resolve the full config by merging defaults → config file → CLI args.
135
+ *
136
+ * @param {import('./types.js').CLIArgs} cliArgs - Parsed CLI arguments
137
+ * @param {string} cwd - Working directory to resolve paths from
138
+ * @returns {import('./types.js').ChampollionConfig} Fully resolved config
139
+ */
140
+ function resolveConfig(cliArgs = {}, cwd = process.cwd()) {
141
+ // Start with defaults
142
+ const config = { ...DEFAULTS };
143
+
144
+ // Layer 2: config file
145
+ let configPath;
146
+ if (cliArgs.config) {
147
+ configPath = path.resolve(cwd, cliArgs.config);
148
+ } else {
149
+ // Try each config filename in priority order
150
+ configPath = CONFIG_FILENAMES
151
+ .map(name => path.resolve(cwd, name))
152
+ .find(p => fs.existsSync(p));
153
+ }
154
+
155
+ if (configPath && fs.existsSync(configPath)) {
156
+ try {
157
+ // Strip a leading UTF-8 BOM (U+FEFF) before parsing. A BOM-prefixed
158
+ // config file (common from Windows editors) otherwise hard-crashes
159
+ // JSON.parse with an opaque "Unexpected token" error.
160
+ let configRaw = fs.readFileSync(configPath, 'utf-8');
161
+ if (configRaw.charCodeAt(0) === 0xFEFF) configRaw = configRaw.slice(1);
162
+ const fileConfig = JSON.parse(configRaw);
163
+
164
+ // `skipKeys` is a documented synonym for `noTranslate` — canonicalize it
165
+ // here so exactly one field name reaches every consumer. Both spellings
166
+ // at once is ambiguous (which list wins?), so it fails loud rather than
167
+ // silently dropping one of them.
168
+ if (Object.prototype.hasOwnProperty.call(fileConfig, 'skipKeys')) {
169
+ if (Object.prototype.hasOwnProperty.call(fileConfig, 'noTranslate')) {
170
+ const e = new Error(
171
+ `${path.basename(configPath)} sets BOTH "noTranslate" and "skipKeys" — `
172
+ + 'they are the same field under two names. Keep one (prefer "noTranslate").',
173
+ );
174
+ e.code = 'CHAMPOLLION_CONFIG_INVALID';
175
+ throw e;
176
+ }
177
+ fileConfig.noTranslate = fileConfig.skipKeys;
178
+ delete fileConfig.skipKeys;
179
+ }
180
+
181
+ // Common misspellings / legacy names → correct field name
182
+ const FIELD_ALIASES = {
183
+ sourceLocale: 'inputLocale',
184
+ sourceLang: 'inputLocale',
185
+ source: 'inputLocale',
186
+ locale: 'inputLocale',
187
+ dir: 'localesDir',
188
+ contentDirectory: 'contentDir',
189
+ translatable: 'translatableFields',
190
+ batch: 'batchSize',
191
+ key: 'apiKeyEnvVar',
192
+ apiKey: 'apiKeyEnvVar',
193
+ provider: 'defaultMethod',
194
+ concurrency: 'concurrency', // valid — not in DEFAULTS but consumed by sync
195
+ skipKeys: 'noTranslate', // canonicalized above; alias kept for the hint
196
+ noTranslateURLs: 'noTranslateUrls',
197
+ neverTranslate: 'noTranslate',
198
+ };
199
+
200
+ // Warn on unknown config fields — prevents silent acceptance of
201
+ // misspelled or unsupported fields that the user expects to work.
202
+ const knownFields = new Set([...Object.keys(DEFAULTS), 'concurrency', 'jsonConcurrency', 'contentConcurrency']);
203
+ for (const key of Object.keys(fileConfig)) {
204
+ // Underscore-prefixed keys are comment/annotation fields — e.g. the
205
+ // "_setup" hint emitted by `champollion init`, or user "_comment"
206
+ // keys (already tolerated silently in per-pair configs).
207
+ if (key.startsWith('_')) continue;
208
+ if (!knownFields.has(key)) {
209
+ const suggestion = FIELD_ALIASES[key];
210
+ if (suggestion) {
211
+ console.warn(`[WARN] Unknown config field "${key}" — did you mean "${suggestion}"?`);
212
+ } else {
213
+ console.warn(`[WARN] Unknown config field "${key}" in ${path.basename(configPath)} — this field has no effect. Check spelling or see docs for supported fields.`);
214
+ }
215
+ }
216
+ }
217
+
218
+ Object.assign(config, fileConfig);
219
+
220
+ // Validate the no-translate fields against the file that set them, so
221
+ // the error names the right place. A bad value must never fall through
222
+ // to "translate everything" — that is the corruption path the feature
223
+ // exists to close.
224
+ validateNoTranslateConfig(config.noTranslate, config.noTranslateUrls);
225
+ } catch (err) {
226
+ // A field-level validation error is already specific and actionable —
227
+ // wrapping it in "Could not parse" would misreport valid JSON as a
228
+ // syntax error and send the user hunting for a trailing comma.
229
+ if (err.code === 'CHAMPOLLION_CONFIG_INVALID') throw err;
230
+
231
+ // FAIL LOUD. This was a `[WARN]` that fell through to the built-in
232
+ // defaults: the Object.assign above never ran, so one trailing comma
233
+ // silently replaced the user's inputLocale, localesDir, defaultMethod
234
+ // AND model with defaults — and the run then translated the wrong
235
+ // locales against the wrong model, at full API cost, having printed
236
+ // only a warning the user had no reason to read as fatal.
237
+ //
238
+ // There is no "continue with defaults" that is ever what the user
239
+ // wanted here: they wrote a config file precisely so these values
240
+ // would not be the defaults. No opt-out — fix the JSON.
241
+ const e = new Error(
242
+ `Could not parse ${path.basename(configPath)}: ${err.message}\n\n`
243
+ + ` ${configPath}\n\n`
244
+ + `Champollion will not fall back to default settings — that would `
245
+ + `translate a different set of locales with a different model than `
246
+ + `your config asks for, and bill you for it. Fix the JSON syntax `
247
+ + `(a trailing comma or unquoted key is the usual cause) and re-run.`,
248
+ );
249
+ e.code = 'CHAMPOLLION_CONFIG_PARSE';
250
+ throw e;
251
+ }
252
+ }
253
+
254
+ // Layer 3: CLI overrides
255
+ if (cliArgs.source) config.inputLocale = cliArgs.source;
256
+ if (cliArgs.dir) config.localesDir = cliArgs.dir;
257
+ if (cliArgs.model) config.model = cliArgs.model;
258
+ if (cliArgs.method) config.defaultMethod = cliArgs.method;
259
+ // --batch-size: keys per translation API call. Accept both the CLI flag
260
+ // spelling ('batch-size' from bin/cli.js parseArgs) and the programmatic
261
+ // camelCase form (watch mode / tests pass cliArgs objects directly).
262
+ // Validated like the concurrency flags: a 0/negative/NaN batch size would
263
+ // otherwise slice zero-key batches and silently translate nothing.
264
+ const rawBatchSize = cliArgs['batch-size'] ?? cliArgs.batchSize;
265
+ if (rawBatchSize != null && rawBatchSize !== false && rawBatchSize !== '') {
266
+ config.batchSize = parseConcurrency(String(rawBatchSize), '--batch-size');
267
+ }
268
+ if (cliArgs.format) config.format = cliArgs.format;
269
+ if (cliArgs.temperature != null) config.temperature = parseFloat(cliArgs.temperature);
270
+ if (cliArgs['content-dir']) config.contentDir = cliArgs['content-dir'];
271
+ if (cliArgs['base-url']) config.baseUrl = cliArgs['base-url'];
272
+ if (cliArgs['coaching-file']) config.coachingFile = cliArgs['coaching-file'];
273
+
274
+ // Concurrency configuration — separate limits for JSON (lightweight) and
275
+ // content (heavy markdown) API calls. --concurrency sets both (backward compat).
276
+ //
277
+ // Validate eagerly here (NOT inside the worker pool): an invalid value like
278
+ // 0, a negative, or a non-number would otherwise make pMap spawn zero
279
+ // workers, write nothing, and "succeed". This check runs in resolveConfig,
280
+ // so it fires for --dry runs too. parseConcurrency throws a clear error.
281
+ if (cliArgs.concurrency) {
282
+ const val = parseConcurrency(cliArgs.concurrency, '--concurrency');
283
+ config.jsonConcurrency = val;
284
+ config.contentConcurrency = val;
285
+ }
286
+ if (cliArgs['json-concurrency']) {
287
+ config.jsonConcurrency = parseConcurrency(cliArgs['json-concurrency'], '--json-concurrency');
288
+ }
289
+ if (cliArgs['content-concurrency']) {
290
+ config.contentConcurrency = parseConcurrency(cliArgs['content-concurrency'], '--content-concurrency');
291
+ }
292
+
293
+ // Parse --force-keys: comma-separated dot-notation keys to force re-translate
294
+ config.forceKeys = cliArgs['force-keys']
295
+ ? cliArgs['force-keys'].split(',').map(k => k.trim()).filter(Boolean)
296
+ : [];
297
+
298
+ // Docusaurus auto-detection: if format is still 'auto' and docusaurus.config.js
299
+ // exists in the project root, switch to 'docusaurus' mode and use the standard
300
+ // Docusaurus i18n directory. This runs before path resolution so localesDir
301
+ // is correctly resolved to an absolute path below.
302
+ if (config.format === 'auto' && detectDocusaurus(cwd)) {
303
+ config.format = 'docusaurus';
304
+ // Only override localesDir if it's still the default './locales'.
305
+ // If the user explicitly set localesDir in their config, respect that.
306
+ if (config.localesDir === './locales' || config.localesDir === 'locales') {
307
+ config.localesDir = './i18n';
308
+ }
309
+ }
310
+
311
+ // Resolve localesDir and contentDir to absolute paths
312
+ config.localesDir = path.resolve(cwd, config.localesDir);
313
+ if (config.contentDir) {
314
+ config.contentDir = path.resolve(cwd, config.contentDir);
315
+ }
316
+
317
+ // Resolve model alias (e.g., "gemini-flash" → "google/gemini-3.5-flash")
318
+ config.model = resolveModel(config.model);
319
+
320
+ // Coaching file: read the file contents into coachingPrompt if coachingFile is set.
321
+ // This allows users to maintain coaching prompts as separate text files rather
322
+ // than inlining long strings into champollion.config.json.
323
+ if (config.coachingFile) {
324
+ const coachingPath = path.resolve(cwd, config.coachingFile);
325
+ if (fs.existsSync(coachingPath)) {
326
+ config.coachingPrompt = fs.readFileSync(coachingPath, 'utf-8').trim();
327
+ } else {
328
+ console.error(` [WARN] Coaching file not found: ${coachingPath}`);
329
+ }
330
+ }
331
+
332
+ // Resolve the languages config into a normalized map:
333
+ // { "fr": { name: "French", register: "..." }, ... }
334
+ config.resolvedLanguages = resolveLanguages(config);
335
+
336
+ return config;
337
+ }
338
+
339
+ /**
340
+ * Normalizes the `languages` config into a consistent map.
341
+ *
342
+ * Supports three input formats:
343
+ * - Array of codes: ["fr", "de", "ja"]
344
+ * - Object with registers: { "fr": "My custom French tone", "de": { register: "..." } }
345
+ * - Empty (auto-detect from directory)
346
+ *
347
+ * @param {import('./types.js').ChampollionConfig} config - Resolved config with languages field
348
+ * @returns {Object<string, import('./types.js').LanguageConfig>} Map of locale code → language config
349
+ */
350
+ function resolveLanguages(config) {
351
+ const resolved = {};
352
+ const langs = config.languages;
353
+
354
+ if (Array.isArray(langs) && langs.length > 0) {
355
+ // Simple array: ["fr", "de", "ja"]
356
+ for (const code of langs) {
357
+ // Resolve aliases for card lookups (e.g., 'fr' → 'fra' for getLanguageCard)
358
+ // but key the map by the RAW code the user provided. This ensures pair
359
+ // keys built in pairs.js (e.g., 'en:fr') match user config.pairs entries.
360
+ const canonical = resolveCode(code);
361
+ const card = getLanguageCard(canonical);
362
+ const defaultPresetKey = card?.formality?.default || null;
363
+ resolved[code] = {
364
+ name: card?.name || code,
365
+ register: getRegister(canonical),
366
+ // Store the preset key so consumers (e.g., DeepL) can look up
367
+ // preset-specific metadata without reverse-matching prompt text.
368
+ registerPreset: defaultPresetKey,
369
+ dir: card?.dir || 'ltr',
370
+ formalitySystem: card?.formality?.system || null,
371
+ };
372
+ }
373
+ } else if (typeof langs === 'object' && !Array.isArray(langs) && Object.keys(langs).length > 0) {
374
+ // Object form: { "fr": "Custom register", "de": { name: "German", register: "..." } }
375
+ for (const [code, value] of Object.entries(langs)) {
376
+ // Resolve aliases for card lookups but keep raw code as the map key
377
+ // (same rationale as the array branch above).
378
+ const canonical = resolveCode(code);
379
+ const card = getLanguageCard(canonical);
380
+ if (typeof value === 'string') {
381
+ // Shorthand: could be a preset key OR custom register text.
382
+ // getRegister() handles both — if it matches a preset key, returns
383
+ // that preset's prompt; otherwise passes through as custom text.
384
+ // Detect whether it's a known preset key to preserve for DeepL/etc.
385
+ const isPresetKey = card?.registers?.[value] != null;
386
+ resolved[code] = {
387
+ name: card?.name || code,
388
+ register: getRegister(canonical, value),
389
+ registerPreset: isPresetKey ? value : null,
390
+ dir: card?.dir || 'ltr',
391
+ formalitySystem: card?.formality?.system || null,
392
+ };
393
+ } else if (typeof value === 'object') {
394
+ // Full object form: extract all supported fields.
395
+ // Fields beyond name/register flow through to the pair graph,
396
+ // enabling per-language model/batchSize/maxRetries/script without
397
+ // the more verbose `pairs` config syntax.
398
+ const regValue = value.register || null;
399
+ const isPresetKey = regValue && card?.registers?.[regValue] != null;
400
+ resolved[code] = {
401
+ name: value.name || card?.name || code,
402
+ register: regValue
403
+ ? getRegister(canonical, regValue)
404
+ : getRegister(canonical),
405
+ registerPreset: isPresetKey ? regValue : (regValue ? null : card?.formality?.default || null),
406
+ dir: card?.dir || 'ltr',
407
+ formalitySystem: card?.formality?.system || null,
408
+ ...(value.method && { method: value.method }),
409
+ ...(value.model && { model: value.model }),
410
+ ...(value.batchSize && { batchSize: value.batchSize }),
411
+ ...(value.maxRetries != null && { maxRetries: value.maxRetries }),
412
+ ...(value.script && { script: value.script }),
413
+ ...(value.scriptFallback && { scriptFallback: value.scriptFallback }),
414
+ };
415
+ }
416
+ }
417
+ }
418
+ // If empty, auto-detection happens in sync.js by scanning the directory
419
+
420
+ return resolved;
421
+ }
422
+
423
+ /**
424
+ * Auto-detect target languages by scanning the locales directory
425
+ * for locale files (JSON, TOML, or YAML) that aren't the source file.
426
+ *
427
+ * @param {import('./types.js').ChampollionConfig} config - Resolved config
428
+ * @returns {Object<string, import('./types.js').LanguageConfig & { filename: string }>} Map of locale code → language config with filename
429
+ */
430
+ function autoDetectLanguages(config) {
431
+ const detected = {};
432
+ const inputLocale = config.inputLocale || 'en';
433
+
434
+ if (!fs.existsSync(config.localesDir)) return detected;
435
+
436
+ // Supported locale file extensions
437
+ const LOCALE_EXTS = ['.json', '.toml', '.yaml', '.yml'];
438
+
439
+ const files = fs.readdirSync(config.localesDir)
440
+ .filter(f => {
441
+ const ext = path.extname(f);
442
+ return LOCALE_EXTS.includes(ext);
443
+ })
444
+ .sort();
445
+
446
+ for (const file of files) {
447
+ const ext = path.extname(file);
448
+ const code = path.basename(file, ext);
449
+
450
+ // Skip source locale
451
+ if (code === inputLocale) continue;
452
+
453
+ // Use language card for richer metadata, fall back to backward-compat proxy
454
+ const canonical = resolveCode(code);
455
+ const card = getLanguageCard(canonical);
456
+ detected[code] = {
457
+ name: card?.name || code,
458
+ register: getRegister(canonical),
459
+ registerPreset: card?.formality?.default || null,
460
+ dir: card?.dir || 'ltr',
461
+ formalitySystem: card?.formality?.system || null,
462
+ filename: file,
463
+ };
464
+ }
465
+
466
+ return detected;
467
+ }
468
+
469
+ /**
470
+ * Generate a starter config file for `champollion init`.
471
+ * Produces v3 format config.
472
+ *
473
+ * @param {string} [localesDir] - Locale files directory (default: './locales')
474
+ * @param {string} [inputLocale] - Source locale code (default: 'en')
475
+ * @returns {string} JSON string of the config template
476
+ */
477
+ function generateConfigTemplate(localesDir, inputLocale) {
478
+ return JSON.stringify({
479
+ _setup: 'Add your target language codes to the languages array below. Example: ["fr", "de", "ja"]',
480
+ version: 3,
481
+ inputLocale: inputLocale || 'en',
482
+ baseUrl: '',
483
+ localesDir: localesDir || './locales',
484
+ languages: [],
485
+ model: DEFAULT_OPENROUTER_MODEL,
486
+ batchSize: DEFAULT_BATCH_SIZE,
487
+ }, null, 2);
488
+ }
489
+
490
+ /**
491
+ * Detect if the current project is a Docusaurus site.
492
+ *
493
+ * Checks for the existence of docusaurus.config.js (or .ts) in the
494
+ * given directory. This is the canonical marker for a Docusaurus project.
495
+ *
496
+ * @param {string} cwd - Project root to check
497
+ * @returns {boolean} True if a Docusaurus config file exists
498
+ */
499
+ function detectDocusaurus(cwd) {
500
+ return (
501
+ fs.existsSync(path.join(cwd, 'docusaurus.config.js')) ||
502
+ fs.existsSync(path.join(cwd, 'docusaurus.config.ts'))
503
+ );
504
+ }
505
+
506
+ export {
507
+ resolveConfig,
508
+ resolveLanguages,
509
+ autoDetectLanguages,
510
+ generateConfigTemplate,
511
+ detectDocusaurus,
512
+ CONFIG_FILENAMES,
513
+ DEFAULT_OPENROUTER_MODEL,
514
+ DEFAULT_BATCH_SIZE,
515
+ DEFAULT_JSON_CONCURRENCY,
516
+ DEFAULT_TEMPERATURE,
517
+ DEFAULT_COACHED_TEMPERATURE,
518
+ EST_INPUT_TOKENS_PER_KEY,
519
+ EST_OUTPUT_TOKENS_PER_KEY,
520
+ EST_CHARS_PER_KEY,
521
+ DEFAULT_MAX_RETRIES,
522
+ DEFAULT_METHOD_CONCURRENCY,
523
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Contamination-lane policy — CLI-side mirror of the SSOT in
3
+ * arena/mt_eval_harness/contamination.py (keep the two in sync; the website
4
+ * util cli/website/src/utils/contaminationBadge.js mirrors the same policy).
5
+ *
6
+ * Every evaluation dataset carries a contamination grade (LOW / MEDIUM /
7
+ * HIGH; NONE is treated as absent). The grade answers one question: can a
8
+ * score on this corpus be read as an ABSOLUTE measure of translation
9
+ * quality, or only as a RELATIVE comparison between methods run on the same
10
+ * corpus?
11
+ *
12
+ * The gate FAILS SAFE: only a positively-LOW grade earns the absolute lane.
13
+ * HIGH, MEDIUM, an absent/NONE grade, or an unknown/misspelled grade are all
14
+ * relative-comparison-only — a missing grade can never let a benchmark score
15
+ * masquerade as real translation quality.
16
+ */
17
+
18
+ // Grades that force a corpus into the relative-comparison-only lane. Listed
19
+ // explicitly so the policy is auditable at a glance.
20
+ const RELATIVE_ONLY_GRADES = new Set(['HIGH', 'MEDIUM']);
21
+
22
+ // The ONLY grade that earns the absolute-quality lane — the inverse SSOT that
23
+ // makes the gate fail safe (an unrecognized grade is not in this set, so it
24
+ // defaults to relative-only instead of slipping through to absolute).
25
+ const ABSOLUTE_RANKABLE_GRADES = new Set(['LOW']);
26
+
27
+ // Canonical lane labels (stable machine values for --json consumers).
28
+ const LANE_ABSOLUTE = 'absolute-quality';
29
+ const LANE_RELATIVE_ONLY = 'relative-comparison-only';
30
+
31
+ /**
32
+ * Upper-case a contamination grade; map empty / "NONE" to null (unknown).
33
+ *
34
+ * @param {*} grade - Raw value as it appears on a card/registry entry
35
+ * @returns {string|null}
36
+ */
37
+ function normalizeGrade(grade) {
38
+ if (grade == null) return null;
39
+ const g = String(grade).trim().toUpperCase();
40
+ if (!g || g === 'NONE') return null;
41
+ return g;
42
+ }
43
+
44
+ /**
45
+ * True when a grade keeps a corpus OUT of the absolute-quality lane.
46
+ * FAIL SAFE: absolute-rankable only when positively LOW; HIGH/MEDIUM,
47
+ * absent, and unrecognized grades all return true.
48
+ *
49
+ * @param {*} grade
50
+ * @returns {boolean}
51
+ */
52
+ function isRelativeOnly(grade) {
53
+ const g = normalizeGrade(grade);
54
+ return g === null || !ABSOLUTE_RANKABLE_GRADES.has(g);
55
+ }
56
+
57
+ /**
58
+ * The canonical lane label for a contamination grade (fail-safe by
59
+ * construction — delegates to isRelativeOnly).
60
+ *
61
+ * @param {*} grade
62
+ * @returns {string}
63
+ */
64
+ function laneForGrade(grade) {
65
+ return isRelativeOnly(grade) ? LANE_RELATIVE_ONLY : LANE_ABSOLUTE;
66
+ }
67
+
68
+ export {
69
+ RELATIVE_ONLY_GRADES,
70
+ ABSOLUTE_RANKABLE_GRADES,
71
+ LANE_ABSOLUTE,
72
+ LANE_RELATIVE_ONLY,
73
+ normalizeGrade,
74
+ isRelativeOnly,
75
+ laneForGrade,
76
+ };