champollion 0.3.3 → 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 +52 -37
- 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 +51 -3
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +289 -88
- 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 +649 -130
- 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 +16 -10
- 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 +197 -38
- 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 +193 -106
- package/lib/seal.mjs +6 -5
- package/lib/sealed-qualifier.mjs +2 -2
- 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 +3 -2
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/DATA-SOVEREIGNTY.md +19 -20
- package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
- package/shared/cards-fallback.json +1 -1
- package/shared/catalogue/card-config.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/docent/faq.en.json +14 -16
- package/shared/docent/system-prompt.md +17 -19
- package/shared/explainers/tc-features.json +15 -15
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/human-services.json +1 -1
- 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 +20 -10
- package/shared/schemas/human-services.schema.json +2 -2
- package/shared/schemas/language-card.schema.json +1 -1
- package/shared/schemas/method-card.schema.json +1 -1
- 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 -11333
package/lib/commands/xliff.js
CHANGED
|
@@ -20,8 +20,11 @@ import path from 'node:path';
|
|
|
20
20
|
import { resolveConfig } from '../config.js';
|
|
21
21
|
import { exportXLIFF, importXLIFF } from '../xliff.js';
|
|
22
22
|
import { compileNoTranslate } from '../no-translate.js';
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
23
|
+
import { setNestedValue, assignInOrder } from '../flatten.js';
|
|
24
|
+
import { writeLocaleFile, detectYAMLStyle } from '../format.js';
|
|
25
|
+
import {
|
|
26
|
+
discoverLocaleLayout, loadSourceUnits, expectedForTarget, readLocaleFlat, lockKey, splitLockKey,
|
|
27
|
+
} from '../locale-layout.js';
|
|
25
28
|
import { output } from '../output.js';
|
|
26
29
|
|
|
27
30
|
/** Default output directory for exported XLIFF files */
|
|
@@ -75,26 +78,34 @@ function runExport(args, cwd) {
|
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
const config = resolveConfig(args, cwd);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
// The project's locale files from the ONE layout module. A folder-per-
|
|
82
|
+
// locale project exports ALL of a locale's namespace files into one XLIFF
|
|
83
|
+
// document; unit ids are "<ns>::<key>" (the lock-manifest convention), so
|
|
84
|
+
// import can route every unit back to its file. Single-file layouts keep
|
|
85
|
+
// bare keys — their XLIFF is exactly what it was.
|
|
86
|
+
const layout = discoverLocaleLayout(config, { cwd });
|
|
87
|
+
const sourceMissing = layout.namespaced
|
|
88
|
+
? layout.sourceFiles.length === 0
|
|
89
|
+
: !fs.existsSync(layout.sourceFiles[0].path);
|
|
90
|
+
if (sourceMissing) {
|
|
91
|
+
const where = layout.namespaced ? `${layout.display} (no files for ${config.inputLocale})` : layout.sourceFiles[0].path;
|
|
92
|
+
output.error(`Source locale file not found: ${where}`);
|
|
87
93
|
return 1;
|
|
88
94
|
}
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
const units = loadSourceUnits(layout);
|
|
96
|
+
|
|
97
|
+
// Source = what THIS locale must contain (i18next plurals expand to the
|
|
98
|
+
// target's own CLDR categories); target = what its files hold today
|
|
99
|
+
// (files that do not exist yet export as untranslated).
|
|
100
|
+
const sourceFlat = {};
|
|
101
|
+
const targetFlat = {};
|
|
102
|
+
for (const unit of units) {
|
|
103
|
+
const expected = expectedForTarget(unit, config.inputLocale, locale).flat;
|
|
104
|
+
for (const [k, v] of Object.entries(expected)) sourceFlat[lockKey(layout, unit.ns, k)] = v;
|
|
105
|
+
const file = layout.fileFor(locale, unit.ns);
|
|
106
|
+
if (fs.existsSync(file.path)) {
|
|
107
|
+
for (const [k, v] of Object.entries(readLocaleFlat(file))) targetFlat[lockKey(layout, unit.ns, k)] = v;
|
|
108
|
+
}
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
// Generate XLIFF
|
|
@@ -103,8 +114,12 @@ function runExport(args, cwd) {
|
|
|
103
114
|
targetLocale: locale,
|
|
104
115
|
sourceFlat,
|
|
105
116
|
targetFlat,
|
|
106
|
-
original:
|
|
107
|
-
|
|
117
|
+
original: layout.namespaced
|
|
118
|
+
? (layout.kind === 'dir' ? `${config.inputLocale}/` : layout.display)
|
|
119
|
+
: units[0].file.rel,
|
|
120
|
+
// No-translate patterns match a key WITHIN its file — strip the
|
|
121
|
+
// namespace before asking.
|
|
122
|
+
noTranslate: namespacedMatcher(compileNoTranslate(config), layout),
|
|
108
123
|
});
|
|
109
124
|
|
|
110
125
|
// Determine output path
|
|
@@ -198,42 +213,69 @@ function runImport(args, cwd) {
|
|
|
198
213
|
}
|
|
199
214
|
|
|
200
215
|
const config = resolveConfig(args, cwd);
|
|
201
|
-
const
|
|
202
|
-
? config.format
|
|
203
|
-
: detectFormatFromDir(config.localesDir);
|
|
204
|
-
const ext = getExtension(format);
|
|
205
|
-
const targetPath = path.join(config.localesDir, `${locale}${ext}`);
|
|
216
|
+
const layout = discoverLocaleLayout(config, { cwd });
|
|
206
217
|
|
|
207
218
|
const dryRun = args.dry || false;
|
|
208
219
|
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
220
|
+
// Route every unit to its file. Namespaced layouts need "<ns>::<key>" ids
|
|
221
|
+
// (what `xliff export` writes); an id without one — or naming a namespace
|
|
222
|
+
// the source does not have — fails the whole import before anything is
|
|
223
|
+
// written, rather than guessing a file.
|
|
224
|
+
const byNs = new Map();
|
|
225
|
+
const unroutable = [];
|
|
226
|
+
const knownNs = new Set(layout.sourceFiles.map(f => f.ns));
|
|
227
|
+
for (const [id, value] of Object.entries(translations)) {
|
|
228
|
+
const parts = splitLockKey(layout, id);
|
|
229
|
+
if (!parts || (layout.namespaced && knownNs.size > 0 && !knownNs.has(parts.ns))) {
|
|
230
|
+
unroutable.push(id);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!byNs.has(parts.ns)) byNs.set(parts.ns, {});
|
|
234
|
+
byNs.get(parts.ns)[parts.key] = value;
|
|
235
|
+
}
|
|
236
|
+
if (unroutable.length > 0) {
|
|
237
|
+
// A gettext context key prints its U+0004 as "␄", as it was exported.
|
|
238
|
+
const sample = unroutable.slice(0, 5).map(id => id.replace(/\u0004/g, '\u2404')).join(', ');
|
|
239
|
+
output.error(
|
|
240
|
+
`${unroutable.length} XLIFF unit id(s) do not name one of this project's locale files: ${sample}`
|
|
241
|
+
+ `${unroutable.length > 5 ? ', …' : ''}. In a folder-per-locale project ids are "<namespace>::<key>" `
|
|
242
|
+
+ `(namespaces: ${[...knownNs].join(', ') || 'none found'}) — export with \`champollion xliff export\`.`);
|
|
243
|
+
return 1;
|
|
215
244
|
}
|
|
216
245
|
|
|
217
|
-
// Merge: XLIFF translations overwrite existing values
|
|
246
|
+
// Merge: XLIFF translations overwrite existing values, file by file.
|
|
218
247
|
let updated = 0;
|
|
219
248
|
let added = 0;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
249
|
+
const writes = [];
|
|
250
|
+
for (const [ns, entries] of byNs) {
|
|
251
|
+
const file = layout.fileFor(locale, ns);
|
|
252
|
+
const existingFlat = fs.existsSync(file.path) ? readLocaleFlat(file) : {};
|
|
253
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
254
|
+
if (key in existingFlat) {
|
|
255
|
+
if (existingFlat[key] !== value) {
|
|
256
|
+
updated++;
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
added++;
|
|
224
260
|
}
|
|
225
|
-
|
|
226
|
-
|
|
261
|
+
// A plural form new to the file goes beside its siblings in CLDR order.
|
|
262
|
+
assignInOrder(existingFlat, key, value);
|
|
227
263
|
}
|
|
228
|
-
existingFlat
|
|
264
|
+
writes.push({ ns, file, existingFlat });
|
|
229
265
|
}
|
|
230
266
|
|
|
231
267
|
const skipped = Object.keys(translations).length - updated - added;
|
|
268
|
+
// One file: report its path as before. Several: the layout's pattern.
|
|
269
|
+
const targetPath = writes.length === 1 ? writes[0].file.path : layout.filesFor(locale)[0]?.path || layout.baseDir;
|
|
270
|
+
const targetLabel = writes.length === 1
|
|
271
|
+
? path.relative(cwd, writes[0].file.path)
|
|
272
|
+
: `${writes.length} files (${writes.map(w => w.file.rel).join(', ')})`;
|
|
232
273
|
|
|
233
274
|
if (dryRun) {
|
|
234
275
|
if (args.json) {
|
|
235
276
|
console.log(JSON.stringify({
|
|
236
277
|
command: 'xliff', action: 'import', dryRun: true, locale, path: targetPath,
|
|
278
|
+
...(writes.length > 1 && { files: writes.map(w => w.file.path) }),
|
|
237
279
|
imported: Object.keys(translations).length, updated, added, unchanged: skipped,
|
|
238
280
|
}, null, 2));
|
|
239
281
|
return 0;
|
|
@@ -242,38 +284,48 @@ function runImport(args, cwd) {
|
|
|
242
284
|
output.raw(` Updated: ${updated} (changed from existing)`);
|
|
243
285
|
output.raw(` Added: ${added} (new keys)`);
|
|
244
286
|
output.raw(` Unchanged: ${skipped}`);
|
|
245
|
-
output.raw(` Target: ${
|
|
287
|
+
output.raw(` Target: ${targetLabel}\n`);
|
|
246
288
|
return 0;
|
|
247
289
|
}
|
|
248
290
|
|
|
249
291
|
// Write back
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
292
|
+
for (const { ns, file, existingFlat } of writes) {
|
|
293
|
+
fs.mkdirSync(path.dirname(file.path), { recursive: true });
|
|
294
|
+
if (file.format === 'json') {
|
|
295
|
+
// Re-nest the flat map into JSON structure using setNestedValue
|
|
296
|
+
const nested = {};
|
|
297
|
+
for (const [key, value] of Object.entries(existingFlat)) {
|
|
298
|
+
setNestedValue(nested, key, value);
|
|
299
|
+
}
|
|
300
|
+
fs.writeFileSync(file.path, JSON.stringify(nested, null, 2) + '\n', 'utf-8');
|
|
301
|
+
} else {
|
|
302
|
+
// TOML/YAML — write the merged flat map back through the exact same
|
|
303
|
+
// writer sync uses (lib/format.js writeLocaleFile), so an imported file
|
|
304
|
+
// keeps the serialization style of a synced project. YAML needs the
|
|
305
|
+
// style probe sync performs on the SOURCE locale file (Hugo plural
|
|
306
|
+
// sub-keys vs standard nesting); fall back to the target file when the
|
|
307
|
+
// source is missing.
|
|
308
|
+
let yamlStyle = null;
|
|
309
|
+
if (file.format === 'yaml') {
|
|
310
|
+
const sourcePath = layout.sourceFiles.find(f => f.ns === ns)?.path;
|
|
311
|
+
const stylePath = sourcePath && fs.existsSync(sourcePath) ? sourcePath
|
|
312
|
+
: (fs.existsSync(file.path) ? file.path : null);
|
|
313
|
+
yamlStyle = stylePath ? detectYAMLStyle(fs.readFileSync(stylePath, 'utf-8')) : null;
|
|
314
|
+
}
|
|
315
|
+
// Document formats (.po, .arb) are rebuilt from the SOURCE file's
|
|
316
|
+
// structure — plural entries, metadata, @@locale — not from the flat
|
|
317
|
+
// map alone (a brand-new target has no structure of its own).
|
|
318
|
+
writeLocaleFile(file.path, existingFlat, file.format, existingFlat, yamlStyle, {
|
|
319
|
+
sourcePath: file.sourcePath || null,
|
|
320
|
+
locale: file.code || null,
|
|
321
|
+
});
|
|
270
322
|
}
|
|
271
|
-
writeLocaleFile(targetPath, existingFlat, format, existingFlat, yamlStyle);
|
|
272
323
|
}
|
|
273
324
|
|
|
274
325
|
if (args.json) {
|
|
275
326
|
console.log(JSON.stringify({
|
|
276
327
|
command: 'xliff', action: 'import', dryRun: false, locale, path: targetPath,
|
|
328
|
+
...(writes.length > 1 && { files: writes.map(w => w.file.path) }),
|
|
277
329
|
imported: Object.keys(translations).length, updated, added, unchanged: skipped,
|
|
278
330
|
}, null, 2));
|
|
279
331
|
return 0;
|
|
@@ -283,11 +335,30 @@ function runImport(args, cwd) {
|
|
|
283
335
|
output.raw(` Updated: ${updated} (changed from existing)`);
|
|
284
336
|
output.raw(` Added: ${added} (new keys)`);
|
|
285
337
|
output.raw(` Unchanged: ${skipped}`);
|
|
286
|
-
output.raw(` Written to: ${
|
|
338
|
+
output.raw(` Written to: ${targetLabel}\n`);
|
|
287
339
|
|
|
288
340
|
return 0;
|
|
289
341
|
}
|
|
290
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Wrap a no-translate matcher so it receives the key WITHIN its file:
|
|
345
|
+
* patterns are written against a file's own keys ("**.url"), never against
|
|
346
|
+
* the "<ns>::" prefix the XLIFF ids carry.
|
|
347
|
+
*
|
|
348
|
+
* @param {import('../no-translate.js').NoTranslateMatcher} matcher
|
|
349
|
+
* @param {{ namespaced: boolean }} layout
|
|
350
|
+
*/
|
|
351
|
+
function namespacedMatcher(matcher, layout) {
|
|
352
|
+
if (!layout.namespaced) return matcher;
|
|
353
|
+
return {
|
|
354
|
+
...matcher,
|
|
355
|
+
matches: (id, value) => {
|
|
356
|
+
const parts = splitLockKey(layout, id);
|
|
357
|
+
return matcher.matches(parts ? parts.key : id, value);
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
291
362
|
// -----------------------------------------------------------------
|
|
292
363
|
// Usage
|
|
293
364
|
// -----------------------------------------------------------------
|
|
@@ -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 => {
|