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.
Files changed (142) hide show
  1. package/README.md +52 -37
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +51 -3
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +289 -88
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +649 -130
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +16 -10
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +197 -38
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +193 -106
  100. package/lib/seal.mjs +6 -5
  101. package/lib/sealed-qualifier.mjs +2 -2
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +3 -2
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/DATA-SOVEREIGNTY.md +19 -20
  123. package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
  124. package/shared/cards-fallback.json +1 -1
  125. package/shared/catalogue/card-config.json +1 -1
  126. package/shared/curated-orthography-conventions.json +26 -8
  127. package/shared/docent/faq.en.json +14 -16
  128. package/shared/docent/system-prompt.md +17 -19
  129. package/shared/explainers/tc-features.json +15 -15
  130. package/shared/gettext-plural-forms.json +45 -0
  131. package/shared/human-services.json +1 -1
  132. package/shared/method-registry.json +2 -0
  133. package/shared/metric-registry.json +96 -18
  134. package/shared/schemas/champollion-plugin.schema.json +4 -0
  135. package/shared/schemas/corpora-card.schema.json +20 -10
  136. package/shared/schemas/human-services.schema.json +2 -2
  137. package/shared/schemas/language-card.schema.json +1 -1
  138. package/shared/schemas/method-card.schema.json +1 -1
  139. package/shared/schemas/method-index-record.schema.json +67 -0
  140. package/shared/schemas/method-registry.schema.json +4 -0
  141. package/shared/schemas/metric-registry.schema.json +55 -1
  142. package/shared/docent/corpus.json +0 -11333
@@ -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 { flattenKeys, setNestedValue } from '../flatten.js';
24
- import { readLocaleFile, writeLocaleFile, detectFormatFromDir, detectYAMLStyle, getExtension } from '../format.js';
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
- const format = config.format !== 'auto'
79
- ? config.format
80
- : detectFormatFromDir(config.localesDir);
81
- const ext = getExtension(format);
82
-
83
- // Load source locale
84
- const sourcePath = path.join(config.localesDir, `${config.inputLocale}${ext}`);
85
- if (!fs.existsSync(sourcePath)) {
86
- output.error(`Source locale file not found: ${sourcePath}`);
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 sourceRaw = readLocaleFile(sourcePath, format);
90
- const sourceFlat = format === 'json' ? flattenKeys(sourceRaw) : { ...sourceRaw };
91
-
92
- // Load target locale (may not exist yet — that's fine, all targets will be empty)
93
- const targetPath = path.join(config.localesDir, `${locale}${ext}`);
94
- let targetFlat = {};
95
- if (fs.existsSync(targetPath)) {
96
- const targetRaw = readLocaleFile(targetPath, format);
97
- targetFlat = format === 'json' ? flattenKeys(targetRaw) : { ...targetRaw };
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: `${config.inputLocale}${ext}`,
107
- noTranslate: compileNoTranslate(config),
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 format = config.format !== 'auto'
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
- // Load existing target locale (or start fresh)
210
- let existingFlat = {};
211
- let existingRaw = {};
212
- if (fs.existsSync(targetPath)) {
213
- existingRaw = readLocaleFile(targetPath, format);
214
- existingFlat = format === 'json' ? flattenKeys(existingRaw) : { ...existingRaw };
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
- for (const [key, value] of Object.entries(translations)) {
221
- if (key in existingFlat) {
222
- if (existingFlat[key] !== value) {
223
- updated++;
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
- } else {
226
- added++;
261
+ // A plural form new to the file goes beside its siblings in CLDR order.
262
+ assignInOrder(existingFlat, key, value);
227
263
  }
228
- existingFlat[key] = value;
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: ${path.relative(cwd, targetPath)}\n`);
287
+ output.raw(` Target: ${targetLabel}\n`);
246
288
  return 0;
247
289
  }
248
290
 
249
291
  // Write back
250
- if (format === 'json') {
251
- // Re-nest the flat map into JSON structure using setNestedValue
252
- const nested = {};
253
- for (const [key, value] of Object.entries(existingFlat)) {
254
- setNestedValue(nested, key, value);
255
- }
256
- fs.writeFileSync(targetPath, JSON.stringify(nested, null, 2) + '\n', 'utf-8');
257
- } else {
258
- // TOML/YAML — write the merged flat map back through the exact same
259
- // writer sync uses (lib/format.js writeLocaleFile), so an imported file
260
- // keeps the serialization style of a synced project. YAML needs the
261
- // style probe sync performs on the SOURCE locale file (Hugo plural
262
- // sub-keys vs standard nesting); fall back to the target file when the
263
- // source is missing.
264
- let yamlStyle = null;
265
- if (format === 'yaml') {
266
- const sourcePath = path.join(config.localesDir, `${config.inputLocale}${ext}`);
267
- const stylePath = fs.existsSync(sourcePath) ? sourcePath
268
- : (fs.existsSync(targetPath) ? targetPath : null);
269
- yamlStyle = stylePath ? detectYAMLStyle(fs.readFileSync(stylePath, 'utf-8')) : null;
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: ${path.relative(cwd, targetPath)}\n`);
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
- contentDir: null, // Hugo content directory (e.g. './content'). null = disabled.
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
- console.warn(`[WARN] Unknown config field "${key}" — did you mean "${suggestion}"?`);
240
+ output.warn(`Unknown config field "${key}" — did you mean "${suggestion}"?`);
212
241
  } 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.`);
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) config.localesDir = cliArgs.dir;
257
- if (cliArgs.model) config.model = cliArgs.model;
258
- if (cliArgs.method) config.defaultMethod = cliArgs.method;
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 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
- : [];
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 (JSON, TOML, or YAML) that aren't the source file.
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
- // Supported locale file extensions
437
- const LOCALE_EXTS = ['.json', '.toml', '.yaml', '.yml'];
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 => {