champollion 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -26
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +82 -37
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +34 -0
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +286 -85
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1709 -101
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +632 -125
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +15 -9
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +230 -17
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +284 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1136 -345
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +194 -35
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +727 -79
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +975 -262
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +1102 -0
- package/lib/file-scope.js +106 -0
- package/lib/flatten.js +80 -3
- package/lib/flutter-locales.js +124 -0
- package/lib/format.js +266 -12
- package/lib/hash.js +146 -21
- package/lib/icu-structure.js +929 -0
- package/lib/integrity.js +223 -75
- package/lib/language-pair.js +157 -0
- package/lib/lint.js +78 -16
- package/lib/local-only-marks.js +106 -0
- package/lib/locale-layout.js +1103 -0
- package/lib/locale-state.js +575 -0
- package/lib/methods/anthropic.js +5 -0
- package/lib/methods/apertium.js +6 -3
- package/lib/methods/api.js +138 -25
- package/lib/methods/base.js +17 -0
- package/lib/methods/coaching-data.js +153 -0
- package/lib/methods/content-separator.js +43 -0
- package/lib/methods/deepl.js +1 -1
- package/lib/methods/direct-llm.js +257 -103
- package/lib/methods/external.js +146 -63
- package/lib/methods/gemini.js +1 -0
- package/lib/methods/google-translate.js +1 -0
- package/lib/methods/http-utils.js +41 -0
- package/lib/methods/libretranslate.js +7 -2
- package/lib/methods/llm-coached.js +71 -129
- package/lib/methods/llm.js +87 -33
- package/lib/methods/local.js +93 -10
- package/lib/methods/microsoft-translator.js +1 -2
- package/lib/methods/openai.js +4 -2
- package/lib/methods/openrouter-client.js +20 -19
- package/lib/methods/openrouter-pricing.js +150 -13
- package/lib/methods/prompt-methods.js +20 -0
- package/lib/methods/provider-pricing.js +42 -1
- package/lib/methods/request-capture.js +104 -0
- package/lib/methods/tilde.js +1 -1
- package/lib/methods/translated.js +1 -2
- package/lib/missing-key.js +93 -0
- package/lib/models.js +102 -55
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +6 -1
- package/lib/seal.mjs +4 -3
- package/lib/sealed-qualifier.mjs +1 -1
- package/lib/segment.js +24 -8
- package/lib/seo.js +19 -9
- package/lib/serve.js +43 -6
- package/lib/shared-output-seed.js +164 -0
- package/lib/source-contexts.js +39 -0
- package/lib/submit.mjs +57 -5
- package/lib/sync.js +2923 -474
- package/lib/terminology.js +13 -4
- package/lib/tm-evict.js +179 -0
- package/lib/tm-seed.js +5 -2
- package/lib/tm.js +818 -36
- package/lib/translate-pair.js +639 -34
- package/lib/translate.js +78 -5
- package/lib/types.js +22 -3
- package/lib/validate.js +996 -21
- package/lib/verify.js +1332 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +1 -1
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/cards-fallback.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/licenses.json +99 -3
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/retired-model-aliases.json +9 -0
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +8 -2
- package/shared/schemas/human-services.schema.json +1 -1
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- package/shared/schemas/retired-model-aliases.schema.json +32 -0
- package/shared/docent/corpus.json +0 -11739
- package/shared/model-aliases.json +0 -7
- package/shared/schemas/model-aliases.schema.json +0 -27
package/lib/commands/init.js
CHANGED
|
@@ -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,
|
|
33
|
+
import { DEFAULT_REGISTERS, getLanguageCard, getRegisterPresets, isMethodSupported, resolveCode, summarizeGenderGuidance, isPrivateUseCode } from '../registers.js';
|
|
34
34
|
import { getConverterInfo, resolveTargetScript, converterKeyForLocale } from '../scripts.js';
|
|
35
|
-
import { fetchAvailableModels, resolveProviderApiKey, isListableProvider, getProviderLabel } from '../models.js';
|
|
35
|
+
import { fetchAvailableModels, resolveProviderApiKey, isListableProvider, getProviderLabel, requireExactModelId } 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()
|
|
587
|
+
const parts = input.split(',').map(s => s.trim()).filter(Boolean);
|
|
158
588
|
|
|
159
589
|
for (const part of parts) {
|
|
160
|
-
|
|
590
|
+
const preset = LANGUAGE_PRESETS[part.toLowerCase()];
|
|
591
|
+
if (preset) {
|
|
161
592
|
// Expand preset into individual codes
|
|
162
|
-
for (const code of
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -470,20 +953,21 @@ async function pickModelForProvider(rl, method, presetModel = null) {
|
|
|
470
953
|
}
|
|
471
954
|
console.log('');
|
|
472
955
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
956
|
+
// A typed id is checked here, as --model is: an alias or a floating id is
|
|
957
|
+
// refused with the exact slug to write, and asked again — not written into
|
|
958
|
+
// the config to fail at the first sync.
|
|
959
|
+
for (;;) {
|
|
960
|
+
const modelChoice = await ask(rl, 'Choose (number or model ID)', '1');
|
|
961
|
+
const modelNum = parseInt(modelChoice, 10);
|
|
962
|
+
if (modelNum >= 1 && modelNum <= models.length) return models[modelNum - 1];
|
|
963
|
+
if (!modelChoice.trim()) return models[0];
|
|
964
|
+
try {
|
|
965
|
+
requireExactModelId(modelChoice.trim(), { from: 'typed' });
|
|
966
|
+
return modelChoice.trim();
|
|
967
|
+
} catch (err) {
|
|
968
|
+
console.log(` ${err.message}`);
|
|
969
|
+
}
|
|
483
970
|
}
|
|
484
|
-
|
|
485
|
-
// Empty input — use the first model from the list
|
|
486
|
-
return models[0];
|
|
487
971
|
}
|
|
488
972
|
|
|
489
973
|
/**
|
|
@@ -526,14 +1010,8 @@ async function pickPerLanguageMethod(rl, languages, presetModel = null) {
|
|
|
526
1010
|
|
|
527
1011
|
// Check if this method supports the language via card metadata.
|
|
528
1012
|
// WHY: A user picking DeepL for Swahili should know it's not supported.
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
}
|
|
1013
|
+
if (isMethodSupported(code, selected.method) === false) {
|
|
1014
|
+
console.log(` ⚠ ${selected.label} may not support ${name}. Consider LLM instead.`);
|
|
537
1015
|
}
|
|
538
1016
|
|
|
539
1017
|
// Build per-language config entry
|
|
@@ -578,7 +1056,7 @@ async function pickPerLanguageMethod(rl, languages, presetModel = null) {
|
|
|
578
1056
|
* @param {string[]} languages - Target language codes
|
|
579
1057
|
* @returns {object|null} Map of code → preset key, or null if no languages
|
|
580
1058
|
*/
|
|
581
|
-
async function stepRegisters(rl, languages) {
|
|
1059
|
+
async function stepRegisters(rl, languages, current = null) {
|
|
582
1060
|
if (languages.length === 0) return null;
|
|
583
1061
|
|
|
584
1062
|
console.log('');
|
|
@@ -601,7 +1079,15 @@ async function stepRegisters(rl, languages) {
|
|
|
601
1079
|
const system = card?.formality?.system || null;
|
|
602
1080
|
const systemLabel = system ? ` [${system}]` : '';
|
|
603
1081
|
|
|
604
|
-
|
|
1082
|
+
// `init --force` over a file: the register it already names is the
|
|
1083
|
+
// default here, so Enter keeps it (a custom register was lost).
|
|
1084
|
+
const kept = current && typeof current[code] === 'string' ? current[code] : null;
|
|
1085
|
+
if (kept) {
|
|
1086
|
+
selectedPresets[code] = kept;
|
|
1087
|
+
const preset = presets.find(p => p.key === kept);
|
|
1088
|
+
console.log(` ${code.padEnd(6)} ${name}${systemLabel}`);
|
|
1089
|
+
console.log(` → ${preset ? preset.label : `"${kept.length > 50 ? `${kept.slice(0, 47)}…` : kept}"`} (from your config)`);
|
|
1090
|
+
} else if (presets.length > 0) {
|
|
605
1091
|
const defaultPreset = presets.find(p => p.isDefault) || presets[0];
|
|
606
1092
|
// Explicitly store the default preset key — makes config self-documenting
|
|
607
1093
|
selectedPresets[code] = defaultPreset.key;
|
|
@@ -765,7 +1251,8 @@ async function stepTemperature(rl, defaultMethod, presetTemp = null) {
|
|
|
765
1251
|
}
|
|
766
1252
|
|
|
767
1253
|
/**
|
|
768
|
-
* Step 5: Content translation —
|
|
1254
|
+
* Step 5: Content translation — a folder of Markdown/MDX (Hugo's content/,
|
|
1255
|
+
* a docs or newsletter folder), Docusaurus, or none.
|
|
769
1256
|
*/
|
|
770
1257
|
async function stepContent(rl, cwd) {
|
|
771
1258
|
console.log('');
|
|
@@ -775,7 +1262,7 @@ async function stepContent(rl, cwd) {
|
|
|
775
1262
|
console.log(' Do you have Markdown content to translate?');
|
|
776
1263
|
console.log('');
|
|
777
1264
|
console.log(' 1. No — key-value locale files only');
|
|
778
|
-
console.log(' 2. Yes — Hugo content
|
|
1265
|
+
console.log(' 2. Yes — a folder of Markdown/MDX (Hugo content/, docs, newsletters…)');
|
|
779
1266
|
|
|
780
1267
|
// Auto-detect Docusaurus
|
|
781
1268
|
const hasDocusaurus = detectDocusaurus(cwd);
|
|
@@ -790,7 +1277,9 @@ async function stepContent(rl, cwd) {
|
|
|
790
1277
|
const choice = await ask(rl, 'Choose', hasDocusaurus ? '3' : '1');
|
|
791
1278
|
|
|
792
1279
|
if (choice === '2') {
|
|
793
|
-
|
|
1280
|
+
// Default to a folder that really holds Markdown (suggestContentDirs).
|
|
1281
|
+
const seen = suggestContentDirs(cwd);
|
|
1282
|
+
const contentDir = await ask(rl, 'Folder of Markdown/MDX files', seen.length > 0 ? `./${seen[0].dir}` : './content');
|
|
794
1283
|
return { contentDir, format: null };
|
|
795
1284
|
}
|
|
796
1285
|
|
|
@@ -835,7 +1324,9 @@ async function stepConfirm(rl, config, envVars) {
|
|
|
835
1324
|
}
|
|
836
1325
|
}
|
|
837
1326
|
|
|
838
|
-
console.log(
|
|
1327
|
+
console.log(config.localesPattern
|
|
1328
|
+
? ` Locale files: ${config.localesPattern}`
|
|
1329
|
+
: ` Locales dir: ${config.localesDir}`);
|
|
839
1330
|
console.log(` Format: ${config.format}`);
|
|
840
1331
|
|
|
841
1332
|
// Show default method if not the default 'llm'
|
|
@@ -886,7 +1377,7 @@ function buildConfig(answers) {
|
|
|
886
1377
|
const {
|
|
887
1378
|
source, languages, defaultMethod, defaultModel,
|
|
888
1379
|
perLanguage, customRegisters, temperature,
|
|
889
|
-
localesDir, format, contentDir, scriptChoices,
|
|
1380
|
+
localesDir, localesPattern = null, format, contentDir, scriptChoices,
|
|
890
1381
|
} = answers;
|
|
891
1382
|
|
|
892
1383
|
const hasPerLanguage = perLanguage && Object.keys(perLanguage).length > 0;
|
|
@@ -933,7 +1424,8 @@ function buildConfig(answers) {
|
|
|
933
1424
|
const config = {
|
|
934
1425
|
version: 3,
|
|
935
1426
|
inputLocale: source,
|
|
936
|
-
|
|
1427
|
+
// A pattern (Flutter, gettext) fixes the directory; never write both.
|
|
1428
|
+
...(localesPattern ? { localesPattern } : { localesDir }),
|
|
937
1429
|
languages: languagesConfig,
|
|
938
1430
|
batchSize: DEFAULT_BATCH_SIZE,
|
|
939
1431
|
format,
|
|
@@ -970,7 +1462,7 @@ function buildConfig(answers) {
|
|
|
970
1462
|
* CLI flags (--langs, --source, --dir, --model, --temperature, --format)
|
|
971
1463
|
* prefill the wizard's defaults — Enter accepts them at each step.
|
|
972
1464
|
*/
|
|
973
|
-
async function runInteractive(cwd, args = {}) {
|
|
1465
|
+
async function runInteractive(cwd, args = {}, detection = null, currentRegisters = null, localMark = null) {
|
|
974
1466
|
const rl = readline.createInterface({
|
|
975
1467
|
input: process.stdin,
|
|
976
1468
|
output: process.stdout,
|
|
@@ -981,33 +1473,52 @@ async function runInteractive(cwd, args = {}) {
|
|
|
981
1473
|
console.log(' champollion — Project Setup');
|
|
982
1474
|
console.log(' ════════════════════════════════════════════════');
|
|
983
1475
|
|
|
984
|
-
// Step 1: Languages (includes orthography choices for dual-script locales)
|
|
985
|
-
|
|
1476
|
+
// Step 1: Languages (includes orthography choices for dual-script locales).
|
|
1477
|
+
// A Flutter template names the source (app_en.arb → en).
|
|
1478
|
+
const { source, languages, scriptChoices } = await stepLanguages(rl, {
|
|
1479
|
+
...args, source: args.source || detection?.found?.inputLocale,
|
|
1480
|
+
});
|
|
986
1481
|
|
|
987
1482
|
// Step 2: Registers — guided tone/formality per language.
|
|
988
1483
|
// Comes before method because register choice informs method selection.
|
|
989
|
-
const customRegisters = await stepRegisters(rl, languages);
|
|
1484
|
+
const customRegisters = await stepRegisters(rl, languages, currentRegisters);
|
|
990
1485
|
|
|
991
1486
|
// Step 3: Translation Method
|
|
992
|
-
const { defaultMethod, defaultModel, perLanguage } = await stepMethod(rl, languages, args.model
|
|
1487
|
+
const { defaultMethod, defaultModel, perLanguage } = await stepMethod(rl, languages, args.model, {
|
|
1488
|
+
localMark, presetMethod: args.method || null,
|
|
1489
|
+
});
|
|
993
1490
|
|
|
994
1491
|
// Step 4: Temperature
|
|
995
1492
|
const temperature = await stepTemperature(rl, defaultMethod, args.temperature);
|
|
996
1493
|
|
|
997
|
-
// Step 5: Content Translation
|
|
998
|
-
const { contentDir, format: contentFormat } =
|
|
1494
|
+
// Step 5: Content Translation (--content-dir answers it)
|
|
1495
|
+
const { contentDir, format: contentFormat } = args['content-dir']
|
|
1496
|
+
? { contentDir: args['content-dir'], format: null }
|
|
1497
|
+
: await stepContent(rl, cwd);
|
|
999
1498
|
|
|
1000
1499
|
// Step 6: Locales directory and format
|
|
1001
1500
|
// These are simpler questions — asked inline before confirmation
|
|
1002
1501
|
console.log('');
|
|
1003
|
-
|
|
1004
|
-
|
|
1502
|
+
// Default to what is actually on disk (detectLocaleSetup), not a guess.
|
|
1503
|
+
// Flutter and gettext layouts are patterns (lib/l10n/app_{lang}.arb),
|
|
1504
|
+
// asked for as a pattern rather than a directory.
|
|
1505
|
+
const detectedPattern = contentFormat !== 'docusaurus' && !args.dir ? detection?.found?.localesPattern : null;
|
|
1506
|
+
let localesDir = null;
|
|
1507
|
+
let localesPattern = null;
|
|
1508
|
+
if (detectedPattern) {
|
|
1509
|
+
localesPattern = await ask(rl, 'Locale file pattern ({lang} = language, {ns} = file/domain)', detectedPattern);
|
|
1510
|
+
} else {
|
|
1511
|
+
const detectedDir = contentFormat === 'docusaurus' ? './i18n' : (detection?.found?.localesDir || './locales');
|
|
1512
|
+
localesDir = await ask(rl, 'Locales directory', args.dir || detectedDir);
|
|
1513
|
+
}
|
|
1514
|
+
const formatDefault = args.format || (detection?.found?.format === 'po' && !detectedPattern ? 'po' : 'auto');
|
|
1515
|
+
const format = contentFormat || await ask(rl, `File format (${['auto', ...LOCALE_FILE_FORMATS].join('/')})`, formatDefault);
|
|
1005
1516
|
|
|
1006
1517
|
// Build config
|
|
1007
1518
|
const config = buildConfig({
|
|
1008
1519
|
source, languages, defaultMethod, defaultModel,
|
|
1009
1520
|
perLanguage, customRegisters, temperature,
|
|
1010
|
-
localesDir, format, contentDir, scriptChoices,
|
|
1521
|
+
localesDir, localesPattern, format, contentDir, scriptChoices,
|
|
1011
1522
|
});
|
|
1012
1523
|
|
|
1013
1524
|
// Collect env vars needed
|
|
@@ -1049,6 +1560,8 @@ async function buildDefaultConfig(args) {
|
|
|
1049
1560
|
// If no API key or fetch fails, leave model unset (method default fires at runtime).
|
|
1050
1561
|
let model = args.model || null;
|
|
1051
1562
|
const method = args.method || 'llm';
|
|
1563
|
+
// The endpoint serves its own model; api has none of ours to write.
|
|
1564
|
+
if (method === API_METHOD) model = null;
|
|
1052
1565
|
|
|
1053
1566
|
if (!model && method !== 'llm' && isListableProvider(method)) {
|
|
1054
1567
|
const apiKey = resolveProviderApiKey(method);
|
|
@@ -1085,9 +1598,148 @@ async function buildDefaultConfig(args) {
|
|
|
1085
1598
|
config.model = model;
|
|
1086
1599
|
}
|
|
1087
1600
|
|
|
1601
|
+
// A folder of Markdown/MDX to translate (a newsletter archive, a docs
|
|
1602
|
+
// folder). Without the flag the folder had to be added by hand
|
|
1603
|
+
// (synthetic Cree school persona, 2026-10).
|
|
1604
|
+
if (args['content-dir']) {
|
|
1605
|
+
config.contentDir = args['content-dir'];
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1088
1608
|
return config;
|
|
1089
1609
|
}
|
|
1090
1610
|
|
|
1611
|
+
/**
|
|
1612
|
+
* --script for init: "Cans" (one target language) or "crk=Cans,sr=Latn"
|
|
1613
|
+
* (":" works as well as "="). Values are checked by the same resolver sync
|
|
1614
|
+
* uses (lib/scripts.js resolveTargetScript), so a value init accepts is one
|
|
1615
|
+
* sync can run with.
|
|
1616
|
+
*
|
|
1617
|
+
* @param {string|undefined} value
|
|
1618
|
+
* @param {string[]} languages - Target codes (--langs)
|
|
1619
|
+
* @returns {{ map: Object<string,string>, error: string|null }}
|
|
1620
|
+
*/
|
|
1621
|
+
function parseScriptFlag(value, languages) {
|
|
1622
|
+
const map = {};
|
|
1623
|
+
if (value == null || value === '') return { map, error: null };
|
|
1624
|
+
const parts = String(value).split(',').map(p => p.trim()).filter(Boolean);
|
|
1625
|
+
for (const part of parts) {
|
|
1626
|
+
const m = /^([^=:]+)[=:](.+)$/.exec(part);
|
|
1627
|
+
if (m) {
|
|
1628
|
+
map[m[1].trim()] = m[2].trim();
|
|
1629
|
+
} else if (parts.length === 1 && languages.length === 1) {
|
|
1630
|
+
map[languages[0]] = part;
|
|
1631
|
+
} else {
|
|
1632
|
+
return { map, error: `--script "${part}": name the language — e.g. --script crk=Cans (one --script value may list several: crk=Cans,sr=Latn).` };
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
for (const [code, script] of Object.entries(map)) {
|
|
1636
|
+
if (!languages.includes(code)) {
|
|
1637
|
+
return { map, error: `--script ${code}=${script}: ${code} is not one of the target languages (--langs ${languages.join(',') || '…'}).` };
|
|
1638
|
+
}
|
|
1639
|
+
try {
|
|
1640
|
+
resolveTargetScript(code, { script }, getLanguageCard(code));
|
|
1641
|
+
} catch (err) {
|
|
1642
|
+
return { map, error: `--script ${code}=${script}: ${err.message}` };
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
return { map, error: null };
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* --name for init: `qaa=Ayta (variety not yet confirmed)`, several separated
|
|
1650
|
+
* by ";" (a display name may hold commas), or a bare name when there is one
|
|
1651
|
+
* target language.
|
|
1652
|
+
*
|
|
1653
|
+
* @param {string|undefined} value
|
|
1654
|
+
* @returns {{ map: Record<string, string>, bare: string|null }}
|
|
1655
|
+
*/
|
|
1656
|
+
function parseNameFlag(value) {
|
|
1657
|
+
const map = {};
|
|
1658
|
+
let bare = null;
|
|
1659
|
+
if (value == null || value === '' || value === true) return { map, bare };
|
|
1660
|
+
for (const part of String(value).split(';').map(p => p.trim()).filter(Boolean)) {
|
|
1661
|
+
const m = /^([A-Za-z]{2,3}(?:[-_][A-Za-z0-9]+)*)\s*=\s*(.+)$/.exec(part);
|
|
1662
|
+
if (m) map[m[1]] = m[2].trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
1663
|
+
else bare = part.replace(/^(["'])(.*)\1$/, '$2');
|
|
1664
|
+
}
|
|
1665
|
+
return { map, bare };
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Write --name display names into the object-form `languages` entries
|
|
1670
|
+
* (`"qaa": { "name": "Ayta (variety not yet confirmed)" }`).
|
|
1671
|
+
*
|
|
1672
|
+
* @returns {{ error: string|null }}
|
|
1673
|
+
*/
|
|
1674
|
+
function applyDisplayNames(config, args) {
|
|
1675
|
+
const { map, bare } = parseNameFlag(args.name);
|
|
1676
|
+
const languages = Array.isArray(config.languages) ? config.languages : Object.keys(config.languages || {});
|
|
1677
|
+
if (bare !== null) {
|
|
1678
|
+
if (languages.length !== 1) {
|
|
1679
|
+
return { error: `--name "${bare}": name the language — e.g. --name qaa="${bare}" (several: --name "qaa=…;qab=…").` };
|
|
1680
|
+
}
|
|
1681
|
+
map[languages[0]] = bare;
|
|
1682
|
+
}
|
|
1683
|
+
if (Object.keys(map).length === 0) return { error: null };
|
|
1684
|
+
for (const code of Object.keys(map)) {
|
|
1685
|
+
if (!languages.includes(code)) {
|
|
1686
|
+
return { error: `--name ${code}=…: ${code} is not one of the target languages (--langs ${languages.join(',') || '…'}).` };
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
const asObject = Array.isArray(config.languages)
|
|
1690
|
+
? Object.fromEntries(config.languages.map(c => [c, {}]))
|
|
1691
|
+
: { ...config.languages };
|
|
1692
|
+
for (const [code, name] of Object.entries(map)) {
|
|
1693
|
+
const prior = asObject[code];
|
|
1694
|
+
asObject[code] = typeof prior === 'string' ? { register: prior, name } : { ...(prior || {}), name };
|
|
1695
|
+
}
|
|
1696
|
+
config.languages = asObject;
|
|
1697
|
+
return { error: null };
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Writing systems on the non-interactive path. The wizard asks for a
|
|
1702
|
+
* language with more than one real orthography (Plains Cree: SRO or
|
|
1703
|
+
* Syllabics); `init --yes` used to skip that in silence and the first sync
|
|
1704
|
+
* refused (Round 4, school persona). Now --script records the choice, and
|
|
1705
|
+
* without it init says a choice is needed, lists the choices, and prints
|
|
1706
|
+
* exactly what to add — the same rule sync applies.
|
|
1707
|
+
*
|
|
1708
|
+
* @param {object} config - The config being written (languages mutated to object form when a script is set)
|
|
1709
|
+
* @param {object} args
|
|
1710
|
+
* @returns {{ error: string|null, needed: Array<{ code: string, choices: Array<{script: string, label: string}> }> }}
|
|
1711
|
+
*/
|
|
1712
|
+
function applyScriptChoices(config, args) {
|
|
1713
|
+
const languages = Array.isArray(config.languages) ? config.languages : Object.keys(config.languages || {});
|
|
1714
|
+
const { map, error } = parseScriptFlag(args.script, languages);
|
|
1715
|
+
if (error) return { error, needed: [] };
|
|
1716
|
+
const needed = [];
|
|
1717
|
+
for (const code of languages) {
|
|
1718
|
+
if (map[code]) continue;
|
|
1719
|
+
// A script the config already names (an existing file under --force).
|
|
1720
|
+
const entry = Array.isArray(config.languages) ? null : config.languages?.[code];
|
|
1721
|
+
if (entry && typeof entry === 'object' && entry.script) continue;
|
|
1722
|
+
let resolution;
|
|
1723
|
+
try { resolution = resolveTargetScript(code, {}, getLanguageCard(code)); } catch { continue; }
|
|
1724
|
+
if (resolution.source === 'choice-required') needed.push({ code, choices: resolution.choices });
|
|
1725
|
+
}
|
|
1726
|
+
if (Object.keys(map).length > 0) {
|
|
1727
|
+
const asObject = Array.isArray(config.languages)
|
|
1728
|
+
? Object.fromEntries(config.languages.map(c => [c, {}]))
|
|
1729
|
+
: { ...config.languages };
|
|
1730
|
+
for (const [code, script] of Object.entries(map)) {
|
|
1731
|
+
const card = getLanguageCard(code);
|
|
1732
|
+
const resolved = resolveTargetScript(code, { script }, card).script || script;
|
|
1733
|
+
const prior = asObject[code];
|
|
1734
|
+
asObject[code] = typeof prior === 'string'
|
|
1735
|
+
? { register: prior, script: resolved }
|
|
1736
|
+
: { ...(prior || {}), script: resolved };
|
|
1737
|
+
}
|
|
1738
|
+
config.languages = asObject;
|
|
1739
|
+
}
|
|
1740
|
+
return { error: null, needed };
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1091
1743
|
/**
|
|
1092
1744
|
* Validate init flag values before any config is written.
|
|
1093
1745
|
* Returns an error message string, or null when the flags are fine.
|
|
@@ -1097,10 +1749,50 @@ async function buildDefaultConfig(args) {
|
|
|
1097
1749
|
* broken config ("temperature": null) at first sync.
|
|
1098
1750
|
*/
|
|
1099
1751
|
function validateInitFlags(args) {
|
|
1100
|
-
|
|
1101
|
-
|
|
1752
|
+
// Same vocabulary resolveConfig() enforces — a config init writes must be
|
|
1753
|
+
// one sync can load.
|
|
1754
|
+
const formats = ['auto', 'docusaurus', ...LOCALE_FILE_FORMATS];
|
|
1755
|
+
if (args.format && !formats.includes(args.format)) {
|
|
1756
|
+
return `Unknown --format "${args.format}". Valid formats: ${formats.join(', ')}`;
|
|
1757
|
+
}
|
|
1758
|
+
if (args.method && args.method !== API_METHOD && !METHOD_OPTIONS.some(m => m.method === args.method)) {
|
|
1759
|
+
const valid = [...METHOD_OPTIONS.map(m => m.method), API_METHOD].join(', ');
|
|
1102
1760
|
return `Unknown method "${args.method}". Valid methods: ${valid}`;
|
|
1103
1761
|
}
|
|
1762
|
+
// --method api: a server speaking the champollion API contract (e.g. a
|
|
1763
|
+
// model served by `nmt-forge serve`) — the endpoint is the whole config.
|
|
1764
|
+
if (args.method === API_METHOD) {
|
|
1765
|
+
if (args.endpoint == null || args.endpoint === true || !String(args.endpoint).trim()) {
|
|
1766
|
+
return '--method api needs --endpoint <url>: the champollion API endpoint, e.g. '
|
|
1767
|
+
+ '--endpoint http://127.0.0.1:8378/translate (what `nmt-forge serve` prints).';
|
|
1768
|
+
}
|
|
1769
|
+
let url = null;
|
|
1770
|
+
try { url = new URL(String(args.endpoint)); } catch { /* reported below */ }
|
|
1771
|
+
if (!url || !/^https?:$/.test(url.protocol)) {
|
|
1772
|
+
return `--endpoint ${args.endpoint}: not an http(s) URL. Example: --endpoint http://127.0.0.1:8378/translate`;
|
|
1773
|
+
}
|
|
1774
|
+
if (args.model) {
|
|
1775
|
+
return '--model does not apply to --method api: the endpoint serves its own model.';
|
|
1776
|
+
}
|
|
1777
|
+
} else if (args.endpoint != null) {
|
|
1778
|
+
return `--endpoint applies to --method api (a champollion API endpoint)${args.method ? `, not --method ${args.method}` : ''}. `
|
|
1779
|
+
+ 'For an OpenAI-compatible server use --method local and LOCAL_API_BASE.';
|
|
1780
|
+
}
|
|
1781
|
+
// Exact model slugs only (founder ruling 2026-10-05): a retired alias or a
|
|
1782
|
+
// floating id is refused before init writes it into the config.
|
|
1783
|
+
if (typeof args.model === 'string' && args.model) {
|
|
1784
|
+
try { requireExactModelId(args.model, { from: 'from --model' }); } catch (err) { return err.message; }
|
|
1785
|
+
}
|
|
1786
|
+
if (args['accepts-instructions'] != null) {
|
|
1787
|
+
if (args.method !== API_METHOD) return '--accepts-instructions applies to --method api.';
|
|
1788
|
+
if (!['true', 'false'].includes(String(args['accepts-instructions']))) {
|
|
1789
|
+
return `--accepts-instructions must be true or false (got "${args['accepts-instructions']}").`;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
if (args.script != null && !args.langs) {
|
|
1794
|
+
return '--script needs --langs: it names the writing system of a target language (e.g. --langs crk --script crk=Cans).';
|
|
1795
|
+
}
|
|
1104
1796
|
|
|
1105
1797
|
if (args.temperature != null) {
|
|
1106
1798
|
const t = parseFloat(args.temperature);
|
|
@@ -1112,6 +1804,418 @@ function validateInitFlags(args) {
|
|
|
1112
1804
|
return null;
|
|
1113
1805
|
}
|
|
1114
1806
|
|
|
1807
|
+
/**
|
|
1808
|
+
* Say what detectLocaleSetup() found, and why, before anything is written —
|
|
1809
|
+
* an agent driving `init --yes` reads this to know which files will sync.
|
|
1810
|
+
*
|
|
1811
|
+
* @param {ReturnType<typeof detectLocaleSetup>} detection
|
|
1812
|
+
* @param {string} source - Source locale code
|
|
1813
|
+
*/
|
|
1814
|
+
function reportDetection(detection, source) {
|
|
1815
|
+
if (detection.docusaurus && !detection.found) {
|
|
1816
|
+
output.info('Detected Docusaurus — locale files live in ./i18n/<locale>/ (Docusaurus lane).');
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
const { found } = detection;
|
|
1820
|
+
if (found) {
|
|
1821
|
+
const fw = detection.framework && detection.framework !== 'generic' ? ` (${detection.framework})` : '';
|
|
1822
|
+
output.info(`Detected locale files: ${found.shape}${fw} — ${found.why}`);
|
|
1823
|
+
const shown = found.sourceFiles.slice(0, 4).join(', ');
|
|
1824
|
+
const more = found.sourceFiles.length > 4 ? `, +${found.sourceFiles.length - 4} more` : '';
|
|
1825
|
+
output.raw(` Source (${source}): ${shown}${more}`);
|
|
1826
|
+
if (found.layout === 'dir') {
|
|
1827
|
+
output.raw(` One folder per locale; each file is a namespace (${found.sourceFiles.length} file(s)).`);
|
|
1828
|
+
}
|
|
1829
|
+
if (found.localesPattern) {
|
|
1830
|
+
output.raw(` Writing "localesPattern": "${found.localesPattern}"${found.format === 'arb' ? ' (Flutter ARB)' : ' (gettext)'}.`);
|
|
1831
|
+
}
|
|
1832
|
+
if (found.targets.length > 0) output.raw(` Target locales on disk: ${found.targets.join(', ')}`);
|
|
1833
|
+
if (found.ambiguous) {
|
|
1834
|
+
output.warn(`Both ${found.localesDir}/${source}.<ext> and ${found.localesDir}/${source}/ exist — writing "localesLayout": "${found.layout}" so sync does not have to guess.`);
|
|
1835
|
+
}
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
for (const hint of detection.hints || []) output.warn(hint);
|
|
1839
|
+
for (const miss of detection.nearMisses) {
|
|
1840
|
+
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>.`);
|
|
1841
|
+
}
|
|
1842
|
+
output.info(`No ${source} locale files found in the usual places (${GENERIC_LOCALE_DIRS.join(', ')}).`);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* Point a default (--yes) config at what detectLocaleSetup() found. An
|
|
1847
|
+
* explicit --dir always wins; it was probed on its own.
|
|
1848
|
+
*
|
|
1849
|
+
* @param {object} config - From buildDefaultConfig()
|
|
1850
|
+
* @param {ReturnType<typeof detectLocaleSetup>} detection
|
|
1851
|
+
* @param {object} args - CLI flags
|
|
1852
|
+
*/
|
|
1853
|
+
function applyDetection(config, detection, args) {
|
|
1854
|
+
if (detection.docusaurus && !args.dir) {
|
|
1855
|
+
if (!args.format) config.format = 'docusaurus';
|
|
1856
|
+
config.localesDir = './i18n';
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (detection.found) {
|
|
1860
|
+
const found = detection.found;
|
|
1861
|
+
if (found.localesPattern) {
|
|
1862
|
+
// The pattern replaces localesDir in place (config key order stays
|
|
1863
|
+
// readable); config.js refuses a localesDir that disagrees with it.
|
|
1864
|
+
const ordered = {};
|
|
1865
|
+
for (const [k, v] of Object.entries(config)) {
|
|
1866
|
+
if (k === 'localesDir') ordered.localesPattern = found.localesPattern;
|
|
1867
|
+
else ordered[k] = v;
|
|
1868
|
+
}
|
|
1869
|
+
for (const k of Object.keys(config)) delete config[k];
|
|
1870
|
+
Object.assign(config, ordered);
|
|
1871
|
+
// A Flutter template names the source (app_en.arb → en); --source wins.
|
|
1872
|
+
if (found.inputLocale && !args.source) config.inputLocale = found.inputLocale;
|
|
1873
|
+
} else {
|
|
1874
|
+
config.localesDir = found.localesDir;
|
|
1875
|
+
}
|
|
1876
|
+
// GNU po/ may hold only a .pot template and targets — say "po" so the
|
|
1877
|
+
// source is never looked up as a JSON file.
|
|
1878
|
+
if (found.format === 'po' && !found.localesPattern && !args.format) config.format = 'po';
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// -----------------------------------------------------------------
|
|
1883
|
+
// `init --force` over an existing config: change what was asked, keep the rest
|
|
1884
|
+
// -----------------------------------------------------------------
|
|
1885
|
+
//
|
|
1886
|
+
// `init --force` used to write a brand-new default config over the old one:
|
|
1887
|
+
// `init --force --method local --model x` (what init itself printed as the way
|
|
1888
|
+
// to switch model) dropped a custom register, the glossary and every other
|
|
1889
|
+
// field, and reset batchSize to the default (Round 10, Next.js persona). Now
|
|
1890
|
+
// a forced re-run starts from the existing file: the fields the flags name
|
|
1891
|
+
// are rewritten (and the locale layout is re-detected when the file no longer
|
|
1892
|
+
// finds the source), everything else stays as it was, the changes are printed,
|
|
1893
|
+
// and the previous file is backed up first.
|
|
1894
|
+
|
|
1895
|
+
/** Plain-object copy of a JSON value. */
|
|
1896
|
+
const cloneJSON = (v) => JSON.parse(JSON.stringify(v));
|
|
1897
|
+
|
|
1898
|
+
/** Target codes of a `languages` value (list or object form). */
|
|
1899
|
+
function languageCodes(languages) {
|
|
1900
|
+
if (Array.isArray(languages)) return [...languages];
|
|
1901
|
+
return languages && typeof languages === 'object' ? Object.keys(languages) : [];
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Set `key` on `obj` to `value` (deleting it for undefined/null), keeping the
|
|
1906
|
+
* key's place in the file. `replaces` names a key the new one takes the place
|
|
1907
|
+
* of (localesPattern for localesDir), so the file still reads top to bottom.
|
|
1908
|
+
*/
|
|
1909
|
+
function setInPlace(obj, key, value, replaces = null) {
|
|
1910
|
+
if (value === undefined || value === null) { delete obj[key]; return; }
|
|
1911
|
+
if (Object.prototype.hasOwnProperty.call(obj, key) || !replaces || !Object.prototype.hasOwnProperty.call(obj, replaces)) {
|
|
1912
|
+
obj[key] = value;
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
const ordered = {};
|
|
1916
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1917
|
+
if (k === replaces) ordered[key] = value;
|
|
1918
|
+
else ordered[k] = v;
|
|
1919
|
+
}
|
|
1920
|
+
for (const k of Object.keys(obj)) delete obj[k];
|
|
1921
|
+
Object.assign(obj, ordered);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
/**
|
|
1925
|
+
* Does the existing config still find the project's source locale files?
|
|
1926
|
+
* When it does, a forced re-run keeps its layout (localesDir/localesPattern,
|
|
1927
|
+
* localesLayout, format) unless --dir or --format names another; when it does
|
|
1928
|
+
* not (the folder moved, a Flutter template or a .pot was created since), the
|
|
1929
|
+
* layout is re-detected — what `init --force` is advised for.
|
|
1930
|
+
*
|
|
1931
|
+
* @param {object} existing - The parsed config file
|
|
1932
|
+
* @param {string} inputLocale - The source locale the run will use
|
|
1933
|
+
* @param {string} cwd
|
|
1934
|
+
* @returns {boolean}
|
|
1935
|
+
*/
|
|
1936
|
+
function existingLayoutHolds(existing, inputLocale, cwd) {
|
|
1937
|
+
if (existing.format === 'docusaurus') return true; // its lane reads i18n/ itself
|
|
1938
|
+
try {
|
|
1939
|
+
const patternAbs = typeof existing.localesPattern === 'string' && existing.localesPattern
|
|
1940
|
+
? path.resolve(cwd, existing.localesPattern) : null;
|
|
1941
|
+
const localesAbs = patternAbs
|
|
1942
|
+
? compileLocalesPattern(patternAbs).base
|
|
1943
|
+
: path.resolve(cwd, typeof existing.localesDir === 'string' && existing.localesDir ? existing.localesDir : './locales');
|
|
1944
|
+
if (!fs.existsSync(localesAbs)) return false;
|
|
1945
|
+
const layout = discoverLocaleLayout({
|
|
1946
|
+
inputLocale, localesDir: localesAbs, format: existing.format || 'auto',
|
|
1947
|
+
...(patternAbs ? { localesPattern: patternAbs } : { localesLayout: existing.localesLayout || null }),
|
|
1948
|
+
}, { cwd });
|
|
1949
|
+
return layout.namespaced ? layout.sourceFiles.length > 0 : fs.existsSync(layout.sourceFiles[0].path);
|
|
1950
|
+
} catch {
|
|
1951
|
+
return false;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* The non-interactive path's config over an existing file: start from the
|
|
1957
|
+
* file, and rewrite only what the flags name (built from them as for a new
|
|
1958
|
+
* project in `fresh`).
|
|
1959
|
+
*
|
|
1960
|
+
* --source → inputLocale; --dir, or a layout that no longer finds the source
|
|
1961
|
+
* → localesDir/localesPattern/localesLayout (re-detected); --format → format;
|
|
1962
|
+
* --method → defaultMethod, and the model with it when the method changes
|
|
1963
|
+
* (a model belongs to its method) unless --model names one; --model → model;
|
|
1964
|
+
* --temperature; --content-dir → contentDir; --langs → the target list (a
|
|
1965
|
+
* code already there keeps its entry — register, script, name — as it was).
|
|
1966
|
+
*
|
|
1967
|
+
* Everything else (batchSize, pairs, glossary, fallbacks, registers, …) is the
|
|
1968
|
+
* file's. --script, --name and --method api's pairs are applied afterwards by
|
|
1969
|
+
* the same code as for a new project.
|
|
1970
|
+
*
|
|
1971
|
+
* @param {object} existing - The parsed config file
|
|
1972
|
+
* @param {object} fresh - buildDefaultConfig() + applyDetection() for these flags
|
|
1973
|
+
* @param {object} args
|
|
1974
|
+
* @param {{ relayout: boolean, detection: object }} p
|
|
1975
|
+
* @returns {{ config: object, added: string[] }} added = target codes new to the file
|
|
1976
|
+
*/
|
|
1977
|
+
function overlayFlagsOnConfig(existing, fresh, args, { relayout, detection }) {
|
|
1978
|
+
const config = cloneJSON(existing);
|
|
1979
|
+
const take = (key, replaces = null) => setInPlace(config, key, fresh[key], replaces);
|
|
1980
|
+
if (config.version === undefined) config.version = fresh.version;
|
|
1981
|
+
if (args.source) take('inputLocale');
|
|
1982
|
+
if (relayout) {
|
|
1983
|
+
// A pattern replaces localesDir in place; never write both.
|
|
1984
|
+
take('localesPattern', 'localesDir');
|
|
1985
|
+
take('localesDir');
|
|
1986
|
+
take('localesLayout');
|
|
1987
|
+
// A Flutter template names the source (app_en.arb → en); --source wins.
|
|
1988
|
+
if (!args.source && detection?.found?.inputLocale) take('inputLocale');
|
|
1989
|
+
}
|
|
1990
|
+
if (args.format || relayout) take('format');
|
|
1991
|
+
const methodBefore = existing.defaultMethod || 'llm';
|
|
1992
|
+
const methodAfter = fresh.defaultMethod || 'llm';
|
|
1993
|
+
if (args.method) take('defaultMethod');
|
|
1994
|
+
if (args.model || (args.method && methodAfter !== methodBefore)) take('model');
|
|
1995
|
+
if (args.temperature != null) take('temperature');
|
|
1996
|
+
if (args['content-dir'] != null) take('contentDir');
|
|
1997
|
+
|
|
1998
|
+
let added = [];
|
|
1999
|
+
if (args.langs) {
|
|
2000
|
+
const listed = languageCodes(fresh.languages);
|
|
2001
|
+
const prior = existing.languages;
|
|
2002
|
+
const priorEntry = Array.isArray(prior)
|
|
2003
|
+
? Object.fromEntries(prior.map(c => [c, null]))
|
|
2004
|
+
: (prior && typeof prior === 'object' ? prior : {});
|
|
2005
|
+
const had = (c) => Object.prototype.hasOwnProperty.call(priorEntry, c);
|
|
2006
|
+
added = listed.filter(c => !had(c));
|
|
2007
|
+
// Plain codes stay a plain list (registers are recorded for the new ones later).
|
|
2008
|
+
config.languages = listed.every(c => priorEntry[c] == null)
|
|
2009
|
+
? [...listed]
|
|
2010
|
+
: Object.fromEntries(listed.map(c => [c, priorEntry[c] ?? {}]));
|
|
2011
|
+
}
|
|
2012
|
+
return { config, added };
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* The wizard's config over an existing file: the wizard asked for the source,
|
|
2017
|
+
* the targets and their registers, the method and model, the temperature, the
|
|
2018
|
+
* content folder and the locale layout — those are its answers; every field
|
|
2019
|
+
* it does not ask about (batchSize, pairs, glossary, …) is the file's, and a
|
|
2020
|
+
* target's entry keeps the fields the wizard does not set (script, name,
|
|
2021
|
+
* genderGuidance, …).
|
|
2022
|
+
*
|
|
2023
|
+
* @param {object} existing
|
|
2024
|
+
* @param {object} answered - buildConfig() from the wizard
|
|
2025
|
+
* @returns {object}
|
|
2026
|
+
*/
|
|
2027
|
+
function mergeWizardConfig(existing, answered) {
|
|
2028
|
+
const config = cloneJSON(existing);
|
|
2029
|
+
const take = (key, replaces = null) => setInPlace(config, key, answered[key], replaces);
|
|
2030
|
+
if (config.version === undefined) config.version = answered.version;
|
|
2031
|
+
take('inputLocale');
|
|
2032
|
+
take('localesPattern', 'localesDir');
|
|
2033
|
+
take('localesDir');
|
|
2034
|
+
if (answered.localesDir !== existing.localesDir || answered.localesPattern !== existing.localesPattern) delete config.localesLayout;
|
|
2035
|
+
take('format');
|
|
2036
|
+
take('defaultMethod');
|
|
2037
|
+
take('model');
|
|
2038
|
+
take('temperature');
|
|
2039
|
+
take('contentDir');
|
|
2040
|
+
|
|
2041
|
+
const asObject = (v) => (typeof v === 'string' ? { register: v } : (v && typeof v === 'object' ? { ...v } : {}));
|
|
2042
|
+
const prior = Array.isArray(existing.languages)
|
|
2043
|
+
? Object.fromEntries(existing.languages.map(c => [c, {}]))
|
|
2044
|
+
: (existing.languages && typeof existing.languages === 'object' ? existing.languages : {});
|
|
2045
|
+
if (Array.isArray(answered.languages)) {
|
|
2046
|
+
config.languages = answered.languages.every(c => !prior[c] || Object.keys(asObject(prior[c])).length === 0)
|
|
2047
|
+
? [...answered.languages]
|
|
2048
|
+
: Object.fromEntries(answered.languages.map(c => [c, prior[c] ?? {}]));
|
|
2049
|
+
} else {
|
|
2050
|
+
const out = {};
|
|
2051
|
+
for (const [code, value] of Object.entries(answered.languages || {})) {
|
|
2052
|
+
const merged = { ...asObject(prior[code]), ...asObject(value) };
|
|
2053
|
+
const keys = Object.keys(merged);
|
|
2054
|
+
out[code] = keys.length === 1 && keys[0] === 'register' && typeof merged.register === 'string' ? merged.register : merged;
|
|
2055
|
+
}
|
|
2056
|
+
config.languages = out;
|
|
2057
|
+
}
|
|
2058
|
+
return config;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/** Each target's register in a config (code → preset key or own words). */
|
|
2062
|
+
function registersOf(config) {
|
|
2063
|
+
const out = {};
|
|
2064
|
+
const langs = config.languages;
|
|
2065
|
+
if (!langs || typeof langs !== 'object' || Array.isArray(langs)) return out;
|
|
2066
|
+
for (const [code, value] of Object.entries(langs)) {
|
|
2067
|
+
const register = typeof value === 'string' ? value : (value && typeof value === 'object' ? value.register : null);
|
|
2068
|
+
if (typeof register === 'string' && register) out[code] = register;
|
|
2069
|
+
}
|
|
2070
|
+
return out;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* The wizard's defaults over an existing file: its source, targets,
|
|
2075
|
+
* temperature, content folder and (while it still finds the source) locale
|
|
2076
|
+
* folder — flags still win — so pressing Enter keeps what the file says.
|
|
2077
|
+
*/
|
|
2078
|
+
function prefillFromConfig(args, existing, relayout) {
|
|
2079
|
+
const out = { ...args };
|
|
2080
|
+
if (!out.source && typeof existing.inputLocale === 'string') out.source = existing.inputLocale;
|
|
2081
|
+
if (!out.langs) {
|
|
2082
|
+
const codes = languageCodes(existing.languages);
|
|
2083
|
+
if (codes.length > 0) out.langs = codes.join(',');
|
|
2084
|
+
}
|
|
2085
|
+
if (out.temperature == null && typeof existing.temperature === 'number') out.temperature = existing.temperature;
|
|
2086
|
+
if (out['content-dir'] == null && typeof existing.contentDir === 'string') out['content-dir'] = existing.contentDir;
|
|
2087
|
+
if (!out.dir && !relayout && !existing.localesPattern && typeof existing.localesDir === 'string') out.dir = existing.localesDir;
|
|
2088
|
+
if (!out.format && typeof existing.format === 'string') out.format = existing.format;
|
|
2089
|
+
if (!out.script && existing.languages && !Array.isArray(existing.languages)) {
|
|
2090
|
+
const scripts = Object.entries(existing.languages)
|
|
2091
|
+
.filter(([, v]) => v && typeof v === 'object' && typeof v.script === 'string')
|
|
2092
|
+
.map(([c, v]) => `${c}=${v.script}`);
|
|
2093
|
+
if (scripts.length > 0) out.script = scripts.join(',');
|
|
2094
|
+
}
|
|
2095
|
+
return out;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
/** A config value, short, for a "was → now" line. */
|
|
2099
|
+
function showValue(v) {
|
|
2100
|
+
if (v === undefined) return '(not set)';
|
|
2101
|
+
const s = JSON.stringify(v);
|
|
2102
|
+
return s.length > 70 ? `${s.slice(0, 67)}…` : s;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* What a forced re-run changes in the file, in words: one line per changed
|
|
2107
|
+
* field ("model: \"m1\" → \"x\""), the targets added or removed and each
|
|
2108
|
+
* target whose entry changed; and the fields left as they were.
|
|
2109
|
+
*
|
|
2110
|
+
* @param {object} before
|
|
2111
|
+
* @param {object} after
|
|
2112
|
+
* @returns {{ changed: string[], kept: string[] }}
|
|
2113
|
+
*/
|
|
2114
|
+
function describeConfigChanges(before, after) {
|
|
2115
|
+
const changed = [];
|
|
2116
|
+
const kept = [];
|
|
2117
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
2118
|
+
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])];
|
|
2119
|
+
for (const key of keys) {
|
|
2120
|
+
if (same(before[key], after[key])) {
|
|
2121
|
+
if (key in before) kept.push(key);
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
if (key !== 'languages') {
|
|
2125
|
+
changed.push(`${key}: ${showValue(before[key])} → ${showValue(after[key])}`);
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
const entry = (langs, code) => (Array.isArray(langs) ? (langs.includes(code) ? {} : undefined) : langs?.[code]);
|
|
2129
|
+
const was = languageCodes(before.languages);
|
|
2130
|
+
const now = languageCodes(after.languages);
|
|
2131
|
+
const added = now.filter(c => !was.includes(c));
|
|
2132
|
+
const removed = was.filter(c => !now.includes(c));
|
|
2133
|
+
const parts = [];
|
|
2134
|
+
if (added.length > 0) parts.push(`added ${added.map(c => `${c} (${showValue(entry(after.languages, c))})`).join(', ')}`);
|
|
2135
|
+
if (removed.length > 0) parts.push(`removed ${removed.join(', ')}`);
|
|
2136
|
+
for (const c of now.filter(c => was.includes(c))) {
|
|
2137
|
+
const a = entry(before.languages, c);
|
|
2138
|
+
const b = entry(after.languages, c);
|
|
2139
|
+
if (!same(a, b)) parts.push(`${c}: ${showValue(a)} → ${showValue(b)}`);
|
|
2140
|
+
}
|
|
2141
|
+
changed.push(`languages: ${parts.length > 0 ? parts.join('; ') : 'written as an object (the same targets and settings)'}`);
|
|
2142
|
+
}
|
|
2143
|
+
return { changed, kept };
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
/**
|
|
2147
|
+
* Keep a copy of the config file before init rewrites it:
|
|
2148
|
+
* champollion.config.json.bak, or — when that already holds an OLDER, different
|
|
2149
|
+
* file — the next free champollion.config.json.bak.2, .bak.3 … (an older
|
|
2150
|
+
* backup is never overwritten). A backup that already holds exactly this file
|
|
2151
|
+
* is reused.
|
|
2152
|
+
*
|
|
2153
|
+
* @param {string} configPath
|
|
2154
|
+
* @param {string} raw - The file's current text
|
|
2155
|
+
* @returns {{ path: string, reused: boolean }}
|
|
2156
|
+
*/
|
|
2157
|
+
function backupConfigFile(configPath, raw) {
|
|
2158
|
+
const base = `${configPath}.bak`;
|
|
2159
|
+
for (let n = 1; ; n++) {
|
|
2160
|
+
const candidate = n === 1 ? base : `${base}.${n}`;
|
|
2161
|
+
if (!fs.existsSync(candidate)) {
|
|
2162
|
+
fs.writeFileSync(candidate, raw, { encoding: 'utf-8', flag: 'wx' });
|
|
2163
|
+
return { path: candidate, reused: false };
|
|
2164
|
+
}
|
|
2165
|
+
if (fs.readFileSync(candidate, 'utf-8') === raw) return { path: candidate, reused: true };
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* Where a method sends the strings it translates, for a method that sends
|
|
2171
|
+
* them off this machine — said at init time, with how to keep them here
|
|
2172
|
+
* (Round 10, hospital persona: the default was a hosted model, and nothing
|
|
2173
|
+
* at init said so). Null for a method that runs here (`local`), and for an
|
|
2174
|
+
* `api` endpoint on this machine.
|
|
2175
|
+
*
|
|
2176
|
+
* @param {object} config
|
|
2177
|
+
* @param {{ endpoint: string }|null} apiSetup
|
|
2178
|
+
* @returns {string|null}
|
|
2179
|
+
*/
|
|
2180
|
+
function whereTextGoes(config, apiSetup) {
|
|
2181
|
+
const method = config.defaultMethod || 'llm';
|
|
2182
|
+
if (method === API_METHOD) {
|
|
2183
|
+
if (!apiSetup) return null;
|
|
2184
|
+
let host = '';
|
|
2185
|
+
try { host = new URL(apiSetup.endpoint).hostname; } catch { /* validated */ }
|
|
2186
|
+
return LOOPBACK_HOSTS.has(host) ? null : `the server at ${apiSetup.endpoint}`;
|
|
2187
|
+
}
|
|
2188
|
+
const opt = METHOD_OPTIONS.find(m => m.method === method);
|
|
2189
|
+
return opt?.sendsTo || null;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
/**
|
|
2193
|
+
* What init says about a local-only mark in the project (lib/local-only-marks.js):
|
|
2194
|
+
* why the default is the local method — one line naming the marked file —
|
|
2195
|
+
* how to choose a hosted method deliberately, and what local needs.
|
|
2196
|
+
* Null when nothing in the project is marked.
|
|
2197
|
+
*
|
|
2198
|
+
* @param {string} cwd
|
|
2199
|
+
* @param {Array<{ sidecar: string, dataFile: string, unreadable: boolean }>} marks
|
|
2200
|
+
* @returns {{ why: string, hosted: string, needs: string }|null}
|
|
2201
|
+
*/
|
|
2202
|
+
function localOnlyNotice(cwd, marks) {
|
|
2203
|
+
if (!marks || marks.length === 0) return null;
|
|
2204
|
+
const rel = (p) => projectRelative(cwd, p).replace(/^\.\//, '');
|
|
2205
|
+
const first = marks[0];
|
|
2206
|
+
const more = marks.length > 1 ? ` (+${marks.length - 1} more marked file(s))` : '';
|
|
2207
|
+
const why = first.unreadable
|
|
2208
|
+
? `${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.`
|
|
2209
|
+
: `${rel(first.dataFile)} is marked local-only (${rel(first.sidecar)})${more}: only a model on this machine may see it.`;
|
|
2210
|
+
return {
|
|
2211
|
+
why,
|
|
2212
|
+
hosted: `A hosted method is a deliberate choice: champollion init --force --method llm --model ${DEFAULT_OPENROUTER_MODEL} `
|
|
2213
|
+
+ '(--force rewrites only the method and model; the strings sync translates then go to OpenRouter).',
|
|
2214
|
+
needs: 'Local needs a model server on this machine: Ollama\'s default (http://localhost:11434/v1), '
|
|
2215
|
+
+ 'or LOCAL_API_BASE set to yours (LM Studio, vLLM).',
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
|
|
1115
2219
|
async function run(args, cwd) {
|
|
1116
2220
|
const configPath = path.join(cwd, DEFAULT_CONFIG_FILENAME);
|
|
1117
2221
|
|
|
@@ -1130,91 +2234,381 @@ async function run(args, cwd) {
|
|
|
1130
2234
|
output.error(flagError);
|
|
1131
2235
|
return 1;
|
|
1132
2236
|
}
|
|
2237
|
+
// Like localesDir: never write a contentDir that does not exist.
|
|
2238
|
+
if (args['content-dir'] != null) {
|
|
2239
|
+
const contentAbs = path.resolve(cwd, String(args['content-dir']));
|
|
2240
|
+
let isDir = false;
|
|
2241
|
+
try { isDir = fs.statSync(contentAbs).isDirectory(); } catch { /* missing */ }
|
|
2242
|
+
if (!args['content-dir'] || !isDir) {
|
|
2243
|
+
output.error(`--content-dir ${args['content-dir'] || '(empty)'}: no such folder `
|
|
2244
|
+
+ `(${projectRelative(cwd, contentAbs)}). Point it at the folder of Markdown/MDX files to translate.`);
|
|
2245
|
+
return 1;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
1133
2248
|
|
|
1134
2249
|
// ── Guard: config already exists ──
|
|
1135
2250
|
// Without --force, refuse to clobber an existing config. Exit non-zero so
|
|
1136
2251
|
// scripts (and the user) can tell "already initialized / not regenerated"
|
|
1137
2252
|
// from a successful fresh init — a silent exit 0 here hid the no-op,
|
|
1138
2253
|
// especially for a corrupt config that the user expected `init --yes` to fix.
|
|
1139
|
-
// Pass --force to
|
|
2254
|
+
// Pass --force to re-run init over an existing config: it rewrites what the
|
|
2255
|
+
// flags ask for and keeps the rest (overlayFlagsOnConfig), after a backup.
|
|
2256
|
+
// A fresh project (no config yet) is where a new user decides to adopt
|
|
2257
|
+
// the CLI: init says the license there once (not on every --force rerun).
|
|
2258
|
+
const freshProject = !fs.existsSync(configPath);
|
|
1140
2259
|
if (fs.existsSync(configPath) && !args.force) {
|
|
1141
2260
|
output.warn(`Config file already exists: ${DEFAULT_CONFIG_FILENAME}`);
|
|
1142
|
-
output.raw('
|
|
2261
|
+
output.raw(' To change a setting, edit it in the file (e.g. "model", "defaultMethod", "languages").');
|
|
2262
|
+
output.raw(' --force re-runs init over it: it rewrites only what the flags name, keeps every other');
|
|
2263
|
+
output.raw(` setting, prints what changed and backs the file up first (${DEFAULT_CONFIG_FILENAME}.bak).`);
|
|
1143
2264
|
return 1;
|
|
1144
2265
|
}
|
|
1145
2266
|
|
|
2267
|
+
// ── --force over an existing config: read it, to keep what is not asked ──
|
|
2268
|
+
// A file that is not a JSON object cannot be kept: it is backed up and a
|
|
2269
|
+
// new one written (said below).
|
|
2270
|
+
let existing = null;
|
|
2271
|
+
let existingRaw = null;
|
|
2272
|
+
let existingUnreadable = null;
|
|
2273
|
+
if (!freshProject) {
|
|
2274
|
+
existingRaw = fs.readFileSync(configPath, 'utf-8');
|
|
2275
|
+
try {
|
|
2276
|
+
const parsed = JSON.parse(existingRaw.replace(/^/, ''));
|
|
2277
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not a JSON object');
|
|
2278
|
+
existing = parsed;
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
existingUnreadable = err.message;
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// ── Find the project's locale files BEFORE writing anything ──
|
|
2285
|
+
// A config whose localesDir points nowhere fails on the first sync; this
|
|
2286
|
+
// is where a next-intl (messages/en.json) or i18next
|
|
2287
|
+
// (public/locales/en/common.json) project gets its real layout.
|
|
2288
|
+
const sourceLocale = args.source
|
|
2289
|
+
|| (existing && typeof existing.inputLocale === 'string' && existing.inputLocale) || 'en';
|
|
2290
|
+
const detection = detectLocaleSetup(cwd, { source: sourceLocale, dir: args.dir || null });
|
|
2291
|
+
// The existing file's layout is kept while it still finds the source files
|
|
2292
|
+
// (and no --dir/--format names another); otherwise it is re-detected.
|
|
2293
|
+
const relayout = !existing || !!args.dir || !existingLayoutHolds(existing, sourceLocale, cwd);
|
|
2294
|
+
reportDetection(detection, detection.found?.inputLocale && !args.source ? detection.found.inputLocale : sourceLocale);
|
|
2295
|
+
if (existing && !relayout) {
|
|
2296
|
+
const where = existing.localesPattern ? `"localesPattern": "${existing.localesPattern}"` : `"localesDir": "${existing.localesDir || './locales'}"`;
|
|
2297
|
+
output.info(`Keeping the config's locale layout (${where}) — it still finds the ${sourceLocale} source files. --dir changes it.`);
|
|
2298
|
+
}
|
|
2299
|
+
// Markdown pages: suggested, never switched on — translating them is work
|
|
2300
|
+
// (and, with a paid method, cost) the user chooses (Round 3, school persona).
|
|
2301
|
+
if (args['content-dir'] == null && !detection.docusaurus && !(existing && existing.contentDir)) {
|
|
2302
|
+
const exclude = detection.found ? [path.resolve(cwd, detection.found.localesDir)] : [];
|
|
2303
|
+
const md = suggestContentDirs(cwd, exclude);
|
|
2304
|
+
if (md.length > 0) {
|
|
2305
|
+
output.info(`Markdown found in ${md.map(m => `${m.dir}/ (${m.files} file(s))`).join(', ')} — not translated unless you ask: `
|
|
2306
|
+
+ `add --content-dir ${md[0].dir} (or set "contentDir") to translate those pages too.`);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
if (args.source && detection.found?.inputLocale && detection.found.inputLocale !== args.source) {
|
|
2310
|
+
output.warn(`--source ${args.source}, but the Flutter template ${detection.found.sourceFiles[0]} is `
|
|
2311
|
+
+ `"${detection.found.inputLocale}" — gen-l10n treats the template as the source. Using --source as given.`);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// ── A file in the project marked local-only: the DEFAULT method is local ──
|
|
2315
|
+
// (lib/local-only-marks.js). Only the default: an explicit --method wins,
|
|
2316
|
+
// and over an existing file the method it holds is kept. Without a mark
|
|
2317
|
+
// nothing here changes (Round 14, hospital persona: three rounds of
|
|
2318
|
+
// `init --yes` wrote OpenRouter beside a set marked local-only).
|
|
2319
|
+
const localMark = localOnlyNotice(cwd, findLocalOnlyMarks(cwd).marks);
|
|
2320
|
+
const markDefaultsToLocal = !!localMark && !args.method && !existing;
|
|
2321
|
+
|
|
1146
2322
|
// ── Choose mode: interactive wizard or silent defaults ──
|
|
1147
2323
|
let config;
|
|
1148
2324
|
let envVars = [];
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
2325
|
+
let apiSetup = null;
|
|
2326
|
+
|
|
2327
|
+
let addedCodes = null; // --force over a file: the targets new to it (registers recorded for these only)
|
|
2328
|
+
if (!args.yes && isInteractive() && args.method !== API_METHOD) {
|
|
2329
|
+
// Over an existing file the wizard starts from its values (Enter keeps them).
|
|
2330
|
+
const wizardArgs = existing ? prefillFromConfig(args, existing, relayout) : args;
|
|
2331
|
+
const result = await runInteractive(cwd, wizardArgs, detection, existing ? registersOf(existing) : null,
|
|
2332
|
+
existing ? null : localMark);
|
|
1152
2333
|
if (!result) return 0; // User cancelled
|
|
1153
|
-
config = result.config;
|
|
1154
|
-
envVars = result.envVars;
|
|
2334
|
+
config = existing ? mergeWizardConfig(existing, result.config) : result.config;
|
|
2335
|
+
envVars = existing ? collectRequiredEnvVars(config.defaultMethod || 'llm', config.languages) : result.envVars;
|
|
2336
|
+
if (existing) addedCodes = new Set();
|
|
1155
2337
|
} else {
|
|
1156
2338
|
// Say WHY the wizard was skipped — an agent (or CI) piping stdin
|
|
1157
2339
|
// shouldn't have to guess which mode ran.
|
|
1158
|
-
if (!args.yes) {
|
|
1159
|
-
output.info('
|
|
2340
|
+
if (!args.yes && args.method === API_METHOD && isInteractive()) {
|
|
2341
|
+
output.info('--method api is set up from flags (--endpoint, --langs) — skipping the wizard.');
|
|
2342
|
+
} else if (!args.yes) {
|
|
2343
|
+
output.info(existing
|
|
2344
|
+
? `stdin is not a TTY — skipping the wizard: the flags say what to change in ${DEFAULT_CONFIG_FILENAME}.`
|
|
2345
|
+
: 'stdin is not a TTY — skipping the wizard, writing a default config.');
|
|
1160
2346
|
output.raw(' Configure via flags (--langs, --method, --model, ...); see `champollion init --help`.');
|
|
1161
2347
|
}
|
|
1162
|
-
|
|
2348
|
+
if (markDefaultsToLocal) {
|
|
2349
|
+
output.info(`Method: local — ${localMark.why}`);
|
|
2350
|
+
output.raw(` ${localMark.hosted}`);
|
|
2351
|
+
output.raw(` ${localMark.needs}`);
|
|
2352
|
+
}
|
|
2353
|
+
config = await buildDefaultConfig({ ...args, source: sourceLocale, ...(markDefaultsToLocal && { method: 'local' }) });
|
|
2354
|
+
applyDetection(config, detection, args);
|
|
2355
|
+
// Over an existing file: the file, with only what the flags name rewritten.
|
|
2356
|
+
if (existing) {
|
|
2357
|
+
const overlaid = overlayFlagsOnConfig(existing, config, args, { relayout, detection });
|
|
2358
|
+
config = overlaid.config;
|
|
2359
|
+
addedCodes = new Set(overlaid.added);
|
|
2360
|
+
}
|
|
2361
|
+
// --method api: one pair per target, the shape forge's DEPLOY.md shows.
|
|
2362
|
+
if (args.method === API_METHOD) {
|
|
2363
|
+
const listed = Array.isArray(config.languages) ? config.languages : Object.keys(config.languages || {});
|
|
2364
|
+
const targets = listed.length > 0 ? listed : (detection.found?.targets || []);
|
|
2365
|
+
if (targets.length === 0) {
|
|
2366
|
+
output.error('--method api needs the target languages: the endpoint is set per pair. Add --langs <codes> (e.g. --langs abc).');
|
|
2367
|
+
return 1;
|
|
2368
|
+
}
|
|
2369
|
+
const endpoint = String(args.endpoint).trim();
|
|
2370
|
+
const instructions = resolveAcceptsInstructions(args, cwd);
|
|
2371
|
+
writeApiPairs(config, targets, endpoint, instructions.value);
|
|
2372
|
+
apiSetup = { endpoint, targets, instructions };
|
|
2373
|
+
}
|
|
2374
|
+
const scriptPlan = applyScriptChoices(config, args);
|
|
2375
|
+
if (scriptPlan.error) {
|
|
2376
|
+
output.error(scriptPlan.error);
|
|
2377
|
+
return 1;
|
|
2378
|
+
}
|
|
2379
|
+
for (const { code, choices } of scriptPlan.needed) {
|
|
2380
|
+
const options = choices.map(c => `"${c.script}" (${c.label})`).join(' or ');
|
|
2381
|
+
// The entry as the file will hold it, with the script added — one
|
|
2382
|
+
// field in the file, never a re-run of init (Round 10: `init --force`
|
|
2383
|
+
// advised for one setting rewrote the whole config).
|
|
2384
|
+
const prior = Array.isArray(config.languages) ? undefined : config.languages[code];
|
|
2385
|
+
const register = typeof prior === 'string' ? prior : (prior && typeof prior === 'object' && prior.register) || defaultRegisterKey(code);
|
|
2386
|
+
const entry = { ...(prior && typeof prior === 'object' ? prior : {}), ...(register && { register }), script: choices[0].script };
|
|
2387
|
+
output.warn(`${code} is written in more than one orthography — ${options}. Champollion will not choose one for a `
|
|
2388
|
+
+ 'community, so `champollion sync` refuses to translate it until the config says which. Choose it in '
|
|
2389
|
+
+ `${DEFAULT_CONFIG_FILENAME}: add "script" to ${code}'s entry in "languages" — `
|
|
2390
|
+
+ `"${code}": ${JSON.stringify(entry).replace(/,"/g, ', "').replace(/":/g, '": ')} (or "script": "${choices[1].script}").`);
|
|
2391
|
+
}
|
|
1163
2392
|
envVars = collectRequiredEnvVars(config.defaultMethod || 'llm', config.languages);
|
|
2393
|
+
// An api endpoint off this machine needs its bearer key (a loopback
|
|
2394
|
+
// server started without a token needs none — lib/methods/api.js).
|
|
2395
|
+
if (apiSetup) {
|
|
2396
|
+
let host = '';
|
|
2397
|
+
try { host = new URL(apiSetup.endpoint).hostname; } catch { /* validated above */ }
|
|
2398
|
+
if (!LOOPBACK_HOSTS.has(host)) envVars.push({ envVar: API_KEY_ENV, label: 'champollion API endpoint' });
|
|
2399
|
+
}
|
|
1164
2400
|
|
|
1165
2401
|
// Typos in --langs write configs that only break at first sync — warn now.
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
2402
|
+
// A private-use code (qaa–qtz) is not a typo: it is the range kept for a
|
|
2403
|
+
// variety with no confirmed code (Round 8 personas: "check the spelling"
|
|
2404
|
+
// for the code the guide recommends).
|
|
2405
|
+
const named = parseNameFlag(args.name).map;
|
|
2406
|
+
for (const code of Array.isArray(config.languages) ? config.languages : Object.keys(config.languages || {})) {
|
|
2407
|
+
// Flutter writes locales with "_" (pt_BR); the cards know "pt-BR".
|
|
2408
|
+
if (getLanguageCard(code) || getLanguageCard(code.replace(/_/g, '-')) || DEFAULT_REGISTERS[code]) continue;
|
|
2409
|
+
if (isPrivateUseCode(code)) {
|
|
2410
|
+
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, `
|
|
2411
|
+
+ 'so no register presets, plural rules or script come from one. '
|
|
2412
|
+
+ (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>".`));
|
|
2413
|
+
continue;
|
|
1169
2414
|
}
|
|
2415
|
+
output.warn(`Unrecognized language code "${code}" — kept in config, but check the spelling.`);
|
|
1170
2416
|
}
|
|
1171
2417
|
}
|
|
1172
2418
|
|
|
2419
|
+
// --name code="Display name": written into the language's entry, so prompts
|
|
2420
|
+
// and reports name a language that has no card (a private-use code).
|
|
2421
|
+
{
|
|
2422
|
+
const { error } = applyDisplayNames(config, args);
|
|
2423
|
+
if (error) {
|
|
2424
|
+
output.error(error);
|
|
2425
|
+
return 1;
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// Each target's register goes into the file, not only into this output.
|
|
2430
|
+
// Over an existing file: only for the targets new to it (the others keep
|
|
2431
|
+
// their entries exactly as the file has them).
|
|
2432
|
+
recordRegisters(config, addedCodes);
|
|
2433
|
+
|
|
2434
|
+
// Both shapes on disk (en.json AND en/…): the detection's choice is
|
|
2435
|
+
// written down so sync never has to guess between them (an existing
|
|
2436
|
+
// file's own choice stands while its layout holds).
|
|
2437
|
+
if (relayout && detection.found?.ambiguous && config.localesDir
|
|
2438
|
+
&& path.resolve(cwd, config.localesDir) === path.resolve(cwd, detection.found.localesDir)) {
|
|
2439
|
+
config.localesLayout = detection.found.layout;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// ── Never write a localesDir that does not exist ──
|
|
2443
|
+
// Nothing found and no --dir: create the (default) directory so the
|
|
2444
|
+
// config is valid, and the next steps below say what to put in it.
|
|
2445
|
+
// Docusaurus is the exception: `docusaurus write-translations` creates
|
|
2446
|
+
// i18n/ with the files sync needs, and an empty one would only hide that.
|
|
2447
|
+
// A localesPattern (Flutter, gettext) was detected from files on disk:
|
|
2448
|
+
// its folder exists, and nothing is created — the pattern is the answer.
|
|
2449
|
+
const patternAbs = config.localesPattern ? path.resolve(cwd, config.localesPattern) : null;
|
|
2450
|
+
const localesAbs = patternAbs ? compileLocalesPattern(patternAbs).base : path.resolve(cwd, config.localesDir);
|
|
2451
|
+
// (run() shadows configPath with the config file's path.)
|
|
2452
|
+
const localesLabel = projectRelative(cwd, localesAbs);
|
|
2453
|
+
let createdLocalesDir = false;
|
|
2454
|
+
if (!patternAbs && config.format !== 'docusaurus' && !fs.existsSync(localesAbs)) {
|
|
2455
|
+
fs.mkdirSync(localesAbs, { recursive: true });
|
|
2456
|
+
createdLocalesDir = true;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
1173
2459
|
// ── Write config ──
|
|
1174
|
-
|
|
1175
|
-
|
|
2460
|
+
// Over an existing file: backed up first (never over an older backup),
|
|
2461
|
+
// then what changed and what was kept, field by field.
|
|
2462
|
+
if (freshProject) {
|
|
2463
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
2464
|
+
output.ok(`Created ${DEFAULT_CONFIG_FILENAME}`);
|
|
2465
|
+
} else {
|
|
2466
|
+
const { changed, kept } = existing ? describeConfigChanges(existing, config) : { changed: null, kept: [] };
|
|
2467
|
+
if (changed && changed.length === 0) {
|
|
2468
|
+
output.ok(`${DEFAULT_CONFIG_FILENAME} unchanged — it already says what the flags ask for (nothing written, no backup needed).`);
|
|
2469
|
+
} else {
|
|
2470
|
+
const backup = backupConfigFile(configPath, existingRaw);
|
|
2471
|
+
const backupName = projectRelative(cwd, backup.path).replace(/^\.\//, '');
|
|
2472
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
2473
|
+
if (!existing) {
|
|
2474
|
+
output.warn(`${DEFAULT_CONFIG_FILENAME} could not be read (${existingUnreadable}), so nothing in it could be kept: `
|
|
2475
|
+
+ `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.`);
|
|
2476
|
+
} else {
|
|
2477
|
+
output.ok(`Updated ${DEFAULT_CONFIG_FILENAME} — only what the flags ask for; the previous file is in ${backupName}`
|
|
2478
|
+
+ `${backup.reused ? ' (an earlier backup of the same file)' : ''}.`);
|
|
2479
|
+
output.raw(' Changed:');
|
|
2480
|
+
for (const line of changed) output.raw(` ${line}`);
|
|
2481
|
+
if (kept.length > 0) output.raw(` Kept as they were: ${kept.join(', ')}`);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
ensureCacheIgnored(cwd);
|
|
2486
|
+
if (createdLocalesDir) {
|
|
2487
|
+
output.ok(`Created ${localesLabel.replace(/^\.\//, '')}/ (no locale files were found to point at)`);
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
// ── Create the target locale files --langs asked for ──
|
|
2491
|
+
// Empty files of the right format, in the detected layout (fr.json, or
|
|
2492
|
+
// fr/common.json + fr/admin.json mirroring en/). Nobody should have to
|
|
2493
|
+
// hand-create an empty fr.json; sync fills them. Needs the source on
|
|
2494
|
+
// disk — the source's files decide the namespaces and the format.
|
|
2495
|
+
const targetCodes = Array.isArray(config.languages) ? config.languages : Object.keys(config.languages || {});
|
|
2496
|
+
let sourceReady = config.format === 'docusaurus';
|
|
2497
|
+
if (config.format !== 'docusaurus') {
|
|
2498
|
+
const layout = discoverLocaleLayout({
|
|
2499
|
+
inputLocale: config.inputLocale, localesDir: localesAbs, format: config.format,
|
|
2500
|
+
...(patternAbs ? { localesPattern: patternAbs } : { localesLayout: config.localesLayout || null }),
|
|
2501
|
+
}, { cwd });
|
|
2502
|
+
sourceReady = layout.namespaced
|
|
2503
|
+
? layout.sourceFiles.length > 0
|
|
2504
|
+
: fs.existsSync(layout.sourceFiles[0].path);
|
|
2505
|
+
if (sourceReady && targetCodes.length > 0) {
|
|
2506
|
+
const { created, unsupported, refused } = createMissingTargetFiles(layout, targetCodes);
|
|
2507
|
+
if (created.length > 0) {
|
|
2508
|
+
// Paths from the project root (locale/fr/LC_MESSAGES/django.po), not
|
|
2509
|
+
// from the locale folder: "fr/LC_MESSAGES/django.po" named no real
|
|
2510
|
+
// path (Round 3, Django persona).
|
|
2511
|
+
const shown = created.slice(0, 6).map(f => projectRelative(cwd, f.path).replace(/^\.\//, '')).join(', ');
|
|
2512
|
+
const more = created.length > 6 ? `, +${created.length - 6} more` : '';
|
|
2513
|
+
output.ok(`Created ${created.length} empty target file(s): ${shown}${more} — \`champollion sync\` fills them`);
|
|
2514
|
+
}
|
|
2515
|
+
for (const f of unsupported) {
|
|
2516
|
+
output.warn(`Did not create ${projectRelative(cwd, f.path).replace(/^\.\//, '')}: this version cannot write "${f.format}" files yet.`);
|
|
2517
|
+
}
|
|
2518
|
+
for (const f of refused) {
|
|
2519
|
+
output.warn(`Did not create ${f.rel}: the path leaves ${localesLabel}/ — check the language code.`);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
// Flutter: the app's own messages come from these ARB files, but
|
|
2523
|
+
// Material/Cupertino widget text comes from flutter_localizations, which
|
|
2524
|
+
// covers a fixed list of languages — a target outside it needs a
|
|
2525
|
+
// fallback delegate (Round 11, hospital persona; lib/flutter-locales.js).
|
|
2526
|
+
if (targetCodes.length > 0 && (layout.sourceFiles[0]?.format === 'arb' || /\.arb$/i.test(layout.sourceFiles[0]?.path || ''))) {
|
|
2527
|
+
for (const { level, text } of flutterLocaleLines(targetCodes)) output[level](text);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
// Django: a locale/ beside manage.py belongs to no app, so Django reads it
|
|
2531
|
+
// only through LOCALE_PATHS, and offers only the languages in LANGUAGES.
|
|
2532
|
+
// Without them CI compiled and committed catalogs the site never showed
|
|
2533
|
+
// (Round 12, Django persona).
|
|
2534
|
+
if (isDjangoRootLocale(cwd)) {
|
|
2535
|
+
output.info('Django reads locale/ only when LOCALE_PATHS in your settings names it, and offers only the languages '
|
|
2536
|
+
+ 'LANGUAGES lists (its default: every language Django ships with): '
|
|
2537
|
+
+ 'https://champollion.dev/docs/integrations/frameworks#django-locale-paths');
|
|
2538
|
+
}
|
|
1176
2539
|
output.raw('');
|
|
1177
2540
|
|
|
1178
2541
|
// ── Show register summary when languages are configured ──
|
|
2542
|
+
// What the config now says for each target, the other presets it could
|
|
2543
|
+
// say, and how to change it (Round 5, i18next persona).
|
|
1179
2544
|
const langCount = Array.isArray(config.languages) ? config.languages.length : Object.keys(config.languages).length;
|
|
1180
2545
|
if (langCount > 0) {
|
|
1181
|
-
output.raw(' Registers:');
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
2546
|
+
output.raw(' Registers (written to "languages" in the config):');
|
|
2547
|
+
const entries = Array.isArray(config.languages)
|
|
2548
|
+
? config.languages.map(code => [code, {}])
|
|
2549
|
+
: Object.entries(config.languages);
|
|
2550
|
+
let example = null;
|
|
2551
|
+
let genderShown = false;
|
|
2552
|
+
for (const [code, value] of entries) {
|
|
2553
|
+
const card = getLanguageCard(code);
|
|
2554
|
+
const name = (value && typeof value === 'object' && typeof value.name === 'string' && value.name) || card?.name || code;
|
|
2555
|
+
const chosen = typeof value === 'string' ? value : (value && typeof value === 'object' ? value.register : null);
|
|
2556
|
+
const others = getRegisterPresets(resolveCode(code)).map(p => p.key).filter(k => k !== chosen);
|
|
2557
|
+
const shown = chosen
|
|
2558
|
+
? (chosen.length > 40 ? `"${chosen.slice(0, 37)}…"` : chosen)
|
|
2559
|
+
: '(no presets — a professional register)';
|
|
2560
|
+
output.raw(` ${code.padEnd(6)} ${name} → ${shown}${others.length > 0 ? ` (others: ${others.join(', ')})` : ''}`);
|
|
2561
|
+
// The gender guidance its prompts carry by default (LLM methods) —
|
|
2562
|
+
// never a silent choice (Round 8: écriture inclusive was invisible).
|
|
2563
|
+
const ownGender = value && typeof value === 'object' ? value.genderGuidance : undefined;
|
|
2564
|
+
const genderSetting = ownGender !== undefined ? ownGender : config.genderGuidance;
|
|
2565
|
+
if (genderSetting === false) {
|
|
2566
|
+
output.raw(' gender: no guidance (set off in the config)');
|
|
2567
|
+
} else {
|
|
2568
|
+
const g = summarizeGenderGuidance(typeof genderSetting === 'string' ? genderSetting : getLanguageCard(resolveCode(code))?.gender?.inclusiveGuidance);
|
|
2569
|
+
if (g) {
|
|
2570
|
+
output.raw(` gender (${typeof genderSetting === 'string' ? 'your config' : 'default'}): ${g}`);
|
|
2571
|
+
genderShown = true;
|
|
1194
2572
|
}
|
|
1195
2573
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
2574
|
+
if (!example && others.length > 0 && typeof value === 'string') example = { code, preset: others[0] };
|
|
2575
|
+
}
|
|
2576
|
+
if (genderShown) {
|
|
2577
|
+
output.raw(' Gender guidance: "genderGuidance" in the config (per language, or for all) — false for none, or your own words.');
|
|
2578
|
+
}
|
|
2579
|
+
if (example) {
|
|
2580
|
+
output.raw(` To change one, edit "languages" in ${DEFAULT_CONFIG_FILENAME}: a preset name (e.g. "${example.code}": "${example.preset}")`);
|
|
2581
|
+
output.raw(' or your own words describing the tone and audience; `champollion status` shows what each pair uses.');
|
|
1204
2582
|
}
|
|
1205
2583
|
output.raw('');
|
|
1206
2584
|
}
|
|
1207
2585
|
|
|
1208
2586
|
// ── Next steps — skip env var instruction if already set ──
|
|
2587
|
+
// One blank line under the heading, none stacked: optional hints and the
|
|
2588
|
+
// numbered steps follow each other directly (two blank lines used to
|
|
2589
|
+
// open this list — hospital persona, 2026-10).
|
|
1209
2590
|
output.raw(' Next steps:');
|
|
1210
2591
|
output.raw('');
|
|
1211
2592
|
let step = 1;
|
|
1212
2593
|
|
|
1213
2594
|
// Only show env var setup if any required key is missing from the environment
|
|
1214
|
-
|
|
2595
|
+
// An optional variable (LOCAL_API_BASE has a working default) is a hint,
|
|
2596
|
+
// not a step: telling someone to "set your API key" for a local model
|
|
2597
|
+
// would send them looking for a key that does not exist.
|
|
2598
|
+
const isOptional = (envVar) => METHOD_OPTIONS.some(m => m.envVar === envVar && m.envOptional);
|
|
2599
|
+
for (const { envVar } of envVars.filter(({ envVar }) => isOptional(envVar) && !process.env[envVar])) {
|
|
2600
|
+
const opt = METHOD_OPTIONS.find(m => m.envVar === envVar);
|
|
2601
|
+
output.raw(` (Local model endpoint: export ${envVar}=${opt.envExample} — only if yours is elsewhere.)`);
|
|
2602
|
+
}
|
|
2603
|
+
const missingEnvVars = envVars.filter(({ envVar }) => !process.env[envVar] && !isOptional(envVar));
|
|
1215
2604
|
if (missingEnvVars.length === 1) {
|
|
1216
2605
|
output.raw(` ${step}. Set your API key:`);
|
|
1217
2606
|
output.raw(` export ${missingEnvVars[0].envVar}=...`);
|
|
2607
|
+
// No key at all? A model on this machine needs none.
|
|
2608
|
+
if (missingEnvVars[0].envVar === 'OPENROUTER_API_KEY') {
|
|
2609
|
+
output.raw(' (or, with no key: a model on this machine — Ollama, LM Studio, a forge model:');
|
|
2610
|
+
output.raw(` set "defaultMethod": "local" and "model": "llama3.1" (your model's name) in ${DEFAULT_CONFIG_FILENAME})`);
|
|
2611
|
+
}
|
|
1218
2612
|
step++;
|
|
1219
2613
|
} else if (missingEnvVars.length > 1) {
|
|
1220
2614
|
output.raw(` ${step}. Set your API key(s):`);
|
|
@@ -1224,36 +2618,250 @@ async function run(args, cwd) {
|
|
|
1224
2618
|
step++;
|
|
1225
2619
|
}
|
|
1226
2620
|
|
|
1227
|
-
|
|
2621
|
+
if (config.format === 'docusaurus' && !fs.existsSync(localesAbs)) {
|
|
2622
|
+
output.raw(` ${step}. Generate the i18n files: npx docusaurus write-translations --locale ${targetCodes[0] || '<lang>'}`);
|
|
2623
|
+
step++;
|
|
2624
|
+
} else if (!sourceReady && patternAbs) {
|
|
2625
|
+
const where = projectRelative(cwd, compileLocalesPattern(patternAbs).render(config.inputLocale, '<ns>'));
|
|
2626
|
+
output.raw(` ${step}. Put your source strings in ${where}`);
|
|
2627
|
+
step++;
|
|
2628
|
+
} else if (!sourceReady) {
|
|
2629
|
+
output.raw(` ${step}. Put your source strings in ${localesLabel}/${config.inputLocale}.json`
|
|
2630
|
+
+ ' (or a folder of files: ' + `${localesLabel}/${config.inputLocale}/common.json)`);
|
|
2631
|
+
step++;
|
|
2632
|
+
}
|
|
1228
2633
|
if (langCount === 0) {
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
2634
|
+
const onDisk = detection.found && path.resolve(cwd, detection.found.localesDir) === localesAbs
|
|
2635
|
+
? detection.found.targets : [];
|
|
2636
|
+
if (onDisk.length > 0) {
|
|
2637
|
+
output.raw(` ${step}. Target locales found on disk: ${onDisk.join(', ')} — sync translates into them.`);
|
|
2638
|
+
output.raw(` To add more: list every target in "languages" in ${DEFAULT_CONFIG_FILENAME} (e.g. "languages": ["fr", "de"]);`);
|
|
2639
|
+
output.raw(' sync creates the files a new one needs.');
|
|
2640
|
+
} else {
|
|
2641
|
+
output.raw(` ${step}. Choose target languages: list them in "languages" in ${DEFAULT_CONFIG_FILENAME}`);
|
|
2642
|
+
output.raw(' (e.g. "languages": ["fr", "de"]); sync creates their empty target files.');
|
|
2643
|
+
}
|
|
2644
|
+
step++;
|
|
2645
|
+
}
|
|
2646
|
+
output.raw(` ${step}. Run: champollion status # verify your setup`);
|
|
2647
|
+
output.raw(` ${step + 1}. Run: champollion sync # translate!`);
|
|
2648
|
+
// Which method the config uses, and how to choose another without editing
|
|
2649
|
+
// JSON — a persona hand-edited the config to pick its local model (Round 3).
|
|
2650
|
+
const chosenMethod = config.defaultMethod || 'llm';
|
|
2651
|
+
// A model served on this machine: a CI runner has none, so a workflow
|
|
2652
|
+
// copied as-is fails there (Round 11, i18next persona — the CI guide said
|
|
2653
|
+
// so, init never mentioned CI). One line: what CI needs, and where to read.
|
|
2654
|
+
let apiHost = '';
|
|
2655
|
+
try { apiHost = apiSetup ? new URL(apiSetup.endpoint).hostname : ''; } catch { /* validated */ }
|
|
2656
|
+
if (chosenMethod === 'local' || (apiSetup && LOOPBACK_HOSTS.has(apiHost))) {
|
|
2657
|
+
output.raw(` ${step + 2}. In CI: a runner has no model server — run a hosted method there (sync --method llm --model ${DEFAULT_OPENROUTER_MODEL}, `
|
|
2658
|
+
+ `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"'}): `
|
|
2659
|
+
+ 'https://champollion.dev/docs/guides/ci-cd');
|
|
2660
|
+
}
|
|
2661
|
+
const chosenModel = config.model ? `, model ${config.model}` : '';
|
|
2662
|
+
output.raw('');
|
|
2663
|
+
if (apiSetup) {
|
|
2664
|
+
const { value, from } = apiSetup.instructions;
|
|
2665
|
+
output.raw(` Method: api, endpoint ${apiSetup.endpoint} — written as "pairs" for ${apiSetup.targets.map(t => `${config.inputLocale}:${t}`).join(', ')}.`);
|
|
2666
|
+
output.raw(typeof value === 'boolean'
|
|
2667
|
+
? ` acceptsInstructions: ${value} (from ${from})${value ? '' : ' — the quality gate\'s retries go to the pair\'s "fallback", if you add one'}.`
|
|
2668
|
+
: ' acceptsInstructions: not stated — the text is sent alone. If the server follows per-key instructions, '
|
|
2669
|
+
+ 'set "acceptsInstructions": true in those "pairs" entries (a model trained with nmt-forge does not: false).');
|
|
2670
|
+
output.raw(' A "fallback" method for the strings it cannot translate safely is added by hand: see the DEPLOY.md beside the model.');
|
|
1232
2671
|
} else {
|
|
1233
|
-
|
|
1234
|
-
|
|
2672
|
+
// How to switch: the config field (or a pair's own), never a re-run of
|
|
2673
|
+
// init (Round 10, Next.js persona: `init --force --method … --model …`
|
|
2674
|
+
// printed here rewrote the whole config). --method/--model on sync try
|
|
2675
|
+
// one for a single run.
|
|
2676
|
+
output.raw(` Method: ${chosenMethod}${chosenModel}. To use another, edit "defaultMethod" and "model" in ${DEFAULT_CONFIG_FILENAME}`);
|
|
2677
|
+
output.raw(' (or a pair\'s own "method"/"model" in "pairs"). To try one for a single run: champollion sync --method <name> --model <model>');
|
|
2678
|
+
output.raw(' — the file is not changed. `champollion init --help` lists the methods.');
|
|
2679
|
+
}
|
|
2680
|
+
// Where the strings go — said at init, with how to keep them here
|
|
2681
|
+
// (Round 10, hospital persona: the default sent them to a hosted model and
|
|
2682
|
+
// nothing at init said so).
|
|
2683
|
+
const destination = whereTextGoes(config, apiSetup);
|
|
2684
|
+
if (destination) {
|
|
2685
|
+
output.raw(` Where the text goes: ${chosenMethod} sends every string it translates to ${destination}.`);
|
|
2686
|
+
// A hosted method beside a local-only mark came from --method, the
|
|
2687
|
+
// wizard or the existing file — never from the default; said, with the file.
|
|
2688
|
+
if (localMark) output.raw(` Note: ${localMark.why}`);
|
|
2689
|
+
output.raw(' To keep it on this machine, use a model served here (Ollama, LM Studio, vLLM): "defaultMethod": "local" and');
|
|
2690
|
+
output.raw(' "model": "<its name>" in the config — or try it for one run: champollion sync --method local --model <its name>.');
|
|
2691
|
+
output.raw(' A model served by `nmt-forge serve`: a pair\'s "method": "api" with its "endpoint" (`champollion init --help`).');
|
|
1235
2692
|
}
|
|
1236
2693
|
|
|
1237
2694
|
// ── Evidence hint — published results + live engine availability per pair ──
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
2695
|
+
// The configured targets, never an example code: a project that targets
|
|
2696
|
+
// only abc was told to `xliff export --locale fr` (hospital persona,
|
|
2697
|
+
// 2026-10). The canonical grouped command form, as the docs write it.
|
|
2698
|
+
const configuredTargets = Array.isArray(config.languages)
|
|
2699
|
+
? config.languages
|
|
2700
|
+
: Object.keys(config.languages || {});
|
|
2701
|
+
const firstTarget = configuredTargets[0] || null;
|
|
2702
|
+
if (firstTarget) {
|
|
1242
2703
|
output.raw('');
|
|
1243
2704
|
output.raw(' Evidence for your pairs (published results + what’s runnable now):');
|
|
1244
|
-
|
|
2705
|
+
for (const code of configuredTargets.slice(0, 3)) {
|
|
2706
|
+
output.raw(` champollion network recommend ${config.inputLocale} ${code}`);
|
|
2707
|
+
}
|
|
2708
|
+
if (configuredTargets.length > 3) output.raw(` … and the same for ${configuredTargets.slice(3).join(', ')}`);
|
|
1245
2709
|
}
|
|
1246
2710
|
|
|
1247
2711
|
// ── Cost-saving tips — help users discover TM early ──
|
|
1248
2712
|
output.raw('');
|
|
1249
2713
|
output.raw(' After your first sync:');
|
|
1250
2714
|
output.raw(' champollion tm stats # see cached translations');
|
|
1251
|
-
|
|
2715
|
+
if (firstTarget) {
|
|
2716
|
+
output.raw(` champollion xliff export --locale ${firstTarget} # export for human review`);
|
|
2717
|
+
}
|
|
1252
2718
|
output.raw('');
|
|
1253
2719
|
output.raw(' Translations are cached in .champollion/tm.json \u2014 re-running sync');
|
|
1254
2720
|
output.raw(' only calls the API for keys that actually changed.');
|
|
2721
|
+
if (freshProject) {
|
|
2722
|
+
output.raw('');
|
|
2723
|
+
output.raw(` ${licenseLine()}`);
|
|
2724
|
+
}
|
|
1255
2725
|
|
|
1256
2726
|
return 0;
|
|
1257
2727
|
}
|
|
1258
2728
|
|
|
1259
|
-
|
|
2729
|
+
/**
|
|
2730
|
+
* The register preset a language's card makes the default, or null when the
|
|
2731
|
+
* language has no presets. Resolved like config.js resolves it at run time
|
|
2732
|
+
* (resolveCode, then the card), so what init writes is what sync would use.
|
|
2733
|
+
*
|
|
2734
|
+
* @param {string} code
|
|
2735
|
+
* @returns {string|null}
|
|
2736
|
+
*/
|
|
2737
|
+
function defaultRegisterKey(code) {
|
|
2738
|
+
const presets = getRegisterPresets(resolveCode(code));
|
|
2739
|
+
return (presets.find(p => p.isDefault) || presets[0])?.key || null;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
/**
|
|
2743
|
+
* Write each target's register into the config — the object form `languages`
|
|
2744
|
+
* already supports ({ "fr": "formal-vous", "es": "neutral-latam" }) — so the
|
|
2745
|
+
* choice is visible and editable in the file, not only in init's output
|
|
2746
|
+
* (Round 5, i18next persona: init printed "es → neutral-latam" but the config
|
|
2747
|
+
* said only ["fr","es"], so a team targeting Spain would not notice the
|
|
2748
|
+
* Latin-American default). The values are the presets sync would have used
|
|
2749
|
+
* anyway: behaviour does not change, only what the file shows.
|
|
2750
|
+
*
|
|
2751
|
+
* A list stays a list when no target has presets (nothing to show), and an
|
|
2752
|
+
* empty list (auto-detect from the folder) stays empty. Object-form entries
|
|
2753
|
+
* keep any register already chosen (the wizard); `{}` becomes the preset, and
|
|
2754
|
+
* an entry with other fields (method, script) gets a `register` beside them.
|
|
2755
|
+
* `onlyCodes` limits it to some targets — the ones `init --force` adds to an
|
|
2756
|
+
* existing file, whose other entries stay exactly as they were.
|
|
2757
|
+
*
|
|
2758
|
+
* @param {object} config - Mutated
|
|
2759
|
+
* @param {Set<string>|null} [onlyCodes]
|
|
2760
|
+
* @returns {object} config
|
|
2761
|
+
*/
|
|
2762
|
+
function recordRegisters(config, onlyCodes = null) {
|
|
2763
|
+
const langs = config.languages;
|
|
2764
|
+
const eligible = (code) => !onlyCodes || onlyCodes.has(code);
|
|
2765
|
+
if (Array.isArray(langs)) {
|
|
2766
|
+
if (langs.length === 0 || !langs.some(code => eligible(code) && defaultRegisterKey(code))) return config;
|
|
2767
|
+
const obj = {};
|
|
2768
|
+
for (const code of langs) obj[code] = (eligible(code) && defaultRegisterKey(code)) || {};
|
|
2769
|
+
config.languages = obj;
|
|
2770
|
+
return config;
|
|
2771
|
+
}
|
|
2772
|
+
if (langs && typeof langs === 'object') {
|
|
2773
|
+
for (const [code, value] of Object.entries(langs)) {
|
|
2774
|
+
if (!eligible(code)) continue;
|
|
2775
|
+
if (!value || typeof value !== 'object' || value.register) continue;
|
|
2776
|
+
const preset = defaultRegisterKey(code);
|
|
2777
|
+
if (!preset) continue;
|
|
2778
|
+
if (Object.keys(value).length === 0) langs[code] = preset;
|
|
2779
|
+
else value.register = preset;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
return config;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
/** Where the CLI's license text is published (also LICENSE in the npm package). */
|
|
2786
|
+
const LICENSE_URL = 'https://github.com/gamedaysuits/Champollion/blob/main/cli/LICENSE';
|
|
2787
|
+
|
|
2788
|
+
/**
|
|
2789
|
+
* The one plain-language page on who each package's license covers
|
|
2790
|
+
* (cli/website/docs/getting-started/who-may-use-this.md) — the shop, clinic
|
|
2791
|
+
* and Django-clinic personas could not tell from "noncommercial" alone
|
|
2792
|
+
* whether they were covered (every round through Round 14).
|
|
2793
|
+
*/
|
|
2794
|
+
const WHO_MAY_USE_URL = 'https://champollion.dev/docs/getting-started/who-may-use-this';
|
|
2795
|
+
|
|
2796
|
+
/**
|
|
2797
|
+
* The license lines init prints once, on a fresh project: nothing on the setup
|
|
2798
|
+
* path said the CLI is noncommercial (Round 5, Next.js persona — a store's
|
|
2799
|
+
* codebase). The license id is read from package.json (the SSOT); the plain
|
|
2800
|
+
* words are only said for the license they describe.
|
|
2801
|
+
*
|
|
2802
|
+
* @returns {string}
|
|
2803
|
+
*/
|
|
2804
|
+
function licenseLine() {
|
|
2805
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf-8'));
|
|
2806
|
+
if (pkg.license === 'PolyForm-Noncommercial-1.0.0') {
|
|
2807
|
+
return 'License: PolyForm Noncommercial 1.0.0 — free for noncommercial use; using it for a commercial purpose '
|
|
2808
|
+
+ 'is not covered by this license.\n'
|
|
2809
|
+
+ ` 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`
|
|
2810
|
+
+ ` The license text governs: ${LICENSE_URL}`;
|
|
2811
|
+
}
|
|
2812
|
+
return `License: ${pkg.license} — see LICENSE in the champollion package (${LICENSE_URL}). Who may use it: ${WHO_MAY_USE_URL}`;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/**
|
|
2816
|
+
* A Django project (manage.py, as lib/locale-layout.js detectGettextLayout
|
|
2817
|
+
* tells Django apart) with a locale/ folder at its root — the folder Django
|
|
2818
|
+
* loads only when LOCALE_PATHS names it.
|
|
2819
|
+
*/
|
|
2820
|
+
function isDjangoRootLocale(cwd) {
|
|
2821
|
+
try {
|
|
2822
|
+
return fs.statSync(path.join(cwd, 'manage.py')).isFile()
|
|
2823
|
+
&& fs.statSync(path.join(cwd, 'locale')).isDirectory();
|
|
2824
|
+
} catch {
|
|
2825
|
+
return false;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
/** .gitignore lines that already keep `.champollion/` out of git. */
|
|
2830
|
+
const CACHE_IGNORE_LINES = new Set([
|
|
2831
|
+
'.champollion', '.champollion/', '/.champollion', '/.champollion/',
|
|
2832
|
+
'.champollion/*', '/.champollion/*', '.champollion/**', '/.champollion/**',
|
|
2833
|
+
]);
|
|
2834
|
+
|
|
2835
|
+
/**
|
|
2836
|
+
* Keep the translation cache out of version control. `.champollion/` holds the
|
|
2837
|
+
* per-machine Translation Memory; the CI guide's `git add --all` would commit
|
|
2838
|
+
* it in a repo that does not ignore it. The lock files stay tracked (they are
|
|
2839
|
+
* how the next run knows what changed).
|
|
2840
|
+
*
|
|
2841
|
+
* ALWAYS, not only in a git repo. It used to skip a folder with no `.git` —
|
|
2842
|
+
* and a project that ran `git init` after `champollion init` (or lives in a
|
|
2843
|
+
* subfolder of a repo) then committed the cache with its first
|
|
2844
|
+
* `git add --all` (synthetic Django/i18next personas, 2026-10). A .gitignore
|
|
2845
|
+
* in a folder that is not (yet) a repo costs nothing. Idempotent: never
|
|
2846
|
+
* duplicates a line that already covers the folder.
|
|
2847
|
+
*/
|
|
2848
|
+
function ensureCacheIgnored(cwd) {
|
|
2849
|
+
const gitignore = path.join(cwd, '.gitignore');
|
|
2850
|
+
const exists = fs.existsSync(gitignore);
|
|
2851
|
+
const current = exists ? fs.readFileSync(gitignore, 'utf-8') : '';
|
|
2852
|
+
const covered = current.split(/\r?\n/).map((l) => l.trim()).some((l) => CACHE_IGNORE_LINES.has(l));
|
|
2853
|
+
if (covered) return;
|
|
2854
|
+
const lead = current && !current.endsWith('\n') ? '\n' : '';
|
|
2855
|
+
const gap = current ? '\n' : '';
|
|
2856
|
+
fs.writeFileSync(gitignore,
|
|
2857
|
+
`${current}${lead}${gap}# champollion: per-machine translation cache (commit the .champollion*.lock files)\n.champollion/\n`,
|
|
2858
|
+
'utf-8');
|
|
2859
|
+
output.ok(`${exists ? 'Added .champollion/ to' : 'Created'} .gitignore `
|
|
2860
|
+
+ `(${exists ? '' : 'ignoring .champollion/: '}the translation cache is per-machine; the lock files stay tracked)`);
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
export {
|
|
2864
|
+
run, parseLanguageInput, buildDefaultConfig, buildConfig,
|
|
2865
|
+
detectLocaleSetup, describeLocaleSetupHint, FRAMEWORK_LAYOUTS, GENERIC_LOCALE_DIRS,
|
|
2866
|
+
mergeWizardConfig, describeConfigChanges,
|
|
2867
|
+
};
|