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.
- package/README.md +41 -26
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +83 -36
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +34 -0
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +286 -85
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1689 -87
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +632 -125
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +15 -9
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +196 -14
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +270 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1127 -344
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +194 -35
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +726 -78
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +965 -253
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +964 -0
- package/lib/file-scope.js +106 -0
- package/lib/flatten.js +80 -3
- package/lib/flutter-locales.js +124 -0
- package/lib/format.js +266 -12
- package/lib/hash.js +146 -21
- package/lib/icu-structure.js +929 -0
- package/lib/integrity.js +223 -75
- package/lib/language-pair.js +157 -0
- package/lib/lint.js +78 -16
- package/lib/local-only-marks.js +106 -0
- package/lib/locale-layout.js +1103 -0
- package/lib/locale-state.js +571 -0
- package/lib/methods/anthropic.js +5 -0
- package/lib/methods/apertium.js +6 -3
- package/lib/methods/api.js +138 -25
- package/lib/methods/base.js +17 -0
- package/lib/methods/coaching-data.js +153 -0
- package/lib/methods/content-separator.js +43 -0
- package/lib/methods/deepl.js +1 -1
- package/lib/methods/direct-llm.js +252 -103
- package/lib/methods/external.js +146 -63
- package/lib/methods/gemini.js +1 -0
- package/lib/methods/google-translate.js +1 -0
- package/lib/methods/http-utils.js +41 -0
- package/lib/methods/libretranslate.js +7 -2
- package/lib/methods/llm-coached.js +68 -128
- package/lib/methods/llm.js +80 -31
- package/lib/methods/local.js +93 -10
- package/lib/methods/microsoft-translator.js +1 -2
- package/lib/methods/openai.js +4 -2
- package/lib/methods/openrouter-client.js +20 -19
- package/lib/methods/openrouter-pricing.js +150 -13
- package/lib/methods/prompt-methods.js +20 -0
- package/lib/methods/provider-pricing.js +42 -1
- package/lib/methods/request-capture.js +104 -0
- package/lib/methods/tilde.js +1 -1
- package/lib/methods/translated.js +1 -2
- package/lib/missing-key.js +93 -0
- package/lib/models.js +11 -0
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +6 -1
- package/lib/seal.mjs +4 -3
- package/lib/sealed-qualifier.mjs +1 -1
- package/lib/segment.js +2 -1
- package/lib/seo.js +19 -9
- package/lib/serve.js +43 -6
- package/lib/shared-output-seed.js +164 -0
- package/lib/source-contexts.js +39 -0
- package/lib/submit.mjs +57 -5
- package/lib/sync.js +2923 -474
- package/lib/terminology.js +13 -4
- package/lib/tm-evict.js +179 -0
- package/lib/tm-seed.js +5 -2
- package/lib/tm.js +818 -36
- package/lib/translate-pair.js +639 -34
- package/lib/translate.js +78 -5
- package/lib/types.js +22 -3
- package/lib/validate.js +880 -17
- package/lib/verify.js +1296 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +1 -1
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/cards-fallback.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +8 -2
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- package/shared/docent/corpus.json +0 -11739
|
@@ -189,7 +189,7 @@ export class CommercialRouteBlockedError extends Error {
|
|
|
189
189
|
+ `${verdict.reason}`
|
|
190
190
|
+ (verdict.license ? ` (license: ${verdict.license})` : '')
|
|
191
191
|
+ '. Route it in the non-commercial lane, or use a method whose license '
|
|
192
|
-
+ 'permits commercial use (`champollion recommend <src> <tgt> '
|
|
192
|
+
+ 'permits commercial use (`champollion network recommend <src> <tgt> '
|
|
193
193
|
+ '--use commercial` lists them).'
|
|
194
194
|
);
|
|
195
195
|
this.name = 'CommercialRouteBlockedError';
|
package/lib/config.js
CHANGED
|
@@ -16,6 +16,10 @@ import path from 'node:path';
|
|
|
16
16
|
import { DEFAULT_REGISTERS, getLanguageCard, getRegister, resolveCode } from './registers.js';
|
|
17
17
|
import { resolveModel } from './models.js';
|
|
18
18
|
import { validateNoTranslateConfig } from './no-translate.js';
|
|
19
|
+
import { output } from './output.js';
|
|
20
|
+
import { splitKeyList } from './redo.js';
|
|
21
|
+
import { LOCALE_FILE_FORMATS } from './format.js';
|
|
22
|
+
import { discoverLocaleLayout, compileLocalesPattern, LAYOUT_KINDS, FORMAT_BY_EXT } from './locale-layout.js';
|
|
19
23
|
|
|
20
24
|
const CONFIG_FILENAMES = ['champollion.config.json'];
|
|
21
25
|
|
|
@@ -67,13 +71,32 @@ const DEFAULTS = {
|
|
|
67
71
|
inputLocale: 'en',
|
|
68
72
|
baseUrl: '',
|
|
69
73
|
localesDir: './locales',
|
|
70
|
-
|
|
74
|
+
// Where every locale's key-value files live when the default shapes do
|
|
75
|
+
// not fit (lib/locale-layout.js): a path with {lang} and optional {ns},
|
|
76
|
+
// e.g. "public/locales/{lang}/{ns}.json". Relative to the project root.
|
|
77
|
+
// When set, it fixes localesDir (the part before the first placeholder).
|
|
78
|
+
localesPattern: null,
|
|
79
|
+
// Force the layout auto-detection's answer: 'flat' (<dir>/<lang>.json)
|
|
80
|
+
// or 'dir' (<dir>/<lang>/<ns>.json). null = detect from the source files.
|
|
81
|
+
localesLayout: null,
|
|
82
|
+
// Namespace `wrap` adds extracted keys to in a folder-per-locale project
|
|
83
|
+
// with several namespace files (e.g. "common"). null = the only file.
|
|
84
|
+
defaultNamespace: null,
|
|
85
|
+
contentDir: null, // Folder of Markdown/MDX to translate (e.g. './content', './newsletters'). null = disabled.
|
|
71
86
|
// Markdown body translation granularity: 'block' (default — segment the
|
|
72
87
|
// body, TM-cache per block, one batched API call for the misses) or
|
|
73
88
|
// 'page' (single whole-body prompt, still TM-threaded). Overridable per
|
|
74
89
|
// pair. Validated in docusaurus-sync.js — anything else fails loud.
|
|
75
90
|
contentSegmentation: 'block',
|
|
76
91
|
promptContext: null, // Global context injected into all translation prompts (e.g. "This is a developer tool README")
|
|
92
|
+
// Gender guidance in LLM prompts: null = each language's guidance from the
|
|
93
|
+
// catalogue (French: écriture inclusive, "Connecté·e"); false = none; a
|
|
94
|
+
// string = your own instruction. Overridable per language and per pair.
|
|
95
|
+
genderGuidance: null,
|
|
96
|
+
// Names kept exactly as written in every language (people, companies,
|
|
97
|
+
// products). Told to the model, and exempt from the wrong-script /
|
|
98
|
+
// untranslated checks. Value-level, unlike noTranslate (which skips KEYS).
|
|
99
|
+
protectedTerms: [],
|
|
77
100
|
translatableFields: null, // Override DEFAULT_TRANSLATABLE_FIELDS from content.js
|
|
78
101
|
languages: [],
|
|
79
102
|
// Keys whose correct translation is the source value, verbatim: dot-paths
|
|
@@ -89,6 +112,11 @@ const DEFAULTS = {
|
|
|
89
112
|
pairs: null, // Advanced per-pair overrides (see pairs.js)
|
|
90
113
|
model: DEFAULT_OPENROUTER_MODEL,
|
|
91
114
|
defaultMethod: 'llm', // Global default: llm, llm-coached, google-translate, api, deepl, microsoft-translator, libretranslate, openai, anthropic, gemini
|
|
115
|
+
// Transport for the LLM lane (llm / llm-coached pairs): openrouter (the
|
|
116
|
+
// default when null), openai, anthropic, gemini, local. Harness
|
|
117
|
+
// `export-config` emits it for runs validated on a direct provider.
|
|
118
|
+
// Overridable per language and per pair; validated in pairs.js.
|
|
119
|
+
provider: null,
|
|
92
120
|
batchSize: DEFAULT_BATCH_SIZE,
|
|
93
121
|
temperature: null, // null = use method default (0.3 standard, 0.2 coached)
|
|
94
122
|
coachingFile: null, // Path to free-text coaching prompt file (relative to cwd)
|
|
@@ -190,11 +218,12 @@ function resolveConfig(cliArgs = {}, cwd = process.cwd()) {
|
|
|
190
218
|
batch: 'batchSize',
|
|
191
219
|
key: 'apiKeyEnvVar',
|
|
192
220
|
apiKey: 'apiKeyEnvVar',
|
|
193
|
-
provider: 'defaultMethod',
|
|
194
221
|
concurrency: 'concurrency', // valid — not in DEFAULTS but consumed by sync
|
|
195
222
|
skipKeys: 'noTranslate', // canonicalized above; alias kept for the hint
|
|
196
223
|
noTranslateURLs: 'noTranslateUrls',
|
|
197
224
|
neverTranslate: 'noTranslate',
|
|
225
|
+
doNotTranslate: 'protectedTerms',
|
|
226
|
+
keepVerbatim: 'protectedTerms',
|
|
198
227
|
};
|
|
199
228
|
|
|
200
229
|
// Warn on unknown config fields — prevents silent acceptance of
|
|
@@ -208,20 +237,36 @@ function resolveConfig(cliArgs = {}, cwd = process.cwd()) {
|
|
|
208
237
|
if (!knownFields.has(key)) {
|
|
209
238
|
const suggestion = FIELD_ALIASES[key];
|
|
210
239
|
if (suggestion) {
|
|
211
|
-
|
|
240
|
+
output.warn(`Unknown config field "${key}" — did you mean "${suggestion}"?`);
|
|
212
241
|
} else {
|
|
213
|
-
|
|
242
|
+
output.warn(`Unknown config field "${key}" in ${path.basename(configPath)} — this field has no effect. Check spelling or see docs for supported fields.`);
|
|
214
243
|
}
|
|
215
244
|
}
|
|
216
245
|
}
|
|
217
246
|
|
|
218
247
|
Object.assign(config, fileConfig);
|
|
248
|
+
// localesPattern fixes localesDir; a separately WRITTEN localesDir
|
|
249
|
+
// that disagrees with it is checked below, once paths are resolved.
|
|
250
|
+
config._localesDirExplicit = fileConfig.localesDir != null;
|
|
251
|
+
// Whether the top-level model was WRITTEN (vs. the built-in OpenRouter
|
|
252
|
+
// default). pairs.js needs the difference: a top-level `model` is sent
|
|
253
|
+
// to a top-level direct `provider` only when someone named it — the
|
|
254
|
+
// OpenRouter default slug must never reach OpenAI or Ollama.
|
|
255
|
+
config._modelExplicit = fileConfig.model != null;
|
|
219
256
|
|
|
220
257
|
// Validate the no-translate fields against the file that set them, so
|
|
221
258
|
// the error names the right place. A bad value must never fall through
|
|
222
259
|
// to "translate everything" — that is the corruption path the feature
|
|
223
260
|
// exists to close.
|
|
224
261
|
validateNoTranslateConfig(config.noTranslate, config.noTranslateUrls);
|
|
262
|
+
if (!Array.isArray(config.protectedTerms)
|
|
263
|
+
|| config.protectedTerms.some(t => typeof t !== 'string' || t.trim() === '')) {
|
|
264
|
+
const e = new Error(
|
|
265
|
+
'"protectedTerms" must be an array of non-empty strings — the names to keep as written, '
|
|
266
|
+
+ 'e.g. "protectedTerms": ["Curtis Forbes", "Game Day Suits"].');
|
|
267
|
+
e.code = 'CHAMPOLLION_CONFIG_INVALID';
|
|
268
|
+
throw e;
|
|
269
|
+
}
|
|
225
270
|
} catch (err) {
|
|
226
271
|
// A field-level validation error is already specific and actionable —
|
|
227
272
|
// wrapping it in "Could not parse" would misreport valid JSON as a
|
|
@@ -253,9 +298,49 @@ function resolveConfig(cliArgs = {}, cwd = process.cwd()) {
|
|
|
253
298
|
|
|
254
299
|
// Layer 3: CLI overrides
|
|
255
300
|
if (cliArgs.source) config.inputLocale = cliArgs.source;
|
|
256
|
-
if (cliArgs.dir)
|
|
257
|
-
|
|
258
|
-
|
|
301
|
+
if (cliArgs.dir) {
|
|
302
|
+
config.localesDir = cliArgs.dir;
|
|
303
|
+
config._localesDirExplicit = true;
|
|
304
|
+
}
|
|
305
|
+
// A pair's `fallback` is NOT touched by --model / --method (they override
|
|
306
|
+
// the pair's own method only), so pairs.js resolves fallbacks against the
|
|
307
|
+
// model and default method the FILE set. Recorded before the flags apply.
|
|
308
|
+
config._fileModel = config.model;
|
|
309
|
+
config._fileModelExplicit = !!config._modelExplicit;
|
|
310
|
+
config._fileDefaultMethod = config.defaultMethod;
|
|
311
|
+
if (cliArgs.model) {
|
|
312
|
+
config.model = cliArgs.model;
|
|
313
|
+
config._modelExplicit = true;
|
|
314
|
+
// Like --method: the flag beats a pair's own model for this run (how you
|
|
315
|
+
// try or price a switch before committing to it), and says so.
|
|
316
|
+
config._modelOverride = cliArgs.model;
|
|
317
|
+
const pinned = Object.entries(config.pairs || {})
|
|
318
|
+
.filter(([, p]) => p && p.model && p.model !== cliArgs.model)
|
|
319
|
+
.map(([key, p]) => `${key} (${p.model})`);
|
|
320
|
+
if (pinned.length > 0) {
|
|
321
|
+
output.info(`--model ${cliArgs.model} overrides the configured model for this run: ${pinned.join(', ')}. Use --pair to limit it.`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (cliArgs.method) {
|
|
325
|
+
config.defaultMethod = cliArgs.method;
|
|
326
|
+
// A flag beats the file for this run — including a pair's own `method`.
|
|
327
|
+
// It used to set only the default, so `sync --method local` (what the
|
|
328
|
+
// forge export's DEPLOY.md says to run) silently kept a pair configured
|
|
329
|
+
// for llm. Say which pairs it overrides; --pair scopes it.
|
|
330
|
+
config._methodOverride = cliArgs.method;
|
|
331
|
+
const overridden = [];
|
|
332
|
+
for (const [key, p] of Object.entries(config.pairs || {})) {
|
|
333
|
+
if (p && p.method && p.method !== cliArgs.method) overridden.push(`${key} (${p.method})`);
|
|
334
|
+
}
|
|
335
|
+
if (config.languages && !Array.isArray(config.languages)) {
|
|
336
|
+
for (const [code, l] of Object.entries(config.languages)) {
|
|
337
|
+
if (l && l.method && l.method !== cliArgs.method) overridden.push(`${code} (${l.method})`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (overridden.length > 0) {
|
|
341
|
+
output.info(`--method ${cliArgs.method} overrides the configured method for this run: ${overridden.join(', ')}. Use --pair to limit it.`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
259
344
|
// --batch-size: keys per translation API call. Accept both the CLI flag
|
|
260
345
|
// spelling ('batch-size' from bin/cli.js parseArgs) and the programmatic
|
|
261
346
|
// camelCase form (watch mode / tests pass cliArgs objects directly).
|
|
@@ -290,10 +375,9 @@ function resolveConfig(cliArgs = {}, cwd = process.cwd()) {
|
|
|
290
375
|
config.contentConcurrency = parseConcurrency(cliArgs['content-concurrency'], '--content-concurrency');
|
|
291
376
|
}
|
|
292
377
|
|
|
293
|
-
// Parse --force-keys: comma-separated
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
: [];
|
|
378
|
+
// Parse --force-keys: comma-separated keys to force re-translate (`\,` is a
|
|
379
|
+
// comma inside a key — gettext msgids are sentences)
|
|
380
|
+
config.forceKeys = cliArgs['force-keys'] ? splitKeyList(cliArgs['force-keys']) : [];
|
|
297
381
|
|
|
298
382
|
// Docusaurus auto-detection: if format is still 'auto' and docusaurus.config.js
|
|
299
383
|
// exists in the project root, switch to 'docusaurus' mode and use the standard
|
|
@@ -308,12 +392,60 @@ function resolveConfig(cliArgs = {}, cwd = process.cwd()) {
|
|
|
308
392
|
}
|
|
309
393
|
}
|
|
310
394
|
|
|
395
|
+
// `format` must name something a reader exists for. An unknown value used
|
|
396
|
+
// to fall through every `format === ...` branch and parse the files as
|
|
397
|
+
// JSON — "jsn" or "yml" produced confusing parse errors (or, for TOML,
|
|
398
|
+
// garbage) instead of a clear answer.
|
|
399
|
+
const validFormats = ['auto', 'docusaurus', ...LOCALE_FILE_FORMATS];
|
|
400
|
+
if (!validFormats.includes(config.format)) {
|
|
401
|
+
const hint = config.format === 'yml' ? ' (use "yaml" — .yml files are read as YAML)' : '';
|
|
402
|
+
const e = new Error(
|
|
403
|
+
`Unknown "format": ${JSON.stringify(config.format)}${hint}. `
|
|
404
|
+
+ `Supported: ${validFormats.join(', ')}.`);
|
|
405
|
+
e.code = 'CHAMPOLLION_CONFIG_INVALID';
|
|
406
|
+
throw e;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (config.localesLayout != null && !LAYOUT_KINDS.includes(config.localesLayout)) {
|
|
410
|
+
const e = new Error(
|
|
411
|
+
`"localesLayout" must be one of ${LAYOUT_KINDS.join(', ')} (got ${JSON.stringify(config.localesLayout)}). `
|
|
412
|
+
+ 'For any other shape, set "localesPattern", e.g. "public/locales/{lang}/{ns}.json".');
|
|
413
|
+
e.code = 'CHAMPOLLION_CONFIG_INVALID';
|
|
414
|
+
throw e;
|
|
415
|
+
}
|
|
416
|
+
|
|
311
417
|
// Resolve localesDir and contentDir to absolute paths
|
|
312
418
|
config.localesDir = path.resolve(cwd, config.localesDir);
|
|
313
419
|
if (config.contentDir) {
|
|
314
420
|
config.contentDir = path.resolve(cwd, config.contentDir);
|
|
315
421
|
}
|
|
316
422
|
|
|
423
|
+
// localesPattern: absolute, validated now (a malformed pattern must stop
|
|
424
|
+
// every command, not surface as "0 files" mid-sync), and the source of
|
|
425
|
+
// truth for localesDir — every path-containment check uses localesDir.
|
|
426
|
+
if (config.localesPattern != null) {
|
|
427
|
+
if (config.format === 'docusaurus') {
|
|
428
|
+
const e = new Error('"localesPattern" does not apply to Docusaurus projects — their locale files live in i18n/<locale>/.');
|
|
429
|
+
e.code = 'CHAMPOLLION_CONFIG_INVALID';
|
|
430
|
+
throw e;
|
|
431
|
+
}
|
|
432
|
+
if (config.localesLayout != null) {
|
|
433
|
+
const e = new Error('Set either "localesPattern" or "localesLayout", not both — a pattern already fixes the layout.');
|
|
434
|
+
e.code = 'CHAMPOLLION_CONFIG_INVALID';
|
|
435
|
+
throw e;
|
|
436
|
+
}
|
|
437
|
+
const compiled = compileLocalesPattern(path.resolve(cwd, String(config.localesPattern)));
|
|
438
|
+
if (config._localesDirExplicit && path.resolve(config.localesDir) !== compiled.base) {
|
|
439
|
+
const e = new Error(
|
|
440
|
+
`"localesDir" (${config.localesDir}) disagrees with "localesPattern" (files under ${compiled.base}). `
|
|
441
|
+
+ 'The pattern already says where locale files live — remove "localesDir".');
|
|
442
|
+
e.code = 'CHAMPOLLION_CONFIG_INVALID';
|
|
443
|
+
throw e;
|
|
444
|
+
}
|
|
445
|
+
config.localesPattern = path.resolve(cwd, String(config.localesPattern));
|
|
446
|
+
config.localesDir = compiled.base;
|
|
447
|
+
}
|
|
448
|
+
|
|
317
449
|
// Resolve model alias (e.g., "gemini-flash" → "google/gemini-3.5-flash")
|
|
318
450
|
config.model = resolveModel(config.model);
|
|
319
451
|
|
|
@@ -407,10 +539,30 @@ function resolveLanguages(config) {
|
|
|
407
539
|
formalitySystem: card?.formality?.system || null,
|
|
408
540
|
...(value.method && { method: value.method }),
|
|
409
541
|
...(value.model && { model: value.model }),
|
|
542
|
+
...(value.provider != null && { provider: value.provider }),
|
|
410
543
|
...(value.batchSize && { batchSize: value.batchSize }),
|
|
411
544
|
...(value.maxRetries != null && { maxRetries: value.maxRetries }),
|
|
412
545
|
...(value.script && { script: value.script }),
|
|
413
546
|
...(value.scriptFallback && { scriptFallback: value.scriptFallback }),
|
|
547
|
+
// The remaining fields pairs.js reads from a language. They used
|
|
548
|
+
// to be dropped here, so `{ "method": "api", "endpoint": … }` in
|
|
549
|
+
// the object form ran with no endpoint, and the documented
|
|
550
|
+
// temperature / coachingFile / promptContext had no effect.
|
|
551
|
+
...(value.endpoint && { endpoint: value.endpoint }),
|
|
552
|
+
...(value.apiKey && { apiKey: value.apiKey }),
|
|
553
|
+
// An `api` endpoint's declared instruction capability (lib/methods/api.js).
|
|
554
|
+
...(typeof value.acceptsInstructions === 'boolean' && { acceptsInstructions: value.acceptsInstructions }),
|
|
555
|
+
...(value.temperature != null && { temperature: value.temperature }),
|
|
556
|
+
...(value.coachingFile != null && { coachingFile: value.coachingFile }),
|
|
557
|
+
...(value.coachingPrompt != null && { coachingPrompt: value.coachingPrompt }),
|
|
558
|
+
...(value.promptContext != null && { promptContext: value.promptContext }),
|
|
559
|
+
...(value.contentSegmentation != null && { contentSegmentation: value.contentSegmentation }),
|
|
560
|
+
// Gender guidance in the prompt: false = none, a string = your own
|
|
561
|
+
// (absent: the catalogue's for the language — lib/pairs.js).
|
|
562
|
+
...((value.genderGuidance === false || typeof value.genderGuidance === 'string') && { genderGuidance: value.genderGuidance }),
|
|
563
|
+
// A second method for what this language's own method cannot
|
|
564
|
+
// translate safely — resolved and validated in pairs.js.
|
|
565
|
+
...(value.fallback !== undefined && { fallback: value.fallback }),
|
|
414
566
|
};
|
|
415
567
|
}
|
|
416
568
|
}
|
|
@@ -422,7 +574,8 @@ function resolveLanguages(config) {
|
|
|
422
574
|
|
|
423
575
|
/**
|
|
424
576
|
* Auto-detect target languages by scanning the locales directory
|
|
425
|
-
* for locale files (
|
|
577
|
+
* for locale files (every extension the layout module reads — JSON, TOML,
|
|
578
|
+
* YAML, gettext .po, Flutter .arb) that aren't the source file.
|
|
426
579
|
*
|
|
427
580
|
* @param {import('./types.js').ChampollionConfig} config - Resolved config
|
|
428
581
|
* @returns {Object<string, import('./types.js').LanguageConfig & { filename: string }>} Map of locale code → language config with filename
|
|
@@ -431,10 +584,39 @@ function autoDetectLanguages(config) {
|
|
|
431
584
|
const detected = {};
|
|
432
585
|
const inputLocale = config.inputLocale || 'en';
|
|
433
586
|
|
|
587
|
+
// Folder-per-locale and pattern layouts: the locales are what the layout
|
|
588
|
+
// finds on disk — sub-folders of localesDir (stray files at its root are
|
|
589
|
+
// not locales) or every {lang} the pattern matches. Exact code match, so
|
|
590
|
+
// en-GB is a target when the source is en.
|
|
591
|
+
const sourceDirExists = (() => {
|
|
592
|
+
try { return fs.statSync(path.join(config.localesDir, inputLocale)).isDirectory(); } catch { return false; }
|
|
593
|
+
})();
|
|
594
|
+
if (config.format !== 'docusaurus'
|
|
595
|
+
&& (config.localesPattern || config.localesLayout === 'dir' || sourceDirExists)) {
|
|
596
|
+
const layout = discoverLocaleLayout(config);
|
|
597
|
+
if (layout.kind !== 'flat') {
|
|
598
|
+
for (const code of layout.listLocales()) {
|
|
599
|
+
const canonical = resolveCode(code);
|
|
600
|
+
const card = getLanguageCard(canonical);
|
|
601
|
+
detected[code] = {
|
|
602
|
+
name: card?.name || code,
|
|
603
|
+
register: getRegister(canonical),
|
|
604
|
+
registerPreset: card?.formality?.default || null,
|
|
605
|
+
dir: card?.dir || 'ltr',
|
|
606
|
+
formalitySystem: card?.formality?.system || null,
|
|
607
|
+
filename: layout.filesFor(code)[0]?.rel ?? code,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return detected;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
434
614
|
if (!fs.existsSync(config.localesDir)) return detected;
|
|
435
615
|
|
|
436
|
-
//
|
|
437
|
-
|
|
616
|
+
// Locale file extensions — the layout module's own list (lib/locale-layout.js
|
|
617
|
+
// FORMAT_BY_EXT), so a format the reader learns is a format detection
|
|
618
|
+
// finds. A gettext template (.pot) is not a locale and is not listed there.
|
|
619
|
+
const LOCALE_EXTS = Object.keys(FORMAT_BY_EXT);
|
|
438
620
|
|
|
439
621
|
const files = fs.readdirSync(config.localesDir)
|
|
440
622
|
.filter(f => {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* content-estimate.js — what a content file will actually BILL.
|
|
3
|
+
*
|
|
4
|
+
* The cost gate (--max-cost) must price what the run will pay for, not the
|
|
5
|
+
* whole file: front-matter fields and body blocks the Translation Memory
|
|
6
|
+
* already holds are served at $0 by the sync. Pricing whole files made a
|
|
7
|
+
* $100 cap refuse runs whose real cost was ~$30, and operators raised the
|
|
8
|
+
* cap past the honest number to get work done (dogfood 2026-08-28, finding
|
|
9
|
+
* 1) — the guardrail stopped guarding.
|
|
10
|
+
*
|
|
11
|
+
* This walks the SAME ladder both content lanes run at sync time:
|
|
12
|
+
* 1. each front-matter field, cached on its own source text;
|
|
13
|
+
* 2. the whole body (a revert or lock-loss re-run is free);
|
|
14
|
+
* 3. in 'block' segmentation, each translatable block.
|
|
15
|
+
* One implementation for the Hugo and Docusaurus estimators, so the two can
|
|
16
|
+
* no longer disagree about what is billable.
|
|
17
|
+
*
|
|
18
|
+
* Pure lookups (lookupTM, never the evicting lookupTMValidated): an
|
|
19
|
+
* estimate must not change the cache it is estimating against.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { lookupTM } from './tm.js';
|
|
23
|
+
import { splitBlocks } from './segment.js';
|
|
24
|
+
import { protectBlocks, restoreBlocks } from './content.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Restored source texts of a body's translatable blocks — the exact unit
|
|
28
|
+
* block-mode sync caches and bills.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} body - Markdown body (front matter stripped)
|
|
31
|
+
* @returns {string[]}
|
|
32
|
+
*/
|
|
33
|
+
export function translatableBlockSources(body) {
|
|
34
|
+
const { protectedBody, blocks } = protectBlocks(body);
|
|
35
|
+
return splitBlocks(protectedBody)
|
|
36
|
+
.filter(seg => seg.type === 'translatable')
|
|
37
|
+
.map(seg => restoreBlocks(seg.text, blocks));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Billable vs total source characters for one (file × locale) work item.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} args
|
|
44
|
+
* @param {object} args.tm - Loaded TM (carry-over settings apply)
|
|
45
|
+
* @param {string} args.code - Target locale
|
|
46
|
+
* @param {string} args.tmKey - tmMethodKey(pairConfig)
|
|
47
|
+
* @param {string|null} [args.fallbackTmKey] - tmMethodKey(pairConfig.fallback)
|
|
48
|
+
* when the pair has a fallback: the sync serves the fallback's cached text
|
|
49
|
+
* before asking the primary (lib/fallback.js), so it is free here too
|
|
50
|
+
* @param {Record<string, string>} args.fields - Front-matter fields to translate
|
|
51
|
+
* @param {string} args.body - Markdown body
|
|
52
|
+
* @param {'block'|'page'} args.segMode - Content segmentation mode
|
|
53
|
+
* @param {() => string[]} args.blockSources - Lazily computed block sources
|
|
54
|
+
* (callers cache per source file: segmentation is locale-independent)
|
|
55
|
+
* @param {ReturnType<import('./content-refusals.js').contentHolds>|null} [args.holds] -
|
|
56
|
+
* What the quality gate refused before (lib/content-refusals.js): a block
|
|
57
|
+
* or field the pair's method refused is not sent to it (held, or the
|
|
58
|
+
* fallback's — which this estimate does not price); a held front-matter
|
|
59
|
+
* field — or a held page body in 'page' segmentation — holds its whole
|
|
60
|
+
* page, so nothing of it is billed
|
|
61
|
+
* @returns {{ billedChars: number, totalChars: number }}
|
|
62
|
+
*/
|
|
63
|
+
export function billableContentChars({ tm, code, tmKey, fallbackTmKey = null, fields, body, segMode, blockSources, holds = null }) {
|
|
64
|
+
let billedChars = 0;
|
|
65
|
+
let totalChars = 0;
|
|
66
|
+
let pageHeld = false;
|
|
67
|
+
const cached = (text) => lookupTM(tm, text, code, tmKey) !== null
|
|
68
|
+
|| (fallbackTmKey !== null && lookupTM(tm, text, code, fallbackTmKey) !== null);
|
|
69
|
+
|
|
70
|
+
for (const [field, text] of Object.entries(fields)) {
|
|
71
|
+
totalChars += text.length;
|
|
72
|
+
if (cached(text)) continue;
|
|
73
|
+
const hold = holds ? holds.field(field, text) : 'send';
|
|
74
|
+
if (hold === 'held') pageHeld = true;
|
|
75
|
+
else if (hold === 'send') billedChars += text.length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (body.trim()) {
|
|
79
|
+
totalChars += body.length;
|
|
80
|
+
if (!cached(body)) {
|
|
81
|
+
if (segMode === 'page') {
|
|
82
|
+
// A page translated whole and refused before: held (its page is
|
|
83
|
+
// not processed — nothing of it is billed), or the fallback's.
|
|
84
|
+
const hold = holds ? holds.page(body) : 'send';
|
|
85
|
+
if (hold === 'held') pageHeld = true;
|
|
86
|
+
else if (hold === 'send') billedChars += body.length;
|
|
87
|
+
} else {
|
|
88
|
+
for (const source of blockSources()) {
|
|
89
|
+
if (!cached(source) && (!holds || holds.block(source) === 'send')) billedChars += source.length;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { billedChars: pageHeld ? 0 : billedChars, totalChars };
|
|
96
|
+
}
|