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.
Files changed (132) hide show
  1. package/README.md +41 -26
  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 +34 -0
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +286 -85
  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 +632 -125
  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 +15 -9
  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 +194 -35
  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 +6 -1
  100. package/lib/seal.mjs +4 -3
  101. package/lib/sealed-qualifier.mjs +1 -1
  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 +1 -1
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/cards-fallback.json +1 -1
  123. package/shared/curated-orthography-conventions.json +26 -8
  124. package/shared/gettext-plural-forms.json +45 -0
  125. package/shared/method-registry.json +2 -0
  126. package/shared/metric-registry.json +96 -18
  127. package/shared/schemas/champollion-plugin.schema.json +4 -0
  128. package/shared/schemas/corpora-card.schema.json +8 -2
  129. package/shared/schemas/method-index-record.schema.json +67 -0
  130. package/shared/schemas/method-registry.schema.json +4 -0
  131. package/shared/schemas/metric-registry.schema.json +55 -1
  132. 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
- 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 => {
@@ -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
+ }