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
@@ -13,7 +13,7 @@
13
13
  * dataset · resource · method · human-service · external-result · card-correction
14
14
  *
15
15
  * Interactive wizard when stdin is a TTY; fully scriptable via flags otherwise
16
- * (--type + --values/--field + --attest, or --yes). See `champollion submit --help`.
16
+ * (--type + --values/--field + --attest, or --yes). See `champollion network submit --help`.
17
17
  *
18
18
  * Exit codes: 0 = URL produced (or cancelled); 1 = invalid/incomplete request.
19
19
  */
@@ -29,6 +29,7 @@ import {
29
29
  resolveType,
30
30
  repoBaseFromRepository,
31
31
  validateSubmission,
32
+ normalizeSubmissionValues,
32
33
  buildIssueUrl,
33
34
  buildSubmissionRecord,
34
35
  } from '../submit.mjs';
@@ -181,7 +182,7 @@ async function run(args, cwd) {
181
182
  return 0;
182
183
  }
183
184
  console.log('');
184
- console.log(' Submission types (champollion submit --type <key>):');
185
+ console.log(' Submission types (champollion network submit --type <key>):');
185
186
  console.log('');
186
187
  SUBMISSION_TYPES.forEach((t, i) => {
187
188
  console.log(` ${i + 1}. ${t.key} — ${t.label}`);
@@ -209,7 +210,7 @@ async function run(args, cwd) {
209
210
  } else {
210
211
  type = resolveType(args.type);
211
212
  if (!type) {
212
- output.error('Choose a submission type with --type (run `champollion submit --list`).');
213
+ output.error('Choose a submission type with --type (run `champollion network submit --list`).');
213
214
  return 1;
214
215
  }
215
216
  values = gatherFromFlags(type, args);
@@ -220,12 +221,16 @@ async function run(args, cwd) {
220
221
  if (!v.ok) {
221
222
  console.error('[ERR] Cannot build this submission:');
222
223
  for (const e of v.errors) console.error(` • ${e}`);
223
- console.error('');
224
- console.error(' The compliance attestation is required. Re-run and confirm it');
225
- console.error(' (interactively, or pass --attest with the other fields).');
224
+ if (v.errors.some((e) => e.startsWith('you must confirm'))) {
225
+ console.error('');
226
+ console.error(' The compliance attestation is required. Re-run and confirm it');
227
+ console.error(' (interactively, or pass --attest with the other fields).');
228
+ }
226
229
  return 1;
227
230
  }
228
231
 
232
+ // Language pairs go out one way: source>target, one per line (eng-crk in, eng>crk out).
233
+ values = normalizeSubmissionValues(type, values);
229
234
  const issueUrl = buildIssueUrl({ type, values, repoBase });
230
235
  const generatedAt = new Date().toISOString();
231
236
  const record = buildSubmissionRecord({ type, values, repoBase, generatedAt });
@@ -279,10 +284,10 @@ async function run(args, cwd) {
279
284
 
280
285
  function showHelp() {
281
286
  console.log(`
282
- champollion submit — Propose an entry for the Champollion index (review-gated)
287
+ champollion network submit — Propose an entry for the Champollion index (review-gated)
283
288
 
284
289
  USAGE
285
- champollion submit [options]
290
+ champollion network submit [options]
286
291
 
287
292
  DESCRIPTION
288
293
  Gather the fields for a submission and print a PRE-FILLED GitHub issue URL
@@ -316,15 +321,15 @@ function showHelp() {
316
321
  "${ATTESTATION_TEXT}"
317
322
 
318
323
  EXAMPLES
319
- champollion submit # interactive wizard
320
- champollion submit --list # see the types
324
+ champollion network submit # interactive wizard
325
+ champollion network submit --list # see the types
321
326
  # A dataset submission, fully from flags:
322
- champollion submit --yes --type dataset --attest \\
327
+ champollion network submit --yes --type dataset --attest \\
323
328
  --field dataset-name="GlobalVoices eng-amh" \\
324
329
  --field pairs=eng-amh --field license=CC-BY-4.0 \\
325
330
  --field source-url=https://globalvoices.org
326
331
  # An external result, with a saved local copy:
327
- champollion submit --yes --type external-result --attest --out ./submission.json \\
332
+ champollion network submit --yes --type external-result --attest --out ./submission.json \\
328
333
  --values '{"system-name":"NLLB-200","pairs":"eng-crk","dataset":"FLORES-200","metric":"chrF++","score":"28.4","citation":"https://arxiv.org/abs/2207.04672"}'
329
334
  `);
330
335
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { runSync } from '../sync.js';
10
+ import { shutdownMethods } from '../translate.js';
10
11
  import { output } from '../output.js';
11
12
 
12
13
  /**
@@ -20,8 +21,9 @@ import { output } from '../output.js';
20
21
  * totalProcessed > 0`) returned 0 here, so a `sync` wired into a
21
22
  * pre-deploy hook went green while silently shipping English
22
23
  * fallbacks. Any failure with zero progress must be loud.
23
- * 2 — partial: some keys translated but others failed, OR verification
24
- * found errors. The run did real work but isn't clean.
24
+ * 2 — partial: some keys translated but others failed (or were held
25
+ * back: refused before by the same method, so not re-sent), OR
26
+ * verification found errors. The run did real work but isn't clean.
25
27
  * ALSO used for a --max-cost abort: the pre-run estimate exceeded the
26
28
  * cap (or was unknowable — unknown ≠ free), so the sync deliberately
27
29
  * stopped BEFORE any API call. Not catastrophic (nothing broke,
@@ -29,7 +31,8 @@ import { output } from '../output.js';
29
31
  *
30
32
  * Pure and total (never throws) so it can be unit-tested directly.
31
33
  *
32
- * @param {{ totalProcessed?: number, totalFailed?: number, verifyErrors?: number, maxCostAborted?: boolean } | null | undefined} result
34
+ * @param {{ totalProcessed?: number, totalFailed?: number, totalHeld?: number, contentTranslated?: number, contentFailed?: number,
35
+ * contentHeldBack?: number, contentRefused?: number, verifyErrors?: number, maxCostAborted?: boolean } | null | undefined} result
33
36
  * @returns {number} exit code
34
37
  */
35
38
  function computeExitCode(result) {
@@ -38,16 +41,34 @@ function computeExitCode(result) {
38
41
  // --max-cost abort: deliberate pre-run stop, exit 2 by documented contract.
39
42
  if (result.maxCostAborted) return 2;
40
43
 
41
- const processed = result.totalProcessed || 0;
42
- const failed = result.totalFailed || 0;
44
+ // A key named for a redo that matches nothing (a typo, a msgid that only
45
+ // exists with a context): the repair asked for did not happen — exit 1,
46
+ // never the 0 of a run that "did" nothing.
47
+ if (Array.isArray(result.unmatchedKeys) && result.unmatchedKeys.length > 0) return 1;
48
+
49
+ // Content files count alongside keys: a run where every content file
50
+ // failed is a failure; one where some failed is partial.
51
+ const processed = (result.totalProcessed || 0) + (result.contentTranslated || 0);
52
+ const failed = (result.totalFailed || 0) + (result.contentFailed || 0);
43
53
  const verifyErrors = result.verifyErrors || 0;
54
+ // Keys held back (refused before by this method — lib/locale-state.js):
55
+ // not translated, so not a clean pass, but nothing failed anew. The same
56
+ // for Markdown blocks and front-matter fields (lib/content-refusals.js):
57
+ // held back, or refused by the quality gate this run and left as the
58
+ // '[EN] ' last resort.
59
+ // A plural message left without a form the language uses for ordinary
60
+ // counts (filled with the "other" form, marked in a gettext catalog) is
61
+ // incomplete in the same way.
62
+ const held = (result.totalHeld || 0) + (result.contentHeldBack || 0) + (result.contentRefused || 0)
63
+ + (result.totalPluralGaps || 0);
44
64
 
45
65
  // Catastrophic: failures with no successful work. A fully-failed sync
46
66
  // (bad key, every locale errored) must never exit 0.
47
67
  if (failed > 0 && processed === 0) return 1;
48
68
 
49
- // Partial: real work done but something failed, or files didn't verify.
50
- if (failed > 0 || verifyErrors > 0) return 2;
69
+ // Partial: real work done but something failed or was held back, or
70
+ // files didn't verify.
71
+ if (failed > 0 || held > 0 || verifyErrors > 0) return 2;
51
72
 
52
73
  return 0;
53
74
  }
@@ -81,6 +102,9 @@ async function run(args, cwd) {
81
102
  return 1;
82
103
  }
83
104
  throw err;
105
+ } finally {
106
+ // Stop the Python bridges external-method pairs started for this run.
107
+ await shutdownMethods();
84
108
  }
85
109
 
86
110
  return computeExitCode(result);
@@ -27,11 +27,11 @@
27
27
  import fs from 'node:fs';
28
28
  import path from 'node:path';
29
29
  import readline from 'node:readline';
30
- import { loadTM, saveTM, tmSize, pruneTM, TM_DIR, TM_FILENAME } from '../tm.js';
30
+ import { loadTM, saveTM, tmSize, pruneTM, describeMethodKey, TM_DIR, TM_FILENAME } from '../tm.js';
31
31
  import { resolveConfig } from '../config.js';
32
32
  import { resolveRuntime } from '../sync.js';
33
33
  import { seedTMFromExisting } from '../tm-seed.js';
34
- import { output } from '../output.js';
34
+ import { output, localTimestamp } from '../output.js';
35
35
 
36
36
  /**
37
37
  * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
@@ -220,6 +220,8 @@ function runStats(args, cwd) {
220
220
 
221
221
  // Timestamps from metadata
222
222
  const created = tm._meta?.created || 'unknown';
223
+ // JSON keeps the stored (UTC) date for compatibility, plus the full ISO
224
+ // timestamps; the human report prints local time with the zone named.
223
225
  const createdDate = created !== 'unknown' ? created.split('T')[0] : 'unknown';
224
226
 
225
227
  // Find newest entry timestamp
@@ -277,6 +279,8 @@ function runStats(args, cwd) {
277
279
  sizeBytes: stat.size,
278
280
  created: createdDate,
279
281
  lastEntry: newestDate,
282
+ createdAt: created !== 'unknown' ? created : null,
283
+ lastEntryAt: newest || null,
280
284
  byLocale,
281
285
  unknownLocale: unknownCount,
282
286
  }, null, 2));
@@ -286,18 +290,20 @@ function runStats(args, cwd) {
286
290
  output.raw('\n Translation Memory — .champollion/tm.json\n');
287
291
  output.raw(` Entries: ${entryCount.toLocaleString()}`);
288
292
  output.raw(` File size: ${sizeStr}`);
289
- output.raw(` Created: ${createdDate}`);
290
- output.raw(` Last entry: ${newestDate}`);
293
+ output.raw(` Created: ${created !== 'unknown' ? localTimestamp(created) : 'unknown'}`);
294
+ output.raw(` Last entry: ${newest ? localTimestamp(newest) : 'unknown'}`);
291
295
 
292
296
  if (sortedLocales.length > 0 || unknownCount > 0) {
293
297
  output.raw('\n By locale:');
294
298
 
299
+ // One line per locale, then what made its entries — method, model and
300
+ // register in words (the raw cache key "local|stub-1|formal-vous|" was
301
+ // printed here before; lib/tm.js describeMethodKey).
295
302
  for (const [locale, stats] of sortedLocales) {
296
- const methodParts = Object.entries(stats.methods)
297
- .sort((a, b) => b[1] - a[1])
298
- .map(([m, count]) => `${m}: ${count}`);
299
- const methodStr = methodParts.length > 0 ? ` (${methodParts.join(', ')})` : '';
300
- output.raw(` ${locale.padEnd(6)} ${String(stats.total).padStart(5)} entries${methodStr}`);
303
+ output.raw(` ${locale.padEnd(6)} ${String(stats.total).padStart(5)} entries`);
304
+ for (const [m, count] of Object.entries(stats.methods).sort((a, b) => b[1] - a[1])) {
305
+ output.raw(` ${''.padEnd(6)} ${String(count).padStart(5)} ${describeMethodKey(m)}`);
306
+ }
301
307
  }
302
308
 
303
309
  if (unknownCount > 0) {
@@ -438,7 +444,7 @@ async function runPrune(args, cwd) {
438
444
  }
439
445
  try {
440
446
  // Case-sensitive, no flags: banned-term patterns are usually
441
- // case-shaped ("OCAP" vs a word that merely contains "ocap").
447
+ // case-shaped ("NASA" vs a word that merely contains "nasa").
442
448
  // Encode any case-insensitivity in the pattern itself ([Cc]…).
443
449
  matching = new RegExp(matchingRaw);
444
450
  } catch (err) {
@@ -6,14 +6,21 @@
6
6
  * success and keys being wrong in fact.
7
7
  *
8
8
  * Exit code 1 if errors found (CI-gate compatible).
9
- * --warn-only exits 0 regardless.
9
+ * --strict exits 1 on warnings too (a source echo, a plural form the
10
+ * translation did not supply, two locales with identical text, a
11
+ * translation made from an older source text) — for a CI that must not pass
12
+ * them. --warn-only exits 0 regardless.
10
13
  *
11
14
  * This runs the same checks that sync's post-sync verification uses,
12
15
  * but can be invoked independently for CI pipelines or manual auditing.
16
+ *
17
+ * --pair en:fr (comma-separated) verifies only those pairs' locales — the
18
+ * same scoping `sync --pair` gives its post-sync verification. An unknown
19
+ * pair fails loud, exactly as it does for sync.
13
20
  */
14
21
 
15
22
  import { resolveConfig } from '../config.js';
16
- import { verifyLocales } from '../verify.js';
23
+ import { verifyLocales, localesForPairFlag } from '../verify.js';
17
24
  import { output } from '../output.js';
18
25
 
19
26
  /**
@@ -27,12 +34,29 @@ async function run(args, cwd) {
27
34
  if (args.json) output.setMode('json');
28
35
  else if (args.quiet) output.setMode('quiet');
29
36
 
37
+ if (args.strict && args['warn-only']) {
38
+ output.error('--strict (fail on warnings) and --warn-only (never fail) contradict each other — pick one.');
39
+ return 1;
40
+ }
30
41
  const config = resolveConfig(args, cwd);
31
- const { errors } = await verifyLocales(config, cwd);
42
+ let locales = null;
43
+ if (args.pair) {
44
+ try {
45
+ locales = localesForPairFlag(config, args.pair, { cwd });
46
+ } catch (err) {
47
+ output.error(err.message);
48
+ return 1;
49
+ }
50
+ }
51
+ // --strict: the summary line itself says a warning fails the check.
52
+ const { errors, warnings } = await verifyLocales(config, cwd, { locales, strict: !!args.strict && !args['warn-only'] });
32
53
 
33
54
  if (errors > 0 && !args['warn-only']) {
34
55
  return 1;
35
56
  }
57
+ if (args.strict && warnings > 0) {
58
+ return 1;
59
+ }
36
60
  return 0;
37
61
  }
38
62
 
@@ -13,11 +13,12 @@
13
13
 
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
- import { resolveConfig } from '../config.js';
16
+ import { resolveConfig, autoDetectLanguages } from '../config.js';
17
+ import { discoverLocaleLayout } from '../locale-layout.js';
17
18
  import { detectFramework, walkDir } from '../lint.js';
18
19
  import {
19
20
  checkGitClean, createBackup, restoreFromBackup,
20
- processFile, generateDiff, addKeysToLocales,
21
+ processFile, generateDiff, addKeysToLocales, wrapUnsupportedReason,
21
22
  } from '../autofix.js';
22
23
  import { output } from '../output.js';
23
24
 
@@ -54,6 +55,15 @@ async function run(args, cwd) {
54
55
  const framework = detectFramework(cwd);
55
56
  const minLength = parseInt(args['min-length'] || 2, 10);
56
57
 
58
+ // Decide WHERE extracted keys go before any source file is rewritten — a
59
+ // wrap that rewrote components to t('…') and then could not store the
60
+ // keys would leave the app showing raw key names.
61
+ const destination = resolveWrapDestination(config, cwd);
62
+ if (destination.error) {
63
+ output.error(destination.error);
64
+ return 1;
65
+ }
66
+
57
67
  output.raw(`\n champollion wrap${isDry ? ' (dry run)' : ''}`);
58
68
  output.raw(` Framework: ${framework.name}`);
59
69
  output.raw('');
@@ -117,9 +127,11 @@ async function run(args, cwd) {
117
127
 
118
128
  // Add extracted keys to locale files (only if not dry-run)
119
129
  if (!isDry && allFixes.length > 0) {
120
- const targetLocales = config.languages || [];
121
- addKeysToLocales(allFixes, config.localesDir, config.inputLocale, targetLocales);
122
- output.info(`Added ${allFixes.length} key(s) to locale files`);
130
+ const written = addKeysToLocales(allFixes, destination.source, destination.targets);
131
+ output.info(`Added ${allFixes.length} key(s) to ${destination.source.rel}`
132
+ + (written.targets > 0 ? ` and ${written.targets} target file(s) (as [EN] placeholders for the next sync)` : ''));
133
+ } else if (isDry && allFixes.length > 0) {
134
+ output.info(`Would add ${allFixes.length} key(s) to ${destination.source.rel}`);
123
135
  }
124
136
 
125
137
  output.raw('');
@@ -135,4 +147,50 @@ async function run(args, cwd) {
135
147
  return 0;
136
148
  }
137
149
 
150
+ /**
151
+ * The locale files `wrap` writes extracted keys into: the source file (one
152
+ * per flat project; the `defaultNamespace` file — or the only file — of a
153
+ * folder-per-locale project) and the matching file of every configured
154
+ * target locale.
155
+ *
156
+ * @param {object} config - Resolved config
157
+ * @param {string} cwd
158
+ * @returns {{ source?: object, targets?: object[], error?: string }}
159
+ */
160
+ function resolveWrapDestination(config, cwd) {
161
+ if (config.format === 'docusaurus') {
162
+ return { error: 'wrap writes t() keys into key-value locale files; Docusaurus translates through <Translate> and `docusaurus write-translations` — wrap does not apply.' };
163
+ }
164
+ const layout = discoverLocaleLayout(config, { cwd });
165
+ let ns = '';
166
+ if (layout.namespaced) {
167
+ const namespaces = layout.sourceFiles.map(f => f.ns);
168
+ if (config.defaultNamespace) {
169
+ if (!namespaces.includes(config.defaultNamespace)) {
170
+ return { error: `"defaultNamespace" is "${config.defaultNamespace}", but the source locale has no such file (namespaces: ${namespaces.join(', ') || 'none'}).` };
171
+ }
172
+ ns = config.defaultNamespace;
173
+ } else if (namespaces.length === 1) {
174
+ ns = namespaces[0];
175
+ } else {
176
+ return { error: `This project has ${namespaces.length} namespace files (${namespaces.join(', ') || 'none'}). `
177
+ + 'Set "defaultNamespace" in champollion.config.json to choose the file wrap adds keys to.' };
178
+ }
179
+ }
180
+ const source = layout.sourceFiles.find(f => f.ns === ns) || layout.sourceFiles[0];
181
+ // Flutter ARB and gettext catalogs cannot hold t('dotted.key') keys —
182
+ // refuse before any component is rewritten.
183
+ const refusal = source ? wrapUnsupportedReason(source) : null;
184
+ if (refusal) return { error: refusal };
185
+ if (!source || !fs.existsSync(source.path)) {
186
+ return { error: `Source locale file not found: ${source ? source.path : layout.display} — wrap needs it to store the extracted keys.` };
187
+ }
188
+ // Configured targets, or — like sync — the ones on disk when none are.
189
+ const languages = Object.keys(config.resolvedLanguages || {}).length > 0
190
+ ? config.resolvedLanguages
191
+ : autoDetectLanguages(config);
192
+ const targetCodes = Object.keys(languages).filter(c => c !== config.inputLocale);
193
+ return { source, targets: targetCodes.map(code => layout.fileFor(code, ns)) };
194
+ }
195
+
138
196
  export { run };