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.
- package/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- 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
|
+
};
|