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