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
@@ -30,11 +30,19 @@ import fs from 'node:fs';
30
30
  import path from 'node:path';
31
31
  import readline from 'node:readline';
32
32
  import { CONFIG_FILENAMES, DEFAULT_OPENROUTER_MODEL, DEFAULT_BATCH_SIZE, DEFAULT_TEMPERATURE, DEFAULT_COACHED_TEMPERATURE, detectDocusaurus } from '../config.js';
33
- import { DEFAULT_REGISTERS, getLanguageCard, getRegisterPresets, getMethodSupport } from '../registers.js';
33
+ import { DEFAULT_REGISTERS, getLanguageCard, getRegisterPresets, isMethodSupported, resolveCode, summarizeGenderGuidance, isPrivateUseCode } from '../registers.js';
34
34
  import { getConverterInfo, resolveTargetScript, converterKeyForLocale } from '../scripts.js';
35
35
  import { fetchAvailableModels, resolveProviderApiKey, isListableProvider, getProviderLabel } from '../models.js';
36
36
  import { showCommandHelp } from '../command-help.js';
37
37
  import { output } from '../output.js';
38
+ import { flutterLocaleLines } from '../flutter-locales.js';
39
+ import { detectFramework } from '../lint.js';
40
+ import { LOCALE_FILE_FORMATS } from '../format.js';
41
+ import {
42
+ discoverLocaleLayout, createMissingTargetFiles, formatForExtension, walkLocaleFiles,
43
+ compileLocalesPattern, detectFlutterL10n, detectGettextLayout,
44
+ } from '../locale-layout.js';
45
+ import { findLocalOnlyMarks } from '../local-only-marks.js';
38
46
 
39
47
  const DEFAULT_CONFIG_FILENAME = CONFIG_FILENAMES[0]; // champollion.config.json
40
48
 
@@ -63,6 +71,7 @@ const METHOD_OPTIONS = [
63
71
  envVar: 'OPENROUTER_API_KEY',
64
72
  isLLM: true,
65
73
  category: 'llm',
74
+ sendsTo: 'OpenRouter, a hosted service, which passes them to the model\'s provider',
66
75
  },
67
76
  {
68
77
  method: 'openai',
@@ -71,6 +80,7 @@ const METHOD_OPTIONS = [
71
80
  envVar: 'OPENAI_API_KEY',
72
81
  isLLM: true,
73
82
  category: 'llm',
83
+ sendsTo: 'OpenAI\'s hosted API',
74
84
  },
75
85
  {
76
86
  method: 'anthropic',
@@ -79,6 +89,7 @@ const METHOD_OPTIONS = [
79
89
  envVar: 'ANTHROPIC_API_KEY',
80
90
  isLLM: true,
81
91
  category: 'llm',
92
+ sendsTo: 'Anthropic\'s hosted API',
82
93
  },
83
94
  {
84
95
  method: 'gemini',
@@ -87,6 +98,22 @@ const METHOD_OPTIONS = [
87
98
  envVar: 'GEMINI_API_KEY',
88
99
  isLLM: true,
89
100
  category: 'llm',
101
+ sendsTo: 'Google\'s hosted Gemini API',
102
+ },
103
+ {
104
+ // A model on this machine (or your own server) behind an
105
+ // OpenAI-compatible endpoint: Ollama, vLLM, LM Studio, llama.cpp — or a
106
+ // model you trained with nmt-forge (`nmt-forge serve`). It worked in sync
107
+ // all along, but init refused it, so the one privacy-preserving setup
108
+ // could not be configured (synthetic hospital persona, 2026-10-03).
109
+ method: 'local',
110
+ label: 'Local / self-hosted model',
111
+ desc: 'Ollama, vLLM, LM Studio, or your own trained model. Text never leaves your machine.',
112
+ envVar: 'LOCAL_API_BASE',
113
+ envOptional: true,
114
+ envExample: 'http://localhost:11434/v1 # default (Ollama)',
115
+ isLLM: true,
116
+ category: 'llm',
90
117
  },
91
118
  {
92
119
  method: 'deepl',
@@ -95,6 +122,7 @@ const METHOD_OPTIONS = [
95
122
  envVar: 'DEEPL_API_KEY',
96
123
  isLLM: false,
97
124
  category: 'api',
125
+ sendsTo: 'DeepL\'s hosted API',
98
126
  },
99
127
  {
100
128
  method: 'microsoft-translator',
@@ -103,6 +131,7 @@ const METHOD_OPTIONS = [
103
131
  envVar: 'MICROSOFT_TRANSLATOR_API_KEY',
104
132
  isLLM: false,
105
133
  category: 'api',
134
+ sendsTo: 'Microsoft\'s hosted Translator API',
106
135
  },
107
136
  {
108
137
  method: 'libretranslate',
@@ -111,6 +140,7 @@ const METHOD_OPTIONS = [
111
140
  envVar: 'LIBRETRANSLATE_API_URL',
112
141
  isLLM: false,
113
142
  category: 'api',
143
+ sendsTo: 'the LibreTranslate server LIBRETRANSLATE_API_URL names (on this machine only if that URL is)',
114
144
  },
115
145
  {
116
146
  method: 'google-translate',
@@ -119,9 +149,409 @@ const METHOD_OPTIONS = [
119
149
  envVar: 'GOOGLE_TRANSLATE_API_KEY',
120
150
  isLLM: false,
121
151
  category: 'api',
152
+ sendsTo: 'Google\'s hosted Cloud Translation API',
122
153
  },
123
154
  ];
124
155
 
156
+ /**
157
+ * `--method api`: a server speaking the champollion API contract — a model
158
+ * you trained, served by `nmt-forge serve`, or any hosted endpoint. Flags
159
+ * only (`--endpoint`), not a wizard choice: the endpoint is per pair, and the
160
+ * persona who needed it hand-wrote the pair config from forge's DEPLOY.md
161
+ * (Round 5, hospital persona). init writes the same pair entries DEPLOY.md
162
+ * shows: { "method": "api", "endpoint": …, "acceptsInstructions": … }.
163
+ */
164
+ const API_METHOD = 'api';
165
+ const API_KEY_ENV = 'CHAMPOLLION_API_KEY';
166
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
167
+
168
+ /**
169
+ * Whether the endpoint follows per-key instructions: the flag, else an
170
+ * installed plugin manifest for the same endpoint (`champollion plugin
171
+ * install` copies forge's plugin/method.json to .champollion/methods/), else
172
+ * unknown (null — the CLI then sends the text alone, as for false).
173
+ *
174
+ * @param {object} args
175
+ * @param {string} cwd
176
+ * @returns {{ value: boolean|null, from: string|null }}
177
+ */
178
+ function resolveAcceptsInstructions(args, cwd) {
179
+ if (args['accepts-instructions'] != null) {
180
+ return { value: String(args['accepts-instructions']) === 'true', from: '--accepts-instructions' };
181
+ }
182
+ const dir = path.join(cwd, '.champollion', 'methods');
183
+ let names = [];
184
+ try { names = fs.readdirSync(dir); } catch { return { value: null, from: null }; }
185
+ const want = String(args.endpoint).replace(/\/+$/, '');
186
+ for (const name of names.sort()) {
187
+ let manifest = null;
188
+ try { manifest = JSON.parse(fs.readFileSync(path.join(dir, name, 'method.json'), 'utf-8')); } catch { continue; }
189
+ if (!manifest || typeof manifest.endpoint !== 'string' || manifest.endpoint.replace(/\/+$/, '') !== want) continue;
190
+ if (typeof manifest.acceptsInstructions === 'boolean') {
191
+ return { value: manifest.acceptsInstructions, from: `.champollion/methods/${name}/method.json` };
192
+ }
193
+ }
194
+ return { value: null, from: null };
195
+ }
196
+
197
+ /**
198
+ * One pair entry per target for `--method api`, exactly DEPLOY.md's shape.
199
+ *
200
+ * @param {object} config - Mutated: `pairs` gains `<source>:<target>` entries
201
+ * @param {string[]} targets
202
+ * @param {string} endpoint
203
+ * @param {boolean|null} acceptsInstructions
204
+ */
205
+ function writeApiPairs(config, targets, endpoint, acceptsInstructions) {
206
+ config.pairs = config.pairs || {};
207
+ for (const code of targets) {
208
+ config.pairs[`${config.inputLocale}:${code}`] = {
209
+ method: API_METHOD,
210
+ endpoint,
211
+ ...(typeof acceptsInstructions === 'boolean' && { acceptsInstructions }),
212
+ };
213
+ }
214
+ }
215
+
216
+ // -----------------------------------------------------------------
217
+ // Locale layout detection
218
+ // -----------------------------------------------------------------
219
+
220
+ /**
221
+ * Where each framework keeps its locale files, keyed by the framework name
222
+ * lib/lint.js detectFramework() reports. Probed IN ORDER and only trusted
223
+ * when the source locale's file is actually on disk — a framework's
224
+ * convention is a hint, never an answer.
225
+ *
226
+ * WHY: two synthetic users failed their first sync here. A next-intl app
227
+ * (messages/en.json) got localesDir "./locales"; an i18next app
228
+ * (public/locales/en/common.json — one folder per language, one file per
229
+ * namespace) had no supported layout at all.
230
+ *
231
+ * Docusaurus is not listed: it has its own lane (format "docusaurus",
232
+ * i18n/<locale>/), handled before these probes.
233
+ */
234
+ const FRAMEWORK_LAYOUTS = {
235
+ 'next-intl': [
236
+ { dir: 'messages', layout: 'flat', shape: 'messages/{lang}.json' },
237
+ // App Router projects keep them under app/ or src/ (Round 3, school persona).
238
+ { dir: 'app/messages', layout: 'flat', shape: 'app/messages/{lang}.json' },
239
+ { dir: 'src/messages', layout: 'flat', shape: 'src/messages/{lang}.json' },
240
+ ],
241
+ 'react-i18next': [
242
+ { dir: 'public/locales', layout: 'dir', shape: 'public/locales/{lang}/{ns}.json' },
243
+ { dir: 'locales', layout: 'dir', shape: 'locales/{lang}/{ns}.json' },
244
+ ],
245
+ 'vue-i18n': [
246
+ { dir: 'src/locales', layout: 'flat', shape: 'src/locales/{lang}.json' },
247
+ ],
248
+ Hugo: [
249
+ { dir: 'i18n', layout: 'flat', formats: ['toml', 'yaml'], shape: 'i18n/{lang}.toml|yaml' },
250
+ ],
251
+ };
252
+
253
+ /**
254
+ * Framework-independent places projects keep locale files, probed after the
255
+ * framework's own (each for both one-file-per-locale and folder-per-locale).
256
+ */
257
+ const GENERIC_LOCALE_DIRS = [
258
+ 'locales', 'messages', 'i18n', 'lang', 'translations', 'public/locales', 'src/locales', 'src/i18n',
259
+ 'app/messages', 'src/messages', 'app/locales', 'app/i18n',
260
+ ];
261
+
262
+ /**
263
+ * When none of the named folders holds the source, init looks one or two
264
+ * levels down for a folder with one of these names (apps/web/messages,
265
+ * frontend/src/locales, …): a project whose locale files are not at the
266
+ * root was configured with a localesDir pointing nowhere and hand-edited
267
+ * (Round 3, school persona).
268
+ */
269
+ const LOCALE_DIR_NAMES = new Set(['locales', 'locale', 'messages', 'i18n', 'lang', 'translations', 'l10n']);
270
+
271
+ /** Folders a project search never descends into. */
272
+ const SKIP_DIRS = new Set([
273
+ 'node_modules', 'vendor', 'dist', 'build', 'out', 'coverage', 'target', 'venv', 'env',
274
+ '__pycache__', 'Pods', 'DerivedData', 'tmp', 'temp',
275
+ ]);
276
+
277
+ /**
278
+ * Folders at depth 1–2 (relative to cwd) whose name says "locales" —
279
+ * the bounded fallback probe. Hidden folders and SKIP_DIRS are not searched.
280
+ *
281
+ * @param {string} cwd
282
+ * @returns {string[]} project-relative paths, shallowest first
283
+ */
284
+ function nestedLocaleDirs(cwd) {
285
+ const out = [];
286
+ const list = (abs) => {
287
+ try { return fs.readdirSync(abs, { withFileTypes: true }).filter(e => e.isDirectory()); } catch { return []; }
288
+ };
289
+ const visible = (e) => !e.name.startsWith('.') && !SKIP_DIRS.has(e.name);
290
+ for (const a of list(cwd).filter(visible)) {
291
+ for (const b of list(path.join(cwd, a.name)).filter(visible)) {
292
+ if (LOCALE_DIR_NAMES.has(b.name)) out.push(`${a.name}/${b.name}`);
293
+ }
294
+ }
295
+ for (const a of list(cwd).filter(visible)) {
296
+ for (const b of list(path.join(cwd, a.name)).filter(visible)) {
297
+ for (const c of list(path.join(cwd, a.name, b.name)).filter(visible)) {
298
+ if (LOCALE_DIR_NAMES.has(c.name)) out.push(`${a.name}/${b.name}/${c.name}`);
299
+ }
300
+ }
301
+ }
302
+ return out;
303
+ }
304
+
305
+ /** Markdown files that are project paperwork, not content to translate. */
306
+ const PAPERWORK_MD = /^(readme|changelog|license|licence|contributing|code_of_conduct|security|authors|history|notice)(\.[a-z-]+)?\.mdx?$/i;
307
+
308
+ /**
309
+ * Folders (depth 1–2) holding Markdown/MDX content — what init SUGGESTS for
310
+ * --content-dir. Never enabled on its own: translating a folder of pages is
311
+ * billed work the user chooses (Round 3, school persona's newsletter/).
312
+ *
313
+ * @param {string} cwd
314
+ * @param {string[]} [exclude] - absolute paths not to suggest (the locales folder)
315
+ * @returns {Array<{ dir: string, files: number }>} most files first, at most 2
316
+ */
317
+ function suggestContentDirs(cwd, exclude = []) {
318
+ const found = [];
319
+ const visible = (e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name);
320
+ const countMd = (abs, depth) => {
321
+ let n = 0;
322
+ let entries;
323
+ try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return 0; }
324
+ for (const e of entries) {
325
+ if (e.isFile() && /\.mdx?$/i.test(e.name) && !PAPERWORK_MD.test(e.name)) n++;
326
+ else if (depth > 0 && visible(e)) n += countMd(path.join(abs, e.name), depth - 1);
327
+ }
328
+ return n;
329
+ };
330
+ let top;
331
+ try { top = fs.readdirSync(cwd, { withFileTypes: true }).filter(visible); } catch { return []; }
332
+ for (const e of top) {
333
+ const abs = path.join(cwd, e.name);
334
+ if (exclude.some(x => abs === x || x.startsWith(abs + path.sep) || abs.startsWith(x + path.sep))) continue;
335
+ const files = countMd(abs, 2);
336
+ if (files > 0) found.push({ dir: e.name, files });
337
+ }
338
+ return found.sort((a, b) => b.files - a.files || a.dir.localeCompare(b.dir)).slice(0, 2);
339
+ }
340
+
341
+ /** "./messages" — the form written into champollion.config.json. */
342
+ function configPath(cwd, abs) {
343
+ const rel = path.relative(cwd, abs).split(path.sep).join('/');
344
+ return rel ? `./${rel}` : '.';
345
+ }
346
+ const projectRelative = configPath;
347
+
348
+ /**
349
+ * Flutter and gettext projects, found BEFORE the folder probes: their
350
+ * layouts are patterns (lib/l10n/app_{lang}.arb,
351
+ * locale/{lang}/LC_MESSAGES/{ns}.po) that a "<dir>/<lang>.<ext>" probe
352
+ * cannot express — a Django project's locale/ is not even on the generic
353
+ * list. The detection itself lives with the layouts (lib/locale-layout.js
354
+ * detectFlutterL10n / detectGettextLayout); this turns it into init's
355
+ * `found` shape, or records why a project that IS Flutter/gettext was not
356
+ * configured (no template / no source catalog) so init can say so.
357
+ *
358
+ * @param {string} cwd
359
+ * @param {string} source - Source locale code
360
+ * @param {object} result - detectLocaleSetup's result (framework + hints are set on it)
361
+ * @returns {object|null} `found`
362
+ */
363
+ function detectDocumentLayout(cwd, source, result) {
364
+ const flutter = detectFlutterL10n(cwd);
365
+ if (flutter) {
366
+ result.framework = 'Flutter';
367
+ if (flutter.templateExists) {
368
+ const base = flutter.localesPattern.slice(0, flutter.localesPattern.lastIndexOf('/'));
369
+ return {
370
+ localesDir: `./${base}`,
371
+ localesPattern: flutter.localesPattern,
372
+ inputLocale: flutter.inputLocale,
373
+ layout: 'pattern',
374
+ format: 'arb',
375
+ shape: flutter.localesPattern,
376
+ why: flutter.l10nYaml ? 'Flutter project (l10n.yaml)' : 'Flutter project (gen-l10n defaults)',
377
+ targets: flutter.targets,
378
+ sourceFiles: [flutter.templateFile],
379
+ ambiguous: false,
380
+ };
381
+ }
382
+ result.hints.push(
383
+ `Flutter project, but its gen-l10n template ${flutter.templateFile} does not exist — create it`
384
+ + ' (or set template-arb-file in l10n.yaml) and re-run `champollion init --force`.');
385
+ }
386
+
387
+ const gettext = detectGettextLayout(cwd, { source });
388
+ if (gettext) {
389
+ result.framework = gettext.framework;
390
+ const base = gettext.localesPattern
391
+ ? gettext.localesPattern.slice(0, gettext.localesPattern.indexOf('/{lang}'))
392
+ : gettext.localesDir.replace(/^\.\//, '');
393
+ if (gettext.sourceFiles.length > 0) {
394
+ return {
395
+ localesDir: `./${base}`,
396
+ ...(gettext.localesPattern && { localesPattern: gettext.localesPattern }),
397
+ layout: gettext.localesPattern ? 'pattern' : 'flat',
398
+ format: 'po',
399
+ shape: gettext.localesPattern || `${base}/{lang}.po`,
400
+ why: `${gettext.framework} project`,
401
+ targets: gettext.targets,
402
+ sourceFiles: gettext.sourceFiles,
403
+ ambiguous: false,
404
+ };
405
+ }
406
+ const shape = gettext.localesPattern || `${base}/{lang}.po`;
407
+ const make = gettext.framework === 'Django' ? `django-admin makemessages -l ${source}`
408
+ : (gettext.framework === 'Babel' ? `pybabel extract -o ${base}/messages.pot .` : `msginit --locale=${source}`);
409
+ result.hints.push(
410
+ `gettext catalogs found (${shape}${gettext.targets.length > 0 ? `: ${gettext.targets.join(', ')}` : ''}) but no `
411
+ + `"${source}" source catalog or .pot template — create one (\`${make}\`) and re-run \`champollion init --force\`.`);
412
+ }
413
+ return null;
414
+ }
415
+
416
+ /**
417
+ * Look for the project's locale files on disk.
418
+ *
419
+ * Probes the detected framework's conventional directories first, then the
420
+ * generic ones (or ONLY `dir` when the user named one). A probe matches when
421
+ * the SOURCE locale exists there: `<dir>/<source>.<ext>` (flat) or a
422
+ * `<dir>/<source>/` folder holding locale files (folder per locale). Near
423
+ * misses — a locale directory without the source file — are reported so the
424
+ * user can fix --source instead of guessing.
425
+ *
426
+ * @param {string} cwd - Project root
427
+ * @param {{ source?: string, dir?: string|null }} [options]
428
+ * @returns {{
429
+ * framework: string,
430
+ * docusaurus: boolean,
431
+ * found: null | { localesDir: string, layout: 'flat'|'dir'|'pattern', format: string,
432
+ * shape: string, why: string, targets: string[], sourceFiles: string[],
433
+ * ambiguous: boolean, localesPattern?: string, inputLocale?: string },
434
+ * nearMisses: Array<{ localesDir: string, locales: string[] }>,
435
+ * hints: string[],
436
+ * }} `localesPattern` (Flutter, gettext) is what the config should carry
437
+ * instead of localesDir; `inputLocale` is the source a Flutter template
438
+ * names (app_en.arb → en)
439
+ */
440
+ function detectLocaleSetup(cwd, { source = 'en', dir = null } = {}) {
441
+ const framework = detectFramework(cwd);
442
+ const result = {
443
+ framework: framework.name, docusaurus: framework.name === 'Docusaurus', found: null, nearMisses: [], hints: [],
444
+ };
445
+ if (result.docusaurus && !dir) return result;
446
+
447
+ // Flutter (.arb) and gettext (.po) first — an explicit --dir names a
448
+ // folder, so it is probed on its own instead.
449
+ if (!dir) {
450
+ const found = detectDocumentLayout(cwd, source, result);
451
+ if (found) {
452
+ result.found = found;
453
+ return result;
454
+ }
455
+ }
456
+
457
+ const probes = [];
458
+ const seen = new Set();
459
+ const add = (probe, why) => {
460
+ const abs = path.resolve(cwd, probe.dir);
461
+ if (seen.has(abs)) return;
462
+ seen.add(abs);
463
+ probes.push({ ...probe, abs, why });
464
+ };
465
+ if (dir) {
466
+ add({ dir }, '--dir');
467
+ } else {
468
+ for (const probe of FRAMEWORK_LAYOUTS[framework.name] || []) {
469
+ add(probe, `${framework.name} project`);
470
+ }
471
+ for (const d of GENERIC_LOCALE_DIRS) add({ dir: d }, 'common locale directory');
472
+ // Last: a folder named like a locale folder one or two levels down.
473
+ for (const d of nestedLocaleDirs(cwd)) add({ dir: d }, `locale folder found at ${d}/`);
474
+ }
475
+
476
+ for (const probe of probes) {
477
+ let isDir = false;
478
+ try { isDir = fs.statSync(probe.abs).isDirectory(); } catch { /* absent */ }
479
+ if (!isDir) continue;
480
+
481
+ const allowed = (ext) => {
482
+ const fmt = formatForExtension(ext);
483
+ return fmt && (!probe.formats || probe.formats.includes(fmt));
484
+ };
485
+ const flatSource = fs.readdirSync(probe.abs)
486
+ .filter(f => f.startsWith(`${source}.`) && path.basename(f, path.extname(f)) === source && allowed(path.extname(f)));
487
+ let folderFiles = [];
488
+ try {
489
+ if (fs.statSync(path.join(probe.abs, source)).isDirectory()) {
490
+ folderFiles = walkLocaleFiles(path.join(probe.abs, source)).filter(f => allowed(path.extname(f)));
491
+ }
492
+ } catch { /* no source folder */ }
493
+
494
+ if (flatSource.length === 0 && folderFiles.length === 0) {
495
+ // A locale directory WITHOUT the source locale: say what is there.
496
+ const locales = fs.readdirSync(probe.abs, { withFileTypes: true })
497
+ .map(e => (e.isDirectory() ? e.name : (allowed(path.extname(e.name)) ? path.basename(e.name, path.extname(e.name)) : null)))
498
+ .filter(n => n && !n.startsWith('.'));
499
+ if (locales.length > 0) result.nearMisses.push({ localesDir: configPath(cwd, probe.abs), locales: [...new Set(locales)].sort() });
500
+ continue;
501
+ }
502
+
503
+ const ambiguous = flatSource.length > 0 && folderFiles.length > 0;
504
+ // Both shapes present: trust the framework's convention when it has
505
+ // one, else the single file. The choice is WRITTEN (localesLayout) so
506
+ // sync never has to guess.
507
+ const layout = folderFiles.length > 0 && (flatSource.length === 0 || probe.layout === 'dir') ? 'dir' : 'flat';
508
+ const layoutInfo = discoverLocaleLayout({
509
+ inputLocale: source, localesDir: probe.abs, format: 'auto', localesLayout: layout,
510
+ }, { cwd });
511
+ const evidence = layout === 'dir'
512
+ ? layoutInfo.sourceFiles.map(f => `${configPath(cwd, probe.abs).slice(2)}/${f.rel}`)
513
+ : [`${configPath(cwd, probe.abs).slice(2)}/${layoutInfo.sourceFiles[0].rel}`];
514
+ result.found = {
515
+ localesDir: configPath(cwd, probe.abs),
516
+ layout,
517
+ format: layoutInfo.format,
518
+ shape: layoutInfo.display,
519
+ why: probe.why,
520
+ targets: layoutInfo.listLocales(),
521
+ sourceFiles: evidence,
522
+ ambiguous,
523
+ };
524
+ return result;
525
+ }
526
+ return result;
527
+ }
528
+
529
+ /**
530
+ * One-line hint for sync's "Locales directory not found" error: what init
531
+ * WOULD configure, based on files on disk. Null when nothing was found.
532
+ *
533
+ * @param {string} cwd
534
+ * @param {string} source - Source locale code
535
+ * @returns {string|null}
536
+ */
537
+ function describeLocaleSetupHint(cwd, source) {
538
+ const { found, nearMisses, hints } = detectLocaleSetup(cwd, { source });
539
+ if (found) {
540
+ const setting = found.localesPattern
541
+ ? `"localesPattern": "${found.localesPattern}"`
542
+ : `"localesDir": "${found.localesDir}"`;
543
+ return `Found ${found.sourceFiles[0]} (${found.shape}) — set ${setting} `
544
+ + 'in champollion.config.json, or run `champollion init --force` to detect it.';
545
+ }
546
+ if (hints.length > 0) return hints[0];
547
+ if (nearMisses.length > 0) {
548
+ const m = nearMisses[0];
549
+ return `${m.localesDir} holds locale(s) ${m.locales.join(', ')} but no "${source}" source — `
550
+ + 'set "localesDir" and "inputLocale" in champollion.config.json.';
551
+ }
552
+ return null;
553
+ }
554
+
125
555
  /**
126
556
  * Checks whether stdin is interactive (attached to a TTY).
127
557
  * If piped or in CI, we skip the interactive wizard.
@@ -154,22 +584,45 @@ function parseLanguageInput(input) {
154
584
  if (!input) return [];
155
585
 
156
586
  const codes = new Set();
157
- const parts = input.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
587
+ const parts = input.split(',').map(s => s.trim()).filter(Boolean);
158
588
 
159
589
  for (const part of parts) {
160
- if (LANGUAGE_PRESETS[part]) {
590
+ const preset = LANGUAGE_PRESETS[part.toLowerCase()];
591
+ if (preset) {
161
592
  // Expand preset into individual codes
162
- for (const code of LANGUAGE_PRESETS[part]) {
593
+ for (const code of preset) {
163
594
  codes.add(code);
164
595
  }
165
596
  } else {
166
- codes.add(part);
597
+ codes.add(canonicalLocaleCase(part));
167
598
  }
168
599
  }
169
600
 
170
601
  return [...codes];
171
602
  }
172
603
 
604
+ /**
605
+ * BCP 47 casing, separator kept: "PT-br" → "pt-BR", "zh_hant" → "zh_Hant".
606
+ *
607
+ * The code becomes a FILE NAME (fr.json, app_pt_BR.arb, pt-BR/common.json)
608
+ * and, for Flutter, the "@@locale" gen-l10n compares with it. Lower-casing
609
+ * the whole code (the old behaviour) wrote app_pt_br.arb — a locale no
610
+ * device reports, and a file next-intl's "pt-BR" never finds.
611
+ *
612
+ * @param {string} code
613
+ * @returns {string}
614
+ */
615
+ function canonicalLocaleCase(code) {
616
+ let first = true;
617
+ return code.split(/([-_])/).map((part) => {
618
+ if (part === '-' || part === '_') return part;
619
+ if (first) { first = false; return part.toLowerCase(); }
620
+ if (/^[a-z]{4}$/i.test(part)) return part[0].toUpperCase() + part.slice(1).toLowerCase();
621
+ if (/^(?:[a-z]{2}|\d{3})$/i.test(part)) return part.toUpperCase();
622
+ return part.toLowerCase();
623
+ }).join('');
624
+ }
625
+
173
626
  /**
174
627
  * Get the METHOD_OPTIONS entry by its 1-based index or method name.
175
628
  * Returns null if not found.
@@ -246,7 +699,9 @@ async function stepLanguages(rl, prefill = {}) {
246
699
  const name = card ? card.name : (DEFAULT_REGISTERS[code]?.name || code);
247
700
  const system = card?.formality?.system;
248
701
  const systemLabel = system ? ` (${system})` : '';
249
- const unknown = !card && !DEFAULT_REGISTERS[code] ? ' ⚠ unrecognized code — check spelling' : '';
702
+ const unknown = !card && !DEFAULT_REGISTERS[code]
703
+ ? (isPrivateUseCode(code) ? ' (private-use code: no language card — give it a name, e.g. --name ' + code + '="…")' : ' ⚠ unrecognized code — check spelling')
704
+ : '';
250
705
  console.log(` ${code} — ${name}${systemLabel}${unknown}`);
251
706
  }
252
707
 
@@ -268,6 +723,13 @@ async function stepLanguages(rl, prefill = {}) {
268
723
  continue; // unrecognized code — already flagged above
269
724
  }
270
725
 
726
+ const preset = prefill.script ? parseScriptFlag(prefill.script, languages).map[code] : null;
727
+ if (preset) {
728
+ // --script on the command line: the choice is already made.
729
+ scriptChoices[code] = resolveTargetScript(code, { script: preset }, card).script;
730
+ console.log(` → ${code}: "script": "${scriptChoices[code]}" (from --script)`);
731
+ continue;
732
+ }
271
733
  if (resolution.source === 'choice-required') {
272
734
  console.log('');
273
735
  console.log(` ${code} is written in more than one orthography:`);
@@ -300,14 +762,31 @@ async function stepLanguages(rl, prefill = {}) {
300
762
  * { defaultMethod, defaultModel, perLanguage: null } — options 1 or 2
301
763
  * { defaultMethod, defaultModel, perLanguage: { ... } } — option 3
302
764
  */
303
- async function stepMethod(rl, languages, presetModel = null) {
304
- const defaultModel = presetModel || DEFAULT_OPENROUTER_MODEL;
765
+ async function stepMethod(rl, languages, presetModel = null, { localMark = null, presetMethod = null } = {}) {
766
+ // A local-only mark in the project makes a model on this machine the
767
+ // default (lib/local-only-marks.js); --method given with it still wins.
768
+ // Without a mark the wizard is as it was.
769
+ const localDefault = !!localMark && (!presetMethod || presetMethod === 'local');
770
+ const markedDefault = localMark && presetMethod && presetMethod !== 'local' && presetMethod !== API_METHOD
771
+ ? METHOD_OPTIONS.find(m => m.method === presetMethod) || null
772
+ : null;
773
+ const defaultModel = localDefault || (markedDefault && markedDefault.method !== 'llm')
774
+ ? (presetModel || null)
775
+ : (presetModel || DEFAULT_OPENROUTER_MODEL);
305
776
 
306
777
  console.log('');
307
778
  console.log(' Step 3/6 — Translation Method');
308
779
  console.log(' ────────────────────────────────────────────────');
309
780
  console.log('');
310
- console.log(` Default: OpenRouter → ${defaultModel}`);
781
+ if (localDefault) {
782
+ console.log(` Default: Local / self-hosted model${defaultModel ? ` → ${defaultModel}` : ''} — ${localMark.why}`);
783
+ console.log(` ${localMark.needs}`);
784
+ console.log(' A hosted method is your choice to make: option 2 lists them (each says where the strings go).');
785
+ } else if (markedDefault) {
786
+ console.log(` Default: ${markedDefault.label}${defaultModel ? ` → ${defaultModel}` : ''} (--method ${presetMethod}) — ${localMark.why}`);
787
+ } else {
788
+ console.log(` Default: OpenRouter → ${defaultModel}`);
789
+ }
311
790
  console.log('');
312
791
  console.log(' 1. Accept defaults');
313
792
  console.log(' 2. Choose a different method for all languages');
@@ -318,6 +797,8 @@ async function stepMethod(rl, languages, presetModel = null) {
318
797
 
319
798
  // ── Option 1: Accept defaults ──
320
799
  if (choice === '1') {
800
+ if (localDefault) return { defaultMethod: 'local', defaultModel, perLanguage: null };
801
+ if (markedDefault) return { defaultMethod: markedDefault.method, defaultModel, perLanguage: null };
321
802
  return {
322
803
  defaultMethod: 'llm',
323
804
  defaultModel,
@@ -337,6 +818,8 @@ async function stepMethod(rl, languages, presetModel = null) {
337
818
 
338
819
  // Default fallback
339
820
  console.log(` Unrecognized choice "${choice}" — accepting defaults.`);
821
+ if (localDefault) return { defaultMethod: 'local', defaultModel, perLanguage: null };
822
+ if (markedDefault) return { defaultMethod: markedDefault.method, defaultModel, perLanguage: null };
340
823
  return {
341
824
  defaultMethod: 'llm',
342
825
  defaultModel,
@@ -526,14 +1009,8 @@ async function pickPerLanguageMethod(rl, languages, presetModel = null) {
526
1009
 
527
1010
  // Check if this method supports the language via card metadata.
528
1011
  // WHY: A user picking DeepL for Swahili should know it's not supported.
529
- const support = getMethodSupport(code);
530
- if (support) {
531
- const methodKey = selected.method === 'google-translate' ? 'googleTranslate'
532
- : selected.method === 'microsoft-translator' ? 'microsoftTranslator'
533
- : selected.method;
534
- if (support[methodKey] === false) {
535
- console.log(` ⚠ ${selected.label} may not support ${name}. Consider LLM instead.`);
536
- }
1012
+ if (isMethodSupported(code, selected.method) === false) {
1013
+ console.log(` ⚠ ${selected.label} may not support ${name}. Consider LLM instead.`);
537
1014
  }
538
1015
 
539
1016
  // Build per-language config entry
@@ -578,7 +1055,7 @@ async function pickPerLanguageMethod(rl, languages, presetModel = null) {
578
1055
  * @param {string[]} languages - Target language codes
579
1056
  * @returns {object|null} Map of code → preset key, or null if no languages
580
1057
  */
581
- async function stepRegisters(rl, languages) {
1058
+ async function stepRegisters(rl, languages, current = null) {
582
1059
  if (languages.length === 0) return null;
583
1060
 
584
1061
  console.log('');
@@ -601,7 +1078,15 @@ async function stepRegisters(rl, languages) {
601
1078
  const system = card?.formality?.system || null;
602
1079
  const systemLabel = system ? ` [${system}]` : '';
603
1080
 
604
- if (presets.length > 0) {
1081
+ // `init --force` over a file: the register it already names is the
1082
+ // default here, so Enter keeps it (a custom register was lost).
1083
+ const kept = current && typeof current[code] === 'string' ? current[code] : null;
1084
+ if (kept) {
1085
+ selectedPresets[code] = kept;
1086
+ const preset = presets.find(p => p.key === kept);
1087
+ console.log(` ${code.padEnd(6)} ${name}${systemLabel}`);
1088
+ console.log(` → ${preset ? preset.label : `"${kept.length > 50 ? `${kept.slice(0, 47)}…` : kept}"`} (from your config)`);
1089
+ } else if (presets.length > 0) {
605
1090
  const defaultPreset = presets.find(p => p.isDefault) || presets[0];
606
1091
  // Explicitly store the default preset key — makes config self-documenting
607
1092
  selectedPresets[code] = defaultPreset.key;
@@ -765,7 +1250,8 @@ async function stepTemperature(rl, defaultMethod, presetTemp = null) {
765
1250
  }
766
1251
 
767
1252
  /**
768
- * Step 5: Content translation — Hugo, Docusaurus, or none.
1253
+ * Step 5: Content translation — a folder of Markdown/MDX (Hugo's content/,
1254
+ * a docs or newsletter folder), Docusaurus, or none.
769
1255
  */
770
1256
  async function stepContent(rl, cwd) {
771
1257
  console.log('');
@@ -775,7 +1261,7 @@ async function stepContent(rl, cwd) {
775
1261
  console.log(' Do you have Markdown content to translate?');
776
1262
  console.log('');
777
1263
  console.log(' 1. No — key-value locale files only');
778
- console.log(' 2. Yes — Hugo content directory');
1264
+ console.log(' 2. Yes — a folder of Markdown/MDX (Hugo content/, docs, newsletters…)');
779
1265
 
780
1266
  // Auto-detect Docusaurus
781
1267
  const hasDocusaurus = detectDocusaurus(cwd);
@@ -790,7 +1276,9 @@ async function stepContent(rl, cwd) {
790
1276
  const choice = await ask(rl, 'Choose', hasDocusaurus ? '3' : '1');
791
1277
 
792
1278
  if (choice === '2') {
793
- const contentDir = await ask(rl, 'Hugo content directory', './content');
1279
+ // Default to a folder that really holds Markdown (suggestContentDirs).
1280
+ const seen = suggestContentDirs(cwd);
1281
+ const contentDir = await ask(rl, 'Folder of Markdown/MDX files', seen.length > 0 ? `./${seen[0].dir}` : './content');
794
1282
  return { contentDir, format: null };
795
1283
  }
796
1284
 
@@ -835,7 +1323,9 @@ async function stepConfirm(rl, config, envVars) {
835
1323
  }
836
1324
  }
837
1325
 
838
- console.log(` Locales dir: ${config.localesDir}`);
1326
+ console.log(config.localesPattern
1327
+ ? ` Locale files: ${config.localesPattern}`
1328
+ : ` Locales dir: ${config.localesDir}`);
839
1329
  console.log(` Format: ${config.format}`);
840
1330
 
841
1331
  // Show default method if not the default 'llm'
@@ -886,7 +1376,7 @@ function buildConfig(answers) {
886
1376
  const {
887
1377
  source, languages, defaultMethod, defaultModel,
888
1378
  perLanguage, customRegisters, temperature,
889
- localesDir, format, contentDir, scriptChoices,
1379
+ localesDir, localesPattern = null, format, contentDir, scriptChoices,
890
1380
  } = answers;
891
1381
 
892
1382
  const hasPerLanguage = perLanguage && Object.keys(perLanguage).length > 0;
@@ -933,7 +1423,8 @@ function buildConfig(answers) {
933
1423
  const config = {
934
1424
  version: 3,
935
1425
  inputLocale: source,
936
- localesDir,
1426
+ // A pattern (Flutter, gettext) fixes the directory; never write both.
1427
+ ...(localesPattern ? { localesPattern } : { localesDir }),
937
1428
  languages: languagesConfig,
938
1429
  batchSize: DEFAULT_BATCH_SIZE,
939
1430
  format,
@@ -970,7 +1461,7 @@ function buildConfig(answers) {
970
1461
  * CLI flags (--langs, --source, --dir, --model, --temperature, --format)
971
1462
  * prefill the wizard's defaults — Enter accepts them at each step.
972
1463
  */
973
- async function runInteractive(cwd, args = {}) {
1464
+ async function runInteractive(cwd, args = {}, detection = null, currentRegisters = null, localMark = null) {
974
1465
  const rl = readline.createInterface({
975
1466
  input: process.stdin,
976
1467
  output: process.stdout,
@@ -981,33 +1472,52 @@ async function runInteractive(cwd, args = {}) {
981
1472
  console.log(' champollion — Project Setup');
982
1473
  console.log(' ════════════════════════════════════════════════');
983
1474
 
984
- // Step 1: Languages (includes orthography choices for dual-script locales)
985
- const { source, languages, scriptChoices } = await stepLanguages(rl, args);
1475
+ // Step 1: Languages (includes orthography choices for dual-script locales).
1476
+ // A Flutter template names the source (app_en.arb → en).
1477
+ const { source, languages, scriptChoices } = await stepLanguages(rl, {
1478
+ ...args, source: args.source || detection?.found?.inputLocale,
1479
+ });
986
1480
 
987
1481
  // Step 2: Registers — guided tone/formality per language.
988
1482
  // Comes before method because register choice informs method selection.
989
- const customRegisters = await stepRegisters(rl, languages);
1483
+ const customRegisters = await stepRegisters(rl, languages, currentRegisters);
990
1484
 
991
1485
  // Step 3: Translation Method
992
- const { defaultMethod, defaultModel, perLanguage } = await stepMethod(rl, languages, args.model);
1486
+ const { defaultMethod, defaultModel, perLanguage } = await stepMethod(rl, languages, args.model, {
1487
+ localMark, presetMethod: args.method || null,
1488
+ });
993
1489
 
994
1490
  // Step 4: Temperature
995
1491
  const temperature = await stepTemperature(rl, defaultMethod, args.temperature);
996
1492
 
997
- // Step 5: Content Translation
998
- const { contentDir, format: contentFormat } = await stepContent(rl, cwd);
1493
+ // Step 5: Content Translation (--content-dir answers it)
1494
+ const { contentDir, format: contentFormat } = args['content-dir']
1495
+ ? { contentDir: args['content-dir'], format: null }
1496
+ : await stepContent(rl, cwd);
999
1497
 
1000
1498
  // Step 6: Locales directory and format
1001
1499
  // These are simpler questions — asked inline before confirmation
1002
1500
  console.log('');
1003
- const localesDir = await ask(rl, 'Locales directory', args.dir || (contentFormat === 'docusaurus' ? './i18n' : './locales'));
1004
- const format = contentFormat || await ask(rl, 'File format (auto/json/toml/yaml)', args.format || 'auto');
1501
+ // Default to what is actually on disk (detectLocaleSetup), not a guess.
1502
+ // Flutter and gettext layouts are patterns (lib/l10n/app_{lang}.arb),
1503
+ // asked for as a pattern rather than a directory.
1504
+ const detectedPattern = contentFormat !== 'docusaurus' && !args.dir ? detection?.found?.localesPattern : null;
1505
+ let localesDir = null;
1506
+ let localesPattern = null;
1507
+ if (detectedPattern) {
1508
+ localesPattern = await ask(rl, 'Locale file pattern ({lang} = language, {ns} = file/domain)', detectedPattern);
1509
+ } else {
1510
+ const detectedDir = contentFormat === 'docusaurus' ? './i18n' : (detection?.found?.localesDir || './locales');
1511
+ localesDir = await ask(rl, 'Locales directory', args.dir || detectedDir);
1512
+ }
1513
+ const formatDefault = args.format || (detection?.found?.format === 'po' && !detectedPattern ? 'po' : 'auto');
1514
+ const format = contentFormat || await ask(rl, `File format (${['auto', ...LOCALE_FILE_FORMATS].join('/')})`, formatDefault);
1005
1515
 
1006
1516
  // Build config
1007
1517
  const config = buildConfig({
1008
1518
  source, languages, defaultMethod, defaultModel,
1009
1519
  perLanguage, customRegisters, temperature,
1010
- localesDir, format, contentDir, scriptChoices,
1520
+ localesDir, localesPattern, format, contentDir, scriptChoices,
1011
1521
  });
1012
1522
 
1013
1523
  // Collect env vars needed
@@ -1049,6 +1559,8 @@ async function buildDefaultConfig(args) {
1049
1559
  // If no API key or fetch fails, leave model unset (method default fires at runtime).
1050
1560
  let model = args.model || null;
1051
1561
  const method = args.method || 'llm';
1562
+ // The endpoint serves its own model; api has none of ours to write.
1563
+ if (method === API_METHOD) model = null;
1052
1564
 
1053
1565
  if (!model && method !== 'llm' && isListableProvider(method)) {
1054
1566
  const apiKey = resolveProviderApiKey(method);
@@ -1085,9 +1597,148 @@ async function buildDefaultConfig(args) {
1085
1597
  config.model = model;
1086
1598
  }
1087
1599
 
1600
+ // A folder of Markdown/MDX to translate (a newsletter archive, a docs
1601
+ // folder). Without the flag the folder had to be added by hand
1602
+ // (synthetic Cree school persona, 2026-10).
1603
+ if (args['content-dir']) {
1604
+ config.contentDir = args['content-dir'];
1605
+ }
1606
+
1088
1607
  return config;
1089
1608
  }
1090
1609
 
1610
+ /**
1611
+ * --script for init: "Cans" (one target language) or "crk=Cans,sr=Latn"
1612
+ * (":" works as well as "="). Values are checked by the same resolver sync
1613
+ * uses (lib/scripts.js resolveTargetScript), so a value init accepts is one
1614
+ * sync can run with.
1615
+ *
1616
+ * @param {string|undefined} value
1617
+ * @param {string[]} languages - Target codes (--langs)
1618
+ * @returns {{ map: Object<string,string>, error: string|null }}
1619
+ */
1620
+ function parseScriptFlag(value, languages) {
1621
+ const map = {};
1622
+ if (value == null || value === '') return { map, error: null };
1623
+ const parts = String(value).split(',').map(p => p.trim()).filter(Boolean);
1624
+ for (const part of parts) {
1625
+ const m = /^([^=:]+)[=:](.+)$/.exec(part);
1626
+ if (m) {
1627
+ map[m[1].trim()] = m[2].trim();
1628
+ } else if (parts.length === 1 && languages.length === 1) {
1629
+ map[languages[0]] = part;
1630
+ } else {
1631
+ return { map, error: `--script "${part}": name the language — e.g. --script crk=Cans (one --script value may list several: crk=Cans,sr=Latn).` };
1632
+ }
1633
+ }
1634
+ for (const [code, script] of Object.entries(map)) {
1635
+ if (!languages.includes(code)) {
1636
+ return { map, error: `--script ${code}=${script}: ${code} is not one of the target languages (--langs ${languages.join(',') || '…'}).` };
1637
+ }
1638
+ try {
1639
+ resolveTargetScript(code, { script }, getLanguageCard(code));
1640
+ } catch (err) {
1641
+ return { map, error: `--script ${code}=${script}: ${err.message}` };
1642
+ }
1643
+ }
1644
+ return { map, error: null };
1645
+ }
1646
+
1647
+ /**
1648
+ * --name for init: `qaa=Ayta (variety not yet confirmed)`, several separated
1649
+ * by ";" (a display name may hold commas), or a bare name when there is one
1650
+ * target language.
1651
+ *
1652
+ * @param {string|undefined} value
1653
+ * @returns {{ map: Record<string, string>, bare: string|null }}
1654
+ */
1655
+ function parseNameFlag(value) {
1656
+ const map = {};
1657
+ let bare = null;
1658
+ if (value == null || value === '' || value === true) return { map, bare };
1659
+ for (const part of String(value).split(';').map(p => p.trim()).filter(Boolean)) {
1660
+ const m = /^([A-Za-z]{2,3}(?:[-_][A-Za-z0-9]+)*)\s*=\s*(.+)$/.exec(part);
1661
+ if (m) map[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2');
1662
+ else bare = part.replace(/^(["'])(.*)\1$/, '$2');
1663
+ }
1664
+ return { map, bare };
1665
+ }
1666
+
1667
+ /**
1668
+ * Write --name display names into the object-form `languages` entries
1669
+ * (`"qaa": { "name": "Ayta (variety not yet confirmed)" }`).
1670
+ *
1671
+ * @returns {{ error: string|null }}
1672
+ */
1673
+ function applyDisplayNames(config, args) {
1674
+ const { map, bare } = parseNameFlag(args.name);
1675
+ const languages = Array.isArray(config.languages) ? config.languages : Object.keys(config.languages || {});
1676
+ if (bare !== null) {
1677
+ if (languages.length !== 1) {
1678
+ return { error: `--name "${bare}": name the language — e.g. --name qaa="${bare}" (several: --name "qaa=…;qab=…").` };
1679
+ }
1680
+ map[languages[0]] = bare;
1681
+ }
1682
+ if (Object.keys(map).length === 0) return { error: null };
1683
+ for (const code of Object.keys(map)) {
1684
+ if (!languages.includes(code)) {
1685
+ return { error: `--name ${code}=…: ${code} is not one of the target languages (--langs ${languages.join(',') || '…'}).` };
1686
+ }
1687
+ }
1688
+ const asObject = Array.isArray(config.languages)
1689
+ ? Object.fromEntries(config.languages.map(c => [c, {}]))
1690
+ : { ...config.languages };
1691
+ for (const [code, name] of Object.entries(map)) {
1692
+ const prior = asObject[code];
1693
+ asObject[code] = typeof prior === 'string' ? { register: prior, name } : { ...(prior || {}), name };
1694
+ }
1695
+ config.languages = asObject;
1696
+ return { error: null };
1697
+ }
1698
+
1699
+ /**
1700
+ * Writing systems on the non-interactive path. The wizard asks for a
1701
+ * language with more than one real orthography (Plains Cree: SRO or
1702
+ * Syllabics); `init --yes` used to skip that in silence and the first sync
1703
+ * refused (Round 4, school persona). Now --script records the choice, and
1704
+ * without it init says a choice is needed, lists the choices, and prints
1705
+ * exactly what to add — the same rule sync applies.
1706
+ *
1707
+ * @param {object} config - The config being written (languages mutated to object form when a script is set)
1708
+ * @param {object} args
1709
+ * @returns {{ error: string|null, needed: Array<{ code: string, choices: Array<{script: string, label: string}> }> }}
1710
+ */
1711
+ function applyScriptChoices(config, args) {
1712
+ const languages = Array.isArray(config.languages) ? config.languages : Object.keys(config.languages || {});
1713
+ const { map, error } = parseScriptFlag(args.script, languages);
1714
+ if (error) return { error, needed: [] };
1715
+ const needed = [];
1716
+ for (const code of languages) {
1717
+ if (map[code]) continue;
1718
+ // A script the config already names (an existing file under --force).
1719
+ const entry = Array.isArray(config.languages) ? null : config.languages?.[code];
1720
+ if (entry && typeof entry === 'object' && entry.script) continue;
1721
+ let resolution;
1722
+ try { resolution = resolveTargetScript(code, {}, getLanguageCard(code)); } catch { continue; }
1723
+ if (resolution.source === 'choice-required') needed.push({ code, choices: resolution.choices });
1724
+ }
1725
+ if (Object.keys(map).length > 0) {
1726
+ const asObject = Array.isArray(config.languages)
1727
+ ? Object.fromEntries(config.languages.map(c => [c, {}]))
1728
+ : { ...config.languages };
1729
+ for (const [code, script] of Object.entries(map)) {
1730
+ const card = getLanguageCard(code);
1731
+ const resolved = resolveTargetScript(code, { script }, card).script || script;
1732
+ const prior = asObject[code];
1733
+ asObject[code] = typeof prior === 'string'
1734
+ ? { register: prior, script: resolved }
1735
+ : { ...(prior || {}), script: resolved };
1736
+ }
1737
+ config.languages = asObject;
1738
+ }
1739
+ return { error: null, needed };
1740
+ }
1741
+
1091
1742
  /**
1092
1743
  * Validate init flag values before any config is written.
1093
1744
  * Returns an error message string, or null when the flags are fine.
@@ -1097,10 +1748,45 @@ async function buildDefaultConfig(args) {
1097
1748
  * broken config ("temperature": null) at first sync.
1098
1749
  */
1099
1750
  function validateInitFlags(args) {
1100
- if (args.method && !METHOD_OPTIONS.some(m => m.method === args.method)) {
1101
- const valid = METHOD_OPTIONS.map(m => m.method).join(', ');
1751
+ // Same vocabulary resolveConfig() enforces — a config init writes must be
1752
+ // one sync can load.
1753
+ const formats = ['auto', 'docusaurus', ...LOCALE_FILE_FORMATS];
1754
+ if (args.format && !formats.includes(args.format)) {
1755
+ return `Unknown --format "${args.format}". Valid formats: ${formats.join(', ')}`;
1756
+ }
1757
+ if (args.method && args.method !== API_METHOD && !METHOD_OPTIONS.some(m => m.method === args.method)) {
1758
+ const valid = [...METHOD_OPTIONS.map(m => m.method), API_METHOD].join(', ');
1102
1759
  return `Unknown method "${args.method}". Valid methods: ${valid}`;
1103
1760
  }
1761
+ // --method api: a server speaking the champollion API contract (e.g. a
1762
+ // model served by `nmt-forge serve`) — the endpoint is the whole config.
1763
+ if (args.method === API_METHOD) {
1764
+ if (args.endpoint == null || args.endpoint === true || !String(args.endpoint).trim()) {
1765
+ return '--method api needs --endpoint <url>: the champollion API endpoint, e.g. '
1766
+ + '--endpoint http://127.0.0.1:8378/translate (what `nmt-forge serve` prints).';
1767
+ }
1768
+ let url = null;
1769
+ try { url = new URL(String(args.endpoint)); } catch { /* reported below */ }
1770
+ if (!url || !/^https?:$/.test(url.protocol)) {
1771
+ return `--endpoint ${args.endpoint}: not an http(s) URL. Example: --endpoint http://127.0.0.1:8378/translate`;
1772
+ }
1773
+ if (args.model) {
1774
+ return '--model does not apply to --method api: the endpoint serves its own model.';
1775
+ }
1776
+ } else if (args.endpoint != null) {
1777
+ return `--endpoint applies to --method api (a champollion API endpoint)${args.method ? `, not --method ${args.method}` : ''}. `
1778
+ + 'For an OpenAI-compatible server use --method local and LOCAL_API_BASE.';
1779
+ }
1780
+ if (args['accepts-instructions'] != null) {
1781
+ if (args.method !== API_METHOD) return '--accepts-instructions applies to --method api.';
1782
+ if (!['true', 'false'].includes(String(args['accepts-instructions']))) {
1783
+ return `--accepts-instructions must be true or false (got "${args['accepts-instructions']}").`;
1784
+ }
1785
+ }
1786
+
1787
+ if (args.script != null && !args.langs) {
1788
+ return '--script needs --langs: it names the writing system of a target language (e.g. --langs crk --script crk=Cans).';
1789
+ }
1104
1790
 
1105
1791
  if (args.temperature != null) {
1106
1792
  const t = parseFloat(args.temperature);
@@ -1112,6 +1798,418 @@ function validateInitFlags(args) {
1112
1798
  return null;
1113
1799
  }
1114
1800
 
1801
+ /**
1802
+ * Say what detectLocaleSetup() found, and why, before anything is written —
1803
+ * an agent driving `init --yes` reads this to know which files will sync.
1804
+ *
1805
+ * @param {ReturnType<typeof detectLocaleSetup>} detection
1806
+ * @param {string} source - Source locale code
1807
+ */
1808
+ function reportDetection(detection, source) {
1809
+ if (detection.docusaurus && !detection.found) {
1810
+ output.info('Detected Docusaurus — locale files live in ./i18n/<locale>/ (Docusaurus lane).');
1811
+ return;
1812
+ }
1813
+ const { found } = detection;
1814
+ if (found) {
1815
+ const fw = detection.framework && detection.framework !== 'generic' ? ` (${detection.framework})` : '';
1816
+ output.info(`Detected locale files: ${found.shape}${fw} — ${found.why}`);
1817
+ const shown = found.sourceFiles.slice(0, 4).join(', ');
1818
+ const more = found.sourceFiles.length > 4 ? `, +${found.sourceFiles.length - 4} more` : '';
1819
+ output.raw(` Source (${source}): ${shown}${more}`);
1820
+ if (found.layout === 'dir') {
1821
+ output.raw(` One folder per locale; each file is a namespace (${found.sourceFiles.length} file(s)).`);
1822
+ }
1823
+ if (found.localesPattern) {
1824
+ output.raw(` Writing "localesPattern": "${found.localesPattern}"${found.format === 'arb' ? ' (Flutter ARB)' : ' (gettext)'}.`);
1825
+ }
1826
+ if (found.targets.length > 0) output.raw(` Target locales on disk: ${found.targets.join(', ')}`);
1827
+ if (found.ambiguous) {
1828
+ output.warn(`Both ${found.localesDir}/${source}.<ext> and ${found.localesDir}/${source}/ exist — writing "localesLayout": "${found.layout}" so sync does not have to guess.`);
1829
+ }
1830
+ return;
1831
+ }
1832
+ for (const hint of detection.hints || []) output.warn(hint);
1833
+ for (const miss of detection.nearMisses) {
1834
+ output.warn(`${miss.localesDir}/ holds locale(s) ${miss.locales.join(', ')} but no "${source}" — if your source language is one of those, re-run with --source <code>.`);
1835
+ }
1836
+ output.info(`No ${source} locale files found in the usual places (${GENERIC_LOCALE_DIRS.join(', ')}).`);
1837
+ }
1838
+
1839
+ /**
1840
+ * Point a default (--yes) config at what detectLocaleSetup() found. An
1841
+ * explicit --dir always wins; it was probed on its own.
1842
+ *
1843
+ * @param {object} config - From buildDefaultConfig()
1844
+ * @param {ReturnType<typeof detectLocaleSetup>} detection
1845
+ * @param {object} args - CLI flags
1846
+ */
1847
+ function applyDetection(config, detection, args) {
1848
+ if (detection.docusaurus && !args.dir) {
1849
+ if (!args.format) config.format = 'docusaurus';
1850
+ config.localesDir = './i18n';
1851
+ return;
1852
+ }
1853
+ if (detection.found) {
1854
+ const found = detection.found;
1855
+ if (found.localesPattern) {
1856
+ // The pattern replaces localesDir in place (config key order stays
1857
+ // readable); config.js refuses a localesDir that disagrees with it.
1858
+ const ordered = {};
1859
+ for (const [k, v] of Object.entries(config)) {
1860
+ if (k === 'localesDir') ordered.localesPattern = found.localesPattern;
1861
+ else ordered[k] = v;
1862
+ }
1863
+ for (const k of Object.keys(config)) delete config[k];
1864
+ Object.assign(config, ordered);
1865
+ // A Flutter template names the source (app_en.arb → en); --source wins.
1866
+ if (found.inputLocale && !args.source) config.inputLocale = found.inputLocale;
1867
+ } else {
1868
+ config.localesDir = found.localesDir;
1869
+ }
1870
+ // GNU po/ may hold only a .pot template and targets — say "po" so the
1871
+ // source is never looked up as a JSON file.
1872
+ if (found.format === 'po' && !found.localesPattern && !args.format) config.format = 'po';
1873
+ }
1874
+ }
1875
+
1876
+ // -----------------------------------------------------------------
1877
+ // `init --force` over an existing config: change what was asked, keep the rest
1878
+ // -----------------------------------------------------------------
1879
+ //
1880
+ // `init --force` used to write a brand-new default config over the old one:
1881
+ // `init --force --method local --model x` (what init itself printed as the way
1882
+ // to switch model) dropped a custom register, the glossary and every other
1883
+ // field, and reset batchSize to the default (Round 10, Next.js persona). Now
1884
+ // a forced re-run starts from the existing file: the fields the flags name
1885
+ // are rewritten (and the locale layout is re-detected when the file no longer
1886
+ // finds the source), everything else stays as it was, the changes are printed,
1887
+ // and the previous file is backed up first.
1888
+
1889
+ /** Plain-object copy of a JSON value. */
1890
+ const cloneJSON = (v) => JSON.parse(JSON.stringify(v));
1891
+
1892
+ /** Target codes of a `languages` value (list or object form). */
1893
+ function languageCodes(languages) {
1894
+ if (Array.isArray(languages)) return [...languages];
1895
+ return languages && typeof languages === 'object' ? Object.keys(languages) : [];
1896
+ }
1897
+
1898
+ /**
1899
+ * Set `key` on `obj` to `value` (deleting it for undefined/null), keeping the
1900
+ * key's place in the file. `replaces` names a key the new one takes the place
1901
+ * of (localesPattern for localesDir), so the file still reads top to bottom.
1902
+ */
1903
+ function setInPlace(obj, key, value, replaces = null) {
1904
+ if (value === undefined || value === null) { delete obj[key]; return; }
1905
+ if (Object.prototype.hasOwnProperty.call(obj, key) || !replaces || !Object.prototype.hasOwnProperty.call(obj, replaces)) {
1906
+ obj[key] = value;
1907
+ return;
1908
+ }
1909
+ const ordered = {};
1910
+ for (const [k, v] of Object.entries(obj)) {
1911
+ if (k === replaces) ordered[key] = value;
1912
+ else ordered[k] = v;
1913
+ }
1914
+ for (const k of Object.keys(obj)) delete obj[k];
1915
+ Object.assign(obj, ordered);
1916
+ }
1917
+
1918
+ /**
1919
+ * Does the existing config still find the project's source locale files?
1920
+ * When it does, a forced re-run keeps its layout (localesDir/localesPattern,
1921
+ * localesLayout, format) unless --dir or --format names another; when it does
1922
+ * not (the folder moved, a Flutter template or a .pot was created since), the
1923
+ * layout is re-detected — what `init --force` is advised for.
1924
+ *
1925
+ * @param {object} existing - The parsed config file
1926
+ * @param {string} inputLocale - The source locale the run will use
1927
+ * @param {string} cwd
1928
+ * @returns {boolean}
1929
+ */
1930
+ function existingLayoutHolds(existing, inputLocale, cwd) {
1931
+ if (existing.format === 'docusaurus') return true; // its lane reads i18n/ itself
1932
+ try {
1933
+ const patternAbs = typeof existing.localesPattern === 'string' && existing.localesPattern
1934
+ ? path.resolve(cwd, existing.localesPattern) : null;
1935
+ const localesAbs = patternAbs
1936
+ ? compileLocalesPattern(patternAbs).base
1937
+ : path.resolve(cwd, typeof existing.localesDir === 'string' && existing.localesDir ? existing.localesDir : './locales');
1938
+ if (!fs.existsSync(localesAbs)) return false;
1939
+ const layout = discoverLocaleLayout({
1940
+ inputLocale, localesDir: localesAbs, format: existing.format || 'auto',
1941
+ ...(patternAbs ? { localesPattern: patternAbs } : { localesLayout: existing.localesLayout || null }),
1942
+ }, { cwd });
1943
+ return layout.namespaced ? layout.sourceFiles.length > 0 : fs.existsSync(layout.sourceFiles[0].path);
1944
+ } catch {
1945
+ return false;
1946
+ }
1947
+ }
1948
+
1949
+ /**
1950
+ * The non-interactive path's config over an existing file: start from the
1951
+ * file, and rewrite only what the flags name (built from them as for a new
1952
+ * project in `fresh`).
1953
+ *
1954
+ * --source → inputLocale; --dir, or a layout that no longer finds the source
1955
+ * → localesDir/localesPattern/localesLayout (re-detected); --format → format;
1956
+ * --method → defaultMethod, and the model with it when the method changes
1957
+ * (a model belongs to its method) unless --model names one; --model → model;
1958
+ * --temperature; --content-dir → contentDir; --langs → the target list (a
1959
+ * code already there keeps its entry — register, script, name — as it was).
1960
+ *
1961
+ * Everything else (batchSize, pairs, glossary, fallbacks, registers, …) is the
1962
+ * file's. --script, --name and --method api's pairs are applied afterwards by
1963
+ * the same code as for a new project.
1964
+ *
1965
+ * @param {object} existing - The parsed config file
1966
+ * @param {object} fresh - buildDefaultConfig() + applyDetection() for these flags
1967
+ * @param {object} args
1968
+ * @param {{ relayout: boolean, detection: object }} p
1969
+ * @returns {{ config: object, added: string[] }} added = target codes new to the file
1970
+ */
1971
+ function overlayFlagsOnConfig(existing, fresh, args, { relayout, detection }) {
1972
+ const config = cloneJSON(existing);
1973
+ const take = (key, replaces = null) => setInPlace(config, key, fresh[key], replaces);
1974
+ if (config.version === undefined) config.version = fresh.version;
1975
+ if (args.source) take('inputLocale');
1976
+ if (relayout) {
1977
+ // A pattern replaces localesDir in place; never write both.
1978
+ take('localesPattern', 'localesDir');
1979
+ take('localesDir');
1980
+ take('localesLayout');
1981
+ // A Flutter template names the source (app_en.arb → en); --source wins.
1982
+ if (!args.source && detection?.found?.inputLocale) take('inputLocale');
1983
+ }
1984
+ if (args.format || relayout) take('format');
1985
+ const methodBefore = existing.defaultMethod || 'llm';
1986
+ const methodAfter = fresh.defaultMethod || 'llm';
1987
+ if (args.method) take('defaultMethod');
1988
+ if (args.model || (args.method && methodAfter !== methodBefore)) take('model');
1989
+ if (args.temperature != null) take('temperature');
1990
+ if (args['content-dir'] != null) take('contentDir');
1991
+
1992
+ let added = [];
1993
+ if (args.langs) {
1994
+ const listed = languageCodes(fresh.languages);
1995
+ const prior = existing.languages;
1996
+ const priorEntry = Array.isArray(prior)
1997
+ ? Object.fromEntries(prior.map(c => [c, null]))
1998
+ : (prior && typeof prior === 'object' ? prior : {});
1999
+ const had = (c) => Object.prototype.hasOwnProperty.call(priorEntry, c);
2000
+ added = listed.filter(c => !had(c));
2001
+ // Plain codes stay a plain list (registers are recorded for the new ones later).
2002
+ config.languages = listed.every(c => priorEntry[c] == null)
2003
+ ? [...listed]
2004
+ : Object.fromEntries(listed.map(c => [c, priorEntry[c] ?? {}]));
2005
+ }
2006
+ return { config, added };
2007
+ }
2008
+
2009
+ /**
2010
+ * The wizard's config over an existing file: the wizard asked for the source,
2011
+ * the targets and their registers, the method and model, the temperature, the
2012
+ * content folder and the locale layout — those are its answers; every field
2013
+ * it does not ask about (batchSize, pairs, glossary, …) is the file's, and a
2014
+ * target's entry keeps the fields the wizard does not set (script, name,
2015
+ * genderGuidance, …).
2016
+ *
2017
+ * @param {object} existing
2018
+ * @param {object} answered - buildConfig() from the wizard
2019
+ * @returns {object}
2020
+ */
2021
+ function mergeWizardConfig(existing, answered) {
2022
+ const config = cloneJSON(existing);
2023
+ const take = (key, replaces = null) => setInPlace(config, key, answered[key], replaces);
2024
+ if (config.version === undefined) config.version = answered.version;
2025
+ take('inputLocale');
2026
+ take('localesPattern', 'localesDir');
2027
+ take('localesDir');
2028
+ if (answered.localesDir !== existing.localesDir || answered.localesPattern !== existing.localesPattern) delete config.localesLayout;
2029
+ take('format');
2030
+ take('defaultMethod');
2031
+ take('model');
2032
+ take('temperature');
2033
+ take('contentDir');
2034
+
2035
+ const asObject = (v) => (typeof v === 'string' ? { register: v } : (v && typeof v === 'object' ? { ...v } : {}));
2036
+ const prior = Array.isArray(existing.languages)
2037
+ ? Object.fromEntries(existing.languages.map(c => [c, {}]))
2038
+ : (existing.languages && typeof existing.languages === 'object' ? existing.languages : {});
2039
+ if (Array.isArray(answered.languages)) {
2040
+ config.languages = answered.languages.every(c => !prior[c] || Object.keys(asObject(prior[c])).length === 0)
2041
+ ? [...answered.languages]
2042
+ : Object.fromEntries(answered.languages.map(c => [c, prior[c] ?? {}]));
2043
+ } else {
2044
+ const out = {};
2045
+ for (const [code, value] of Object.entries(answered.languages || {})) {
2046
+ const merged = { ...asObject(prior[code]), ...asObject(value) };
2047
+ const keys = Object.keys(merged);
2048
+ out[code] = keys.length === 1 && keys[0] === 'register' && typeof merged.register === 'string' ? merged.register : merged;
2049
+ }
2050
+ config.languages = out;
2051
+ }
2052
+ return config;
2053
+ }
2054
+
2055
+ /** Each target's register in a config (code → preset key or own words). */
2056
+ function registersOf(config) {
2057
+ const out = {};
2058
+ const langs = config.languages;
2059
+ if (!langs || typeof langs !== 'object' || Array.isArray(langs)) return out;
2060
+ for (const [code, value] of Object.entries(langs)) {
2061
+ const register = typeof value === 'string' ? value : (value && typeof value === 'object' ? value.register : null);
2062
+ if (typeof register === 'string' && register) out[code] = register;
2063
+ }
2064
+ return out;
2065
+ }
2066
+
2067
+ /**
2068
+ * The wizard's defaults over an existing file: its source, targets,
2069
+ * temperature, content folder and (while it still finds the source) locale
2070
+ * folder — flags still win — so pressing Enter keeps what the file says.
2071
+ */
2072
+ function prefillFromConfig(args, existing, relayout) {
2073
+ const out = { ...args };
2074
+ if (!out.source && typeof existing.inputLocale === 'string') out.source = existing.inputLocale;
2075
+ if (!out.langs) {
2076
+ const codes = languageCodes(existing.languages);
2077
+ if (codes.length > 0) out.langs = codes.join(',');
2078
+ }
2079
+ if (out.temperature == null && typeof existing.temperature === 'number') out.temperature = existing.temperature;
2080
+ if (out['content-dir'] == null && typeof existing.contentDir === 'string') out['content-dir'] = existing.contentDir;
2081
+ if (!out.dir && !relayout && !existing.localesPattern && typeof existing.localesDir === 'string') out.dir = existing.localesDir;
2082
+ if (!out.format && typeof existing.format === 'string') out.format = existing.format;
2083
+ if (!out.script && existing.languages && !Array.isArray(existing.languages)) {
2084
+ const scripts = Object.entries(existing.languages)
2085
+ .filter(([, v]) => v && typeof v === 'object' && typeof v.script === 'string')
2086
+ .map(([c, v]) => `${c}=${v.script}`);
2087
+ if (scripts.length > 0) out.script = scripts.join(',');
2088
+ }
2089
+ return out;
2090
+ }
2091
+
2092
+ /** A config value, short, for a "was → now" line. */
2093
+ function showValue(v) {
2094
+ if (v === undefined) return '(not set)';
2095
+ const s = JSON.stringify(v);
2096
+ return s.length > 70 ? `${s.slice(0, 67)}…` : s;
2097
+ }
2098
+
2099
+ /**
2100
+ * What a forced re-run changes in the file, in words: one line per changed
2101
+ * field ("model: \"m1\" → \"x\""), the targets added or removed and each
2102
+ * target whose entry changed; and the fields left as they were.
2103
+ *
2104
+ * @param {object} before
2105
+ * @param {object} after
2106
+ * @returns {{ changed: string[], kept: string[] }}
2107
+ */
2108
+ function describeConfigChanges(before, after) {
2109
+ const changed = [];
2110
+ const kept = [];
2111
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
2112
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])];
2113
+ for (const key of keys) {
2114
+ if (same(before[key], after[key])) {
2115
+ if (key in before) kept.push(key);
2116
+ continue;
2117
+ }
2118
+ if (key !== 'languages') {
2119
+ changed.push(`${key}: ${showValue(before[key])} → ${showValue(after[key])}`);
2120
+ continue;
2121
+ }
2122
+ const entry = (langs, code) => (Array.isArray(langs) ? (langs.includes(code) ? {} : undefined) : langs?.[code]);
2123
+ const was = languageCodes(before.languages);
2124
+ const now = languageCodes(after.languages);
2125
+ const added = now.filter(c => !was.includes(c));
2126
+ const removed = was.filter(c => !now.includes(c));
2127
+ const parts = [];
2128
+ if (added.length > 0) parts.push(`added ${added.map(c => `${c} (${showValue(entry(after.languages, c))})`).join(', ')}`);
2129
+ if (removed.length > 0) parts.push(`removed ${removed.join(', ')}`);
2130
+ for (const c of now.filter(c => was.includes(c))) {
2131
+ const a = entry(before.languages, c);
2132
+ const b = entry(after.languages, c);
2133
+ if (!same(a, b)) parts.push(`${c}: ${showValue(a)} → ${showValue(b)}`);
2134
+ }
2135
+ changed.push(`languages: ${parts.length > 0 ? parts.join('; ') : 'written as an object (the same targets and settings)'}`);
2136
+ }
2137
+ return { changed, kept };
2138
+ }
2139
+
2140
+ /**
2141
+ * Keep a copy of the config file before init rewrites it:
2142
+ * champollion.config.json.bak, or — when that already holds an OLDER, different
2143
+ * file — the next free champollion.config.json.bak.2, .bak.3 … (an older
2144
+ * backup is never overwritten). A backup that already holds exactly this file
2145
+ * is reused.
2146
+ *
2147
+ * @param {string} configPath
2148
+ * @param {string} raw - The file's current text
2149
+ * @returns {{ path: string, reused: boolean }}
2150
+ */
2151
+ function backupConfigFile(configPath, raw) {
2152
+ const base = `${configPath}.bak`;
2153
+ for (let n = 1; ; n++) {
2154
+ const candidate = n === 1 ? base : `${base}.${n}`;
2155
+ if (!fs.existsSync(candidate)) {
2156
+ fs.writeFileSync(candidate, raw, { encoding: 'utf-8', flag: 'wx' });
2157
+ return { path: candidate, reused: false };
2158
+ }
2159
+ if (fs.readFileSync(candidate, 'utf-8') === raw) return { path: candidate, reused: true };
2160
+ }
2161
+ }
2162
+
2163
+ /**
2164
+ * Where a method sends the strings it translates, for a method that sends
2165
+ * them off this machine — said at init time, with how to keep them here
2166
+ * (Round 10, hospital persona: the default was a hosted model, and nothing
2167
+ * at init said so). Null for a method that runs here (`local`), and for an
2168
+ * `api` endpoint on this machine.
2169
+ *
2170
+ * @param {object} config
2171
+ * @param {{ endpoint: string }|null} apiSetup
2172
+ * @returns {string|null}
2173
+ */
2174
+ function whereTextGoes(config, apiSetup) {
2175
+ const method = config.defaultMethod || 'llm';
2176
+ if (method === API_METHOD) {
2177
+ if (!apiSetup) return null;
2178
+ let host = '';
2179
+ try { host = new URL(apiSetup.endpoint).hostname; } catch { /* validated */ }
2180
+ return LOOPBACK_HOSTS.has(host) ? null : `the server at ${apiSetup.endpoint}`;
2181
+ }
2182
+ const opt = METHOD_OPTIONS.find(m => m.method === method);
2183
+ return opt?.sendsTo || null;
2184
+ }
2185
+
2186
+ /**
2187
+ * What init says about a local-only mark in the project (lib/local-only-marks.js):
2188
+ * why the default is the local method — one line naming the marked file —
2189
+ * how to choose a hosted method deliberately, and what local needs.
2190
+ * Null when nothing in the project is marked.
2191
+ *
2192
+ * @param {string} cwd
2193
+ * @param {Array<{ sidecar: string, dataFile: string, unreadable: boolean }>} marks
2194
+ * @returns {{ why: string, hosted: string, needs: string }|null}
2195
+ */
2196
+ function localOnlyNotice(cwd, marks) {
2197
+ if (!marks || marks.length === 0) return null;
2198
+ const rel = (p) => projectRelative(cwd, p).replace(/^\.\//, '');
2199
+ const first = marks[0];
2200
+ const more = marks.length > 1 ? ` (+${marks.length - 1} more marked file(s))` : '';
2201
+ const why = first.unreadable
2202
+ ? `${rel(first.sidecar)} could not be read, so ${rel(first.dataFile)} is treated as marked local-only${more}: only a model on this machine may see it.`
2203
+ : `${rel(first.dataFile)} is marked local-only (${rel(first.sidecar)})${more}: only a model on this machine may see it.`;
2204
+ return {
2205
+ why,
2206
+ hosted: `A hosted method is a deliberate choice: champollion init --force --method llm --model ${DEFAULT_OPENROUTER_MODEL} `
2207
+ + '(--force rewrites only the method and model; the strings sync translates then go to OpenRouter).',
2208
+ needs: 'Local needs a model server on this machine: Ollama\'s default (http://localhost:11434/v1), '
2209
+ + 'or LOCAL_API_BASE set to yours (LM Studio, vLLM).',
2210
+ };
2211
+ }
2212
+
1115
2213
  async function run(args, cwd) {
1116
2214
  const configPath = path.join(cwd, DEFAULT_CONFIG_FILENAME);
1117
2215
 
@@ -1130,91 +2228,381 @@ async function run(args, cwd) {
1130
2228
  output.error(flagError);
1131
2229
  return 1;
1132
2230
  }
2231
+ // Like localesDir: never write a contentDir that does not exist.
2232
+ if (args['content-dir'] != null) {
2233
+ const contentAbs = path.resolve(cwd, String(args['content-dir']));
2234
+ let isDir = false;
2235
+ try { isDir = fs.statSync(contentAbs).isDirectory(); } catch { /* missing */ }
2236
+ if (!args['content-dir'] || !isDir) {
2237
+ output.error(`--content-dir ${args['content-dir'] || '(empty)'}: no such folder `
2238
+ + `(${projectRelative(cwd, contentAbs)}). Point it at the folder of Markdown/MDX files to translate.`);
2239
+ return 1;
2240
+ }
2241
+ }
1133
2242
 
1134
2243
  // ── Guard: config already exists ──
1135
2244
  // Without --force, refuse to clobber an existing config. Exit non-zero so
1136
2245
  // scripts (and the user) can tell "already initialized / not regenerated"
1137
2246
  // from a successful fresh init — a silent exit 0 here hid the no-op,
1138
2247
  // especially for a corrupt config that the user expected `init --yes` to fix.
1139
- // Pass --force to regenerate over the top of an existing (or invalid) config.
2248
+ // Pass --force to re-run init over an existing config: it rewrites what the
2249
+ // flags ask for and keeps the rest (overlayFlagsOnConfig), after a backup.
2250
+ // A fresh project (no config yet) is where a new user decides to adopt
2251
+ // the CLI: init says the license there once (not on every --force rerun).
2252
+ const freshProject = !fs.existsSync(configPath);
1140
2253
  if (fs.existsSync(configPath) && !args.force) {
1141
2254
  output.warn(`Config file already exists: ${DEFAULT_CONFIG_FILENAME}`);
1142
- output.raw(' Run with --force to regenerate, or delete it first.');
2255
+ output.raw(' To change a setting, edit it in the file (e.g. "model", "defaultMethod", "languages").');
2256
+ output.raw(' --force re-runs init over it: it rewrites only what the flags name, keeps every other');
2257
+ output.raw(` setting, prints what changed and backs the file up first (${DEFAULT_CONFIG_FILENAME}.bak).`);
1143
2258
  return 1;
1144
2259
  }
1145
2260
 
2261
+ // ── --force over an existing config: read it, to keep what is not asked ──
2262
+ // A file that is not a JSON object cannot be kept: it is backed up and a
2263
+ // new one written (said below).
2264
+ let existing = null;
2265
+ let existingRaw = null;
2266
+ let existingUnreadable = null;
2267
+ if (!freshProject) {
2268
+ existingRaw = fs.readFileSync(configPath, 'utf-8');
2269
+ try {
2270
+ const parsed = JSON.parse(existingRaw.replace(/^/, ''));
2271
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not a JSON object');
2272
+ existing = parsed;
2273
+ } catch (err) {
2274
+ existingUnreadable = err.message;
2275
+ }
2276
+ }
2277
+
2278
+ // ── Find the project's locale files BEFORE writing anything ──
2279
+ // A config whose localesDir points nowhere fails on the first sync; this
2280
+ // is where a next-intl (messages/en.json) or i18next
2281
+ // (public/locales/en/common.json) project gets its real layout.
2282
+ const sourceLocale = args.source
2283
+ || (existing && typeof existing.inputLocale === 'string' && existing.inputLocale) || 'en';
2284
+ const detection = detectLocaleSetup(cwd, { source: sourceLocale, dir: args.dir || null });
2285
+ // The existing file's layout is kept while it still finds the source files
2286
+ // (and no --dir/--format names another); otherwise it is re-detected.
2287
+ const relayout = !existing || !!args.dir || !existingLayoutHolds(existing, sourceLocale, cwd);
2288
+ reportDetection(detection, detection.found?.inputLocale && !args.source ? detection.found.inputLocale : sourceLocale);
2289
+ if (existing && !relayout) {
2290
+ const where = existing.localesPattern ? `"localesPattern": "${existing.localesPattern}"` : `"localesDir": "${existing.localesDir || './locales'}"`;
2291
+ output.info(`Keeping the config's locale layout (${where}) — it still finds the ${sourceLocale} source files. --dir changes it.`);
2292
+ }
2293
+ // Markdown pages: suggested, never switched on — translating them is work
2294
+ // (and, with a paid method, cost) the user chooses (Round 3, school persona).
2295
+ if (args['content-dir'] == null && !detection.docusaurus && !(existing && existing.contentDir)) {
2296
+ const exclude = detection.found ? [path.resolve(cwd, detection.found.localesDir)] : [];
2297
+ const md = suggestContentDirs(cwd, exclude);
2298
+ if (md.length > 0) {
2299
+ output.info(`Markdown found in ${md.map(m => `${m.dir}/ (${m.files} file(s))`).join(', ')} — not translated unless you ask: `
2300
+ + `add --content-dir ${md[0].dir} (or set "contentDir") to translate those pages too.`);
2301
+ }
2302
+ }
2303
+ if (args.source && detection.found?.inputLocale && detection.found.inputLocale !== args.source) {
2304
+ output.warn(`--source ${args.source}, but the Flutter template ${detection.found.sourceFiles[0]} is `
2305
+ + `"${detection.found.inputLocale}" — gen-l10n treats the template as the source. Using --source as given.`);
2306
+ }
2307
+
2308
+ // ── A file in the project marked local-only: the DEFAULT method is local ──
2309
+ // (lib/local-only-marks.js). Only the default: an explicit --method wins,
2310
+ // and over an existing file the method it holds is kept. Without a mark
2311
+ // nothing here changes (Round 14, hospital persona: three rounds of
2312
+ // `init --yes` wrote OpenRouter beside a set marked local-only).
2313
+ const localMark = localOnlyNotice(cwd, findLocalOnlyMarks(cwd).marks);
2314
+ const markDefaultsToLocal = !!localMark && !args.method && !existing;
2315
+
1146
2316
  // ── Choose mode: interactive wizard or silent defaults ──
1147
2317
  let config;
1148
2318
  let envVars = [];
1149
-
1150
- if (!args.yes && isInteractive()) {
1151
- const result = await runInteractive(cwd, args);
2319
+ let apiSetup = null;
2320
+
2321
+ let addedCodes = null; // --force over a file: the targets new to it (registers recorded for these only)
2322
+ if (!args.yes && isInteractive() && args.method !== API_METHOD) {
2323
+ // Over an existing file the wizard starts from its values (Enter keeps them).
2324
+ const wizardArgs = existing ? prefillFromConfig(args, existing, relayout) : args;
2325
+ const result = await runInteractive(cwd, wizardArgs, detection, existing ? registersOf(existing) : null,
2326
+ existing ? null : localMark);
1152
2327
  if (!result) return 0; // User cancelled
1153
- config = result.config;
1154
- envVars = result.envVars;
2328
+ config = existing ? mergeWizardConfig(existing, result.config) : result.config;
2329
+ envVars = existing ? collectRequiredEnvVars(config.defaultMethod || 'llm', config.languages) : result.envVars;
2330
+ if (existing) addedCodes = new Set();
1155
2331
  } else {
1156
2332
  // Say WHY the wizard was skipped — an agent (or CI) piping stdin
1157
2333
  // shouldn't have to guess which mode ran.
1158
- if (!args.yes) {
1159
- output.info('stdin is not a TTY — skipping the wizard, writing a default config.');
2334
+ if (!args.yes && args.method === API_METHOD && isInteractive()) {
2335
+ output.info('--method api is set up from flags (--endpoint, --langs) — skipping the wizard.');
2336
+ } else if (!args.yes) {
2337
+ output.info(existing
2338
+ ? `stdin is not a TTY — skipping the wizard: the flags say what to change in ${DEFAULT_CONFIG_FILENAME}.`
2339
+ : 'stdin is not a TTY — skipping the wizard, writing a default config.');
1160
2340
  output.raw(' Configure via flags (--langs, --method, --model, ...); see `champollion init --help`.');
1161
2341
  }
1162
- config = await buildDefaultConfig(args);
2342
+ if (markDefaultsToLocal) {
2343
+ output.info(`Method: local — ${localMark.why}`);
2344
+ output.raw(` ${localMark.hosted}`);
2345
+ output.raw(` ${localMark.needs}`);
2346
+ }
2347
+ config = await buildDefaultConfig({ ...args, source: sourceLocale, ...(markDefaultsToLocal && { method: 'local' }) });
2348
+ applyDetection(config, detection, args);
2349
+ // Over an existing file: the file, with only what the flags name rewritten.
2350
+ if (existing) {
2351
+ const overlaid = overlayFlagsOnConfig(existing, config, args, { relayout, detection });
2352
+ config = overlaid.config;
2353
+ addedCodes = new Set(overlaid.added);
2354
+ }
2355
+ // --method api: one pair per target, the shape forge's DEPLOY.md shows.
2356
+ if (args.method === API_METHOD) {
2357
+ const listed = Array.isArray(config.languages) ? config.languages : Object.keys(config.languages || {});
2358
+ const targets = listed.length > 0 ? listed : (detection.found?.targets || []);
2359
+ if (targets.length === 0) {
2360
+ output.error('--method api needs the target languages: the endpoint is set per pair. Add --langs <codes> (e.g. --langs abc).');
2361
+ return 1;
2362
+ }
2363
+ const endpoint = String(args.endpoint).trim();
2364
+ const instructions = resolveAcceptsInstructions(args, cwd);
2365
+ writeApiPairs(config, targets, endpoint, instructions.value);
2366
+ apiSetup = { endpoint, targets, instructions };
2367
+ }
2368
+ const scriptPlan = applyScriptChoices(config, args);
2369
+ if (scriptPlan.error) {
2370
+ output.error(scriptPlan.error);
2371
+ return 1;
2372
+ }
2373
+ for (const { code, choices } of scriptPlan.needed) {
2374
+ const options = choices.map(c => `"${c.script}" (${c.label})`).join(' or ');
2375
+ // The entry as the file will hold it, with the script added — one
2376
+ // field in the file, never a re-run of init (Round 10: `init --force`
2377
+ // advised for one setting rewrote the whole config).
2378
+ const prior = Array.isArray(config.languages) ? undefined : config.languages[code];
2379
+ const register = typeof prior === 'string' ? prior : (prior && typeof prior === 'object' && prior.register) || defaultRegisterKey(code);
2380
+ const entry = { ...(prior && typeof prior === 'object' ? prior : {}), ...(register && { register }), script: choices[0].script };
2381
+ output.warn(`${code} is written in more than one orthography — ${options}. Champollion will not choose one for a `
2382
+ + 'community, so `champollion sync` refuses to translate it until the config says which. Choose it in '
2383
+ + `${DEFAULT_CONFIG_FILENAME}: add "script" to ${code}'s entry in "languages" — `
2384
+ + `"${code}": ${JSON.stringify(entry).replace(/,"/g, ', "').replace(/":/g, '": ')} (or "script": "${choices[1].script}").`);
2385
+ }
1163
2386
  envVars = collectRequiredEnvVars(config.defaultMethod || 'llm', config.languages);
2387
+ // An api endpoint off this machine needs its bearer key (a loopback
2388
+ // server started without a token needs none — lib/methods/api.js).
2389
+ if (apiSetup) {
2390
+ let host = '';
2391
+ try { host = new URL(apiSetup.endpoint).hostname; } catch { /* validated above */ }
2392
+ if (!LOOPBACK_HOSTS.has(host)) envVars.push({ envVar: API_KEY_ENV, label: 'champollion API endpoint' });
2393
+ }
1164
2394
 
1165
2395
  // Typos in --langs write configs that only break at first sync — warn now.
1166
- for (const code of Array.isArray(config.languages) ? config.languages : []) {
1167
- if (!getLanguageCard(code) && !DEFAULT_REGISTERS[code]) {
1168
- output.warn(`Unrecognized language code "${code}" — kept in config, but check the spelling.`);
2396
+ // A private-use code (qaa–qtz) is not a typo: it is the range kept for a
2397
+ // variety with no confirmed code (Round 8 personas: "check the spelling"
2398
+ // for the code the guide recommends).
2399
+ const named = parseNameFlag(args.name).map;
2400
+ for (const code of Array.isArray(config.languages) ? config.languages : Object.keys(config.languages || {})) {
2401
+ // Flutter writes locales with "_" (pt_BR); the cards know "pt-BR".
2402
+ if (getLanguageCard(code) || getLanguageCard(code.replace(/_/g, '-')) || DEFAULT_REGISTERS[code]) continue;
2403
+ if (isPrivateUseCode(code)) {
2404
+ output.info(`"${code}" is a private-use code (ISO 639 keeps qaa–qtz for a variety with no confirmed code): it has no language card, `
2405
+ + 'so no register presets, plural rules or script come from one. '
2406
+ + (named[code] ? `Its name in prompts and reports: "${named[code]}".` : `Give it a name, which is what the model is told: --name ${code}="<display name>".`));
2407
+ continue;
1169
2408
  }
2409
+ output.warn(`Unrecognized language code "${code}" — kept in config, but check the spelling.`);
2410
+ }
2411
+ }
2412
+
2413
+ // --name code="Display name": written into the language's entry, so prompts
2414
+ // and reports name a language that has no card (a private-use code).
2415
+ {
2416
+ const { error } = applyDisplayNames(config, args);
2417
+ if (error) {
2418
+ output.error(error);
2419
+ return 1;
1170
2420
  }
1171
2421
  }
1172
2422
 
2423
+ // Each target's register goes into the file, not only into this output.
2424
+ // Over an existing file: only for the targets new to it (the others keep
2425
+ // their entries exactly as the file has them).
2426
+ recordRegisters(config, addedCodes);
2427
+
2428
+ // Both shapes on disk (en.json AND en/…): the detection's choice is
2429
+ // written down so sync never has to guess between them (an existing
2430
+ // file's own choice stands while its layout holds).
2431
+ if (relayout && detection.found?.ambiguous && config.localesDir
2432
+ && path.resolve(cwd, config.localesDir) === path.resolve(cwd, detection.found.localesDir)) {
2433
+ config.localesLayout = detection.found.layout;
2434
+ }
2435
+
2436
+ // ── Never write a localesDir that does not exist ──
2437
+ // Nothing found and no --dir: create the (default) directory so the
2438
+ // config is valid, and the next steps below say what to put in it.
2439
+ // Docusaurus is the exception: `docusaurus write-translations` creates
2440
+ // i18n/ with the files sync needs, and an empty one would only hide that.
2441
+ // A localesPattern (Flutter, gettext) was detected from files on disk:
2442
+ // its folder exists, and nothing is created — the pattern is the answer.
2443
+ const patternAbs = config.localesPattern ? path.resolve(cwd, config.localesPattern) : null;
2444
+ const localesAbs = patternAbs ? compileLocalesPattern(patternAbs).base : path.resolve(cwd, config.localesDir);
2445
+ // (run() shadows configPath with the config file's path.)
2446
+ const localesLabel = projectRelative(cwd, localesAbs);
2447
+ let createdLocalesDir = false;
2448
+ if (!patternAbs && config.format !== 'docusaurus' && !fs.existsSync(localesAbs)) {
2449
+ fs.mkdirSync(localesAbs, { recursive: true });
2450
+ createdLocalesDir = true;
2451
+ }
2452
+
1173
2453
  // ── Write config ──
1174
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
1175
- output.ok(`Created ${DEFAULT_CONFIG_FILENAME}`);
2454
+ // Over an existing file: backed up first (never over an older backup),
2455
+ // then what changed and what was kept, field by field.
2456
+ if (freshProject) {
2457
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
2458
+ output.ok(`Created ${DEFAULT_CONFIG_FILENAME}`);
2459
+ } else {
2460
+ const { changed, kept } = existing ? describeConfigChanges(existing, config) : { changed: null, kept: [] };
2461
+ if (changed && changed.length === 0) {
2462
+ output.ok(`${DEFAULT_CONFIG_FILENAME} unchanged — it already says what the flags ask for (nothing written, no backup needed).`);
2463
+ } else {
2464
+ const backup = backupConfigFile(configPath, existingRaw);
2465
+ const backupName = projectRelative(cwd, backup.path).replace(/^\.\//, '');
2466
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
2467
+ if (!existing) {
2468
+ output.warn(`${DEFAULT_CONFIG_FILENAME} could not be read (${existingUnreadable}), so nothing in it could be kept: `
2469
+ + `wrote a new one from the flags and what is on disk. The old file is in ${backupName}${backup.reused ? ' (an earlier backup of the same file)' : ''} — copy back what you need.`);
2470
+ } else {
2471
+ output.ok(`Updated ${DEFAULT_CONFIG_FILENAME} — only what the flags ask for; the previous file is in ${backupName}`
2472
+ + `${backup.reused ? ' (an earlier backup of the same file)' : ''}.`);
2473
+ output.raw(' Changed:');
2474
+ for (const line of changed) output.raw(` ${line}`);
2475
+ if (kept.length > 0) output.raw(` Kept as they were: ${kept.join(', ')}`);
2476
+ }
2477
+ }
2478
+ }
2479
+ ensureCacheIgnored(cwd);
2480
+ if (createdLocalesDir) {
2481
+ output.ok(`Created ${localesLabel.replace(/^\.\//, '')}/ (no locale files were found to point at)`);
2482
+ }
2483
+
2484
+ // ── Create the target locale files --langs asked for ──
2485
+ // Empty files of the right format, in the detected layout (fr.json, or
2486
+ // fr/common.json + fr/admin.json mirroring en/). Nobody should have to
2487
+ // hand-create an empty fr.json; sync fills them. Needs the source on
2488
+ // disk — the source's files decide the namespaces and the format.
2489
+ const targetCodes = Array.isArray(config.languages) ? config.languages : Object.keys(config.languages || {});
2490
+ let sourceReady = config.format === 'docusaurus';
2491
+ if (config.format !== 'docusaurus') {
2492
+ const layout = discoverLocaleLayout({
2493
+ inputLocale: config.inputLocale, localesDir: localesAbs, format: config.format,
2494
+ ...(patternAbs ? { localesPattern: patternAbs } : { localesLayout: config.localesLayout || null }),
2495
+ }, { cwd });
2496
+ sourceReady = layout.namespaced
2497
+ ? layout.sourceFiles.length > 0
2498
+ : fs.existsSync(layout.sourceFiles[0].path);
2499
+ if (sourceReady && targetCodes.length > 0) {
2500
+ const { created, unsupported, refused } = createMissingTargetFiles(layout, targetCodes);
2501
+ if (created.length > 0) {
2502
+ // Paths from the project root (locale/fr/LC_MESSAGES/django.po), not
2503
+ // from the locale folder: "fr/LC_MESSAGES/django.po" named no real
2504
+ // path (Round 3, Django persona).
2505
+ const shown = created.slice(0, 6).map(f => projectRelative(cwd, f.path).replace(/^\.\//, '')).join(', ');
2506
+ const more = created.length > 6 ? `, +${created.length - 6} more` : '';
2507
+ output.ok(`Created ${created.length} empty target file(s): ${shown}${more} — \`champollion sync\` fills them`);
2508
+ }
2509
+ for (const f of unsupported) {
2510
+ output.warn(`Did not create ${projectRelative(cwd, f.path).replace(/^\.\//, '')}: this version cannot write "${f.format}" files yet.`);
2511
+ }
2512
+ for (const f of refused) {
2513
+ output.warn(`Did not create ${f.rel}: the path leaves ${localesLabel}/ — check the language code.`);
2514
+ }
2515
+ }
2516
+ // Flutter: the app's own messages come from these ARB files, but
2517
+ // Material/Cupertino widget text comes from flutter_localizations, which
2518
+ // covers a fixed list of languages — a target outside it needs a
2519
+ // fallback delegate (Round 11, hospital persona; lib/flutter-locales.js).
2520
+ if (targetCodes.length > 0 && (layout.sourceFiles[0]?.format === 'arb' || /\.arb$/i.test(layout.sourceFiles[0]?.path || ''))) {
2521
+ for (const { level, text } of flutterLocaleLines(targetCodes)) output[level](text);
2522
+ }
2523
+ }
2524
+ // Django: a locale/ beside manage.py belongs to no app, so Django reads it
2525
+ // only through LOCALE_PATHS, and offers only the languages in LANGUAGES.
2526
+ // Without them CI compiled and committed catalogs the site never showed
2527
+ // (Round 12, Django persona).
2528
+ if (isDjangoRootLocale(cwd)) {
2529
+ output.info('Django reads locale/ only when LOCALE_PATHS in your settings names it, and offers only the languages '
2530
+ + 'LANGUAGES lists (its default: every language Django ships with): '
2531
+ + 'https://champollion.dev/docs/integrations/frameworks#django-locale-paths');
2532
+ }
1176
2533
  output.raw('');
1177
2534
 
1178
2535
  // ── Show register summary when languages are configured ──
2536
+ // What the config now says for each target, the other presets it could
2537
+ // say, and how to change it (Round 5, i18next persona).
1179
2538
  const langCount = Array.isArray(config.languages) ? config.languages.length : Object.keys(config.languages).length;
1180
2539
  if (langCount > 0) {
1181
- output.raw(' Registers:');
1182
- if (typeof config.languages === 'object' && !Array.isArray(config.languages)) {
1183
- // Object form — show preset keys
1184
- for (const [code, value] of Object.entries(config.languages)) {
1185
- const card = getLanguageCard(code);
1186
- const name = card?.name || code;
1187
- if (typeof value === 'string') {
1188
- // Bare preset key
1189
- output.raw(` ${code.padEnd(6)} ${name} → ${value}`);
1190
- } else if (typeof value === 'object' && value.register) {
1191
- output.raw(` ${code.padEnd(6)} ${name} → ${value.register}`);
1192
- } else {
1193
- output.raw(` ${code.padEnd(6)} ${name} → (default)`);
2540
+ output.raw(' Registers (written to "languages" in the config):');
2541
+ const entries = Array.isArray(config.languages)
2542
+ ? config.languages.map(code => [code, {}])
2543
+ : Object.entries(config.languages);
2544
+ let example = null;
2545
+ let genderShown = false;
2546
+ for (const [code, value] of entries) {
2547
+ const card = getLanguageCard(code);
2548
+ const name = (value && typeof value === 'object' && typeof value.name === 'string' && value.name) || card?.name || code;
2549
+ const chosen = typeof value === 'string' ? value : (value && typeof value === 'object' ? value.register : null);
2550
+ const others = getRegisterPresets(resolveCode(code)).map(p => p.key).filter(k => k !== chosen);
2551
+ const shown = chosen
2552
+ ? (chosen.length > 40 ? `"${chosen.slice(0, 37)}…"` : chosen)
2553
+ : '(no presets — a professional register)';
2554
+ output.raw(` ${code.padEnd(6)} ${name} → ${shown}${others.length > 0 ? ` (others: ${others.join(', ')})` : ''}`);
2555
+ // The gender guidance its prompts carry by default (LLM methods) —
2556
+ // never a silent choice (Round 8: écriture inclusive was invisible).
2557
+ const ownGender = value && typeof value === 'object' ? value.genderGuidance : undefined;
2558
+ const genderSetting = ownGender !== undefined ? ownGender : config.genderGuidance;
2559
+ if (genderSetting === false) {
2560
+ output.raw(' gender: no guidance (set off in the config)');
2561
+ } else {
2562
+ const g = summarizeGenderGuidance(typeof genderSetting === 'string' ? genderSetting : getLanguageCard(resolveCode(code))?.gender?.inclusiveGuidance);
2563
+ if (g) {
2564
+ output.raw(` gender (${typeof genderSetting === 'string' ? 'your config' : 'default'}): ${g}`);
2565
+ genderShown = true;
1194
2566
  }
1195
2567
  }
1196
- } else {
1197
- // Array form — all defaults
1198
- for (const code of config.languages) {
1199
- const card = getLanguageCard(code);
1200
- const name = card?.name || code;
1201
- const defaultKey = card?.formality?.default || 'default';
1202
- output.raw(` ${code.padEnd(6)} ${name} → ${defaultKey}`);
1203
- }
2568
+ if (!example && others.length > 0 && typeof value === 'string') example = { code, preset: others[0] };
2569
+ }
2570
+ if (genderShown) {
2571
+ output.raw(' Gender guidance: "genderGuidance" in the config (per language, or for all) — false for none, or your own words.');
2572
+ }
2573
+ if (example) {
2574
+ output.raw(` To change one, edit "languages" in ${DEFAULT_CONFIG_FILENAME}: a preset name (e.g. "${example.code}": "${example.preset}")`);
2575
+ output.raw(' or your own words describing the tone and audience; `champollion status` shows what each pair uses.');
1204
2576
  }
1205
2577
  output.raw('');
1206
2578
  }
1207
2579
 
1208
2580
  // ── Next steps — skip env var instruction if already set ──
2581
+ // One blank line under the heading, none stacked: optional hints and the
2582
+ // numbered steps follow each other directly (two blank lines used to
2583
+ // open this list — hospital persona, 2026-10).
1209
2584
  output.raw(' Next steps:');
1210
2585
  output.raw('');
1211
2586
  let step = 1;
1212
2587
 
1213
2588
  // Only show env var setup if any required key is missing from the environment
1214
- const missingEnvVars = envVars.filter(({ envVar }) => !process.env[envVar]);
2589
+ // An optional variable (LOCAL_API_BASE has a working default) is a hint,
2590
+ // not a step: telling someone to "set your API key" for a local model
2591
+ // would send them looking for a key that does not exist.
2592
+ const isOptional = (envVar) => METHOD_OPTIONS.some(m => m.envVar === envVar && m.envOptional);
2593
+ for (const { envVar } of envVars.filter(({ envVar }) => isOptional(envVar) && !process.env[envVar])) {
2594
+ const opt = METHOD_OPTIONS.find(m => m.envVar === envVar);
2595
+ output.raw(` (Local model endpoint: export ${envVar}=${opt.envExample} — only if yours is elsewhere.)`);
2596
+ }
2597
+ const missingEnvVars = envVars.filter(({ envVar }) => !process.env[envVar] && !isOptional(envVar));
1215
2598
  if (missingEnvVars.length === 1) {
1216
2599
  output.raw(` ${step}. Set your API key:`);
1217
2600
  output.raw(` export ${missingEnvVars[0].envVar}=...`);
2601
+ // No key at all? A model on this machine needs none.
2602
+ if (missingEnvVars[0].envVar === 'OPENROUTER_API_KEY') {
2603
+ output.raw(' (or, with no key: a model on this machine — Ollama, LM Studio, a forge model:');
2604
+ output.raw(` set "defaultMethod": "local" and "model": "llama3.1" (your model's name) in ${DEFAULT_CONFIG_FILENAME})`);
2605
+ }
1218
2606
  step++;
1219
2607
  } else if (missingEnvVars.length > 1) {
1220
2608
  output.raw(` ${step}. Set your API key(s):`);
@@ -1224,36 +2612,250 @@ async function run(args, cwd) {
1224
2612
  step++;
1225
2613
  }
1226
2614
 
1227
- output.raw('');
2615
+ if (config.format === 'docusaurus' && !fs.existsSync(localesAbs)) {
2616
+ output.raw(` ${step}. Generate the i18n files: npx docusaurus write-translations --locale ${targetCodes[0] || '<lang>'}`);
2617
+ step++;
2618
+ } else if (!sourceReady && patternAbs) {
2619
+ const where = projectRelative(cwd, compileLocalesPattern(patternAbs).render(config.inputLocale, '<ns>'));
2620
+ output.raw(` ${step}. Put your source strings in ${where}`);
2621
+ step++;
2622
+ } else if (!sourceReady) {
2623
+ output.raw(` ${step}. Put your source strings in ${localesLabel}/${config.inputLocale}.json`
2624
+ + ' (or a folder of files: ' + `${localesLabel}/${config.inputLocale}/common.json)`);
2625
+ step++;
2626
+ }
1228
2627
  if (langCount === 0) {
1229
- output.raw(` ${step}. Add target locale files to your locales directory (e.g., fr.json)`);
1230
- output.raw(` ${step + 1}. Run: champollion status # verify your setup`);
1231
- output.raw(` ${step + 2}. Run: champollion sync # translate!`);
2628
+ const onDisk = detection.found && path.resolve(cwd, detection.found.localesDir) === localesAbs
2629
+ ? detection.found.targets : [];
2630
+ if (onDisk.length > 0) {
2631
+ output.raw(` ${step}. Target locales found on disk: ${onDisk.join(', ')} — sync translates into them.`);
2632
+ output.raw(` To add more: list every target in "languages" in ${DEFAULT_CONFIG_FILENAME} (e.g. "languages": ["fr", "de"]);`);
2633
+ output.raw(' sync creates the files a new one needs.');
2634
+ } else {
2635
+ output.raw(` ${step}. Choose target languages: list them in "languages" in ${DEFAULT_CONFIG_FILENAME}`);
2636
+ output.raw(' (e.g. "languages": ["fr", "de"]); sync creates their empty target files.');
2637
+ }
2638
+ step++;
2639
+ }
2640
+ output.raw(` ${step}. Run: champollion status # verify your setup`);
2641
+ output.raw(` ${step + 1}. Run: champollion sync # translate!`);
2642
+ // Which method the config uses, and how to choose another without editing
2643
+ // JSON — a persona hand-edited the config to pick its local model (Round 3).
2644
+ const chosenMethod = config.defaultMethod || 'llm';
2645
+ // A model served on this machine: a CI runner has none, so a workflow
2646
+ // copied as-is fails there (Round 11, i18next persona — the CI guide said
2647
+ // so, init never mentioned CI). One line: what CI needs, and where to read.
2648
+ let apiHost = '';
2649
+ try { apiHost = apiSetup ? new URL(apiSetup.endpoint).hostname : ''; } catch { /* validated */ }
2650
+ if (chosenMethod === 'local' || (apiSetup && LOOPBACK_HOSTS.has(apiHost))) {
2651
+ output.raw(` ${step + 2}. In CI: a runner has no model server — run a hosted method there (sync --method llm --model ${DEFAULT_OPENROUTER_MODEL}, `
2652
+ + `its key as a repository secret) or use a runner that can reach a model server (${chosenMethod === 'local' ? 'LOCAL_API_BASE' : 'the pair\'s "endpoint"'}): `
2653
+ + 'https://champollion.dev/docs/guides/ci-cd');
2654
+ }
2655
+ const chosenModel = config.model ? `, model ${config.model}` : '';
2656
+ output.raw('');
2657
+ if (apiSetup) {
2658
+ const { value, from } = apiSetup.instructions;
2659
+ output.raw(` Method: api, endpoint ${apiSetup.endpoint} — written as "pairs" for ${apiSetup.targets.map(t => `${config.inputLocale}:${t}`).join(', ')}.`);
2660
+ output.raw(typeof value === 'boolean'
2661
+ ? ` acceptsInstructions: ${value} (from ${from})${value ? '' : ' — the quality gate\'s retries go to the pair\'s "fallback", if you add one'}.`
2662
+ : ' acceptsInstructions: not stated — the text is sent alone. If the server follows per-key instructions, '
2663
+ + 'set "acceptsInstructions": true in those "pairs" entries (a model trained with nmt-forge does not: false).');
2664
+ output.raw(' A "fallback" method for the strings it cannot translate safely is added by hand: see the DEPLOY.md beside the model.');
1232
2665
  } else {
1233
- output.raw(` ${step}. Run: champollion status # verify your setup`);
1234
- output.raw(` ${step + 1}. Run: champollion sync # translate!`);
2666
+ // How to switch: the config field (or a pair's own), never a re-run of
2667
+ // init (Round 10, Next.js persona: `init --force --method … --model …`
2668
+ // printed here rewrote the whole config). --method/--model on sync try
2669
+ // one for a single run.
2670
+ output.raw(` Method: ${chosenMethod}${chosenModel}. To use another, edit "defaultMethod" and "model" in ${DEFAULT_CONFIG_FILENAME}`);
2671
+ output.raw(' (or a pair\'s own "method"/"model" in "pairs"). To try one for a single run: champollion sync --method <name> --model <model>');
2672
+ output.raw(' — the file is not changed. `champollion init --help` lists the methods.');
2673
+ }
2674
+ // Where the strings go — said at init, with how to keep them here
2675
+ // (Round 10, hospital persona: the default sent them to a hosted model and
2676
+ // nothing at init said so).
2677
+ const destination = whereTextGoes(config, apiSetup);
2678
+ if (destination) {
2679
+ output.raw(` Where the text goes: ${chosenMethod} sends every string it translates to ${destination}.`);
2680
+ // A hosted method beside a local-only mark came from --method, the
2681
+ // wizard or the existing file — never from the default; said, with the file.
2682
+ if (localMark) output.raw(` Note: ${localMark.why}`);
2683
+ output.raw(' To keep it on this machine, use a model served here (Ollama, LM Studio, vLLM): "defaultMethod": "local" and');
2684
+ output.raw(' "model": "<its name>" in the config — or try it for one run: champollion sync --method local --model <its name>.');
2685
+ output.raw(' A model served by `nmt-forge serve`: a pair\'s "method": "api" with its "endpoint" (`champollion init --help`).');
1235
2686
  }
1236
2687
 
1237
2688
  // ── Evidence hint — published results + live engine availability per pair ──
1238
- if (langCount > 0) {
1239
- const firstTarget = Array.isArray(config.languages)
1240
- ? config.languages[0]
1241
- : Object.keys(config.languages)[0];
2689
+ // The configured targets, never an example code: a project that targets
2690
+ // only abc was told to `xliff export --locale fr` (hospital persona,
2691
+ // 2026-10). The canonical grouped command form, as the docs write it.
2692
+ const configuredTargets = Array.isArray(config.languages)
2693
+ ? config.languages
2694
+ : Object.keys(config.languages || {});
2695
+ const firstTarget = configuredTargets[0] || null;
2696
+ if (firstTarget) {
1242
2697
  output.raw('');
1243
2698
  output.raw(' Evidence for your pairs (published results + what’s runnable now):');
1244
- output.raw(` champollion recommend ${config.inputLocale} ${firstTarget}`);
2699
+ for (const code of configuredTargets.slice(0, 3)) {
2700
+ output.raw(` champollion network recommend ${config.inputLocale} ${code}`);
2701
+ }
2702
+ if (configuredTargets.length > 3) output.raw(` … and the same for ${configuredTargets.slice(3).join(', ')}`);
1245
2703
  }
1246
2704
 
1247
2705
  // ── Cost-saving tips — help users discover TM early ──
1248
2706
  output.raw('');
1249
2707
  output.raw(' After your first sync:');
1250
2708
  output.raw(' champollion tm stats # see cached translations');
1251
- output.raw(' champollion xliff export --locale fr # export for human review');
2709
+ if (firstTarget) {
2710
+ output.raw(` champollion xliff export --locale ${firstTarget} # export for human review`);
2711
+ }
1252
2712
  output.raw('');
1253
2713
  output.raw(' Translations are cached in .champollion/tm.json \u2014 re-running sync');
1254
2714
  output.raw(' only calls the API for keys that actually changed.');
2715
+ if (freshProject) {
2716
+ output.raw('');
2717
+ output.raw(` ${licenseLine()}`);
2718
+ }
1255
2719
 
1256
2720
  return 0;
1257
2721
  }
1258
2722
 
1259
- export { run, parseLanguageInput, buildDefaultConfig, buildConfig };
2723
+ /**
2724
+ * The register preset a language's card makes the default, or null when the
2725
+ * language has no presets. Resolved like config.js resolves it at run time
2726
+ * (resolveCode, then the card), so what init writes is what sync would use.
2727
+ *
2728
+ * @param {string} code
2729
+ * @returns {string|null}
2730
+ */
2731
+ function defaultRegisterKey(code) {
2732
+ const presets = getRegisterPresets(resolveCode(code));
2733
+ return (presets.find(p => p.isDefault) || presets[0])?.key || null;
2734
+ }
2735
+
2736
+ /**
2737
+ * Write each target's register into the config — the object form `languages`
2738
+ * already supports ({ "fr": "formal-vous", "es": "neutral-latam" }) — so the
2739
+ * choice is visible and editable in the file, not only in init's output
2740
+ * (Round 5, i18next persona: init printed "es → neutral-latam" but the config
2741
+ * said only ["fr","es"], so a team targeting Spain would not notice the
2742
+ * Latin-American default). The values are the presets sync would have used
2743
+ * anyway: behaviour does not change, only what the file shows.
2744
+ *
2745
+ * A list stays a list when no target has presets (nothing to show), and an
2746
+ * empty list (auto-detect from the folder) stays empty. Object-form entries
2747
+ * keep any register already chosen (the wizard); `{}` becomes the preset, and
2748
+ * an entry with other fields (method, script) gets a `register` beside them.
2749
+ * `onlyCodes` limits it to some targets — the ones `init --force` adds to an
2750
+ * existing file, whose other entries stay exactly as they were.
2751
+ *
2752
+ * @param {object} config - Mutated
2753
+ * @param {Set<string>|null} [onlyCodes]
2754
+ * @returns {object} config
2755
+ */
2756
+ function recordRegisters(config, onlyCodes = null) {
2757
+ const langs = config.languages;
2758
+ const eligible = (code) => !onlyCodes || onlyCodes.has(code);
2759
+ if (Array.isArray(langs)) {
2760
+ if (langs.length === 0 || !langs.some(code => eligible(code) && defaultRegisterKey(code))) return config;
2761
+ const obj = {};
2762
+ for (const code of langs) obj[code] = (eligible(code) && defaultRegisterKey(code)) || {};
2763
+ config.languages = obj;
2764
+ return config;
2765
+ }
2766
+ if (langs && typeof langs === 'object') {
2767
+ for (const [code, value] of Object.entries(langs)) {
2768
+ if (!eligible(code)) continue;
2769
+ if (!value || typeof value !== 'object' || value.register) continue;
2770
+ const preset = defaultRegisterKey(code);
2771
+ if (!preset) continue;
2772
+ if (Object.keys(value).length === 0) langs[code] = preset;
2773
+ else value.register = preset;
2774
+ }
2775
+ }
2776
+ return config;
2777
+ }
2778
+
2779
+ /** Where the CLI's license text is published (also LICENSE in the npm package). */
2780
+ const LICENSE_URL = 'https://github.com/gamedaysuits/Champollion/blob/main/cli/LICENSE';
2781
+
2782
+ /**
2783
+ * The one plain-language page on who each package's license covers
2784
+ * (cli/website/docs/getting-started/who-may-use-this.md) — the shop, clinic
2785
+ * and Django-clinic personas could not tell from "noncommercial" alone
2786
+ * whether they were covered (every round through Round 14).
2787
+ */
2788
+ const WHO_MAY_USE_URL = 'https://champollion.dev/docs/getting-started/who-may-use-this';
2789
+
2790
+ /**
2791
+ * The license lines init prints once, on a fresh project: nothing on the setup
2792
+ * path said the CLI is noncommercial (Round 5, Next.js persona — a store's
2793
+ * codebase). The license id is read from package.json (the SSOT); the plain
2794
+ * words are only said for the license they describe.
2795
+ *
2796
+ * @returns {string}
2797
+ */
2798
+ function licenseLine() {
2799
+ const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf-8'));
2800
+ if (pkg.license === 'PolyForm-Noncommercial-1.0.0') {
2801
+ return 'License: PolyForm Noncommercial 1.0.0 — free for noncommercial use; using it for a commercial purpose '
2802
+ + 'is not covered by this license.\n'
2803
+ + ` Who may use it (a school, a public hospital or clinic, a charity, a personal project — not a business's product): ${WHO_MAY_USE_URL}\n`
2804
+ + ` The license text governs: ${LICENSE_URL}`;
2805
+ }
2806
+ return `License: ${pkg.license} — see LICENSE in the champollion package (${LICENSE_URL}). Who may use it: ${WHO_MAY_USE_URL}`;
2807
+ }
2808
+
2809
+ /**
2810
+ * A Django project (manage.py, as lib/locale-layout.js detectGettextLayout
2811
+ * tells Django apart) with a locale/ folder at its root — the folder Django
2812
+ * loads only when LOCALE_PATHS names it.
2813
+ */
2814
+ function isDjangoRootLocale(cwd) {
2815
+ try {
2816
+ return fs.statSync(path.join(cwd, 'manage.py')).isFile()
2817
+ && fs.statSync(path.join(cwd, 'locale')).isDirectory();
2818
+ } catch {
2819
+ return false;
2820
+ }
2821
+ }
2822
+
2823
+ /** .gitignore lines that already keep `.champollion/` out of git. */
2824
+ const CACHE_IGNORE_LINES = new Set([
2825
+ '.champollion', '.champollion/', '/.champollion', '/.champollion/',
2826
+ '.champollion/*', '/.champollion/*', '.champollion/**', '/.champollion/**',
2827
+ ]);
2828
+
2829
+ /**
2830
+ * Keep the translation cache out of version control. `.champollion/` holds the
2831
+ * per-machine Translation Memory; the CI guide's `git add --all` would commit
2832
+ * it in a repo that does not ignore it. The lock files stay tracked (they are
2833
+ * how the next run knows what changed).
2834
+ *
2835
+ * ALWAYS, not only in a git repo. It used to skip a folder with no `.git` —
2836
+ * and a project that ran `git init` after `champollion init` (or lives in a
2837
+ * subfolder of a repo) then committed the cache with its first
2838
+ * `git add --all` (synthetic Django/i18next personas, 2026-10). A .gitignore
2839
+ * in a folder that is not (yet) a repo costs nothing. Idempotent: never
2840
+ * duplicates a line that already covers the folder.
2841
+ */
2842
+ function ensureCacheIgnored(cwd) {
2843
+ const gitignore = path.join(cwd, '.gitignore');
2844
+ const exists = fs.existsSync(gitignore);
2845
+ const current = exists ? fs.readFileSync(gitignore, 'utf-8') : '';
2846
+ const covered = current.split(/\r?\n/).map((l) => l.trim()).some((l) => CACHE_IGNORE_LINES.has(l));
2847
+ if (covered) return;
2848
+ const lead = current && !current.endsWith('\n') ? '\n' : '';
2849
+ const gap = current ? '\n' : '';
2850
+ fs.writeFileSync(gitignore,
2851
+ `${current}${lead}${gap}# champollion: per-machine translation cache (commit the .champollion*.lock files)\n.champollion/\n`,
2852
+ 'utf-8');
2853
+ output.ok(`${exists ? 'Added .champollion/ to' : 'Created'} .gitignore `
2854
+ + `(${exists ? '' : 'ignoring .champollion/: '}the translation cache is per-machine; the lock files stay tracked)`);
2855
+ }
2856
+
2857
+ export {
2858
+ run, parseLanguageInput, buildDefaultConfig, buildConfig,
2859
+ detectLocaleSetup, describeLocaleSetupHint, FRAMEWORK_LAYOUTS, GENERIC_LOCALE_DIRS,
2860
+ mergeWizardConfig, describeConfigChanges,
2861
+ };