@ng-linguo/extract 0.9.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 +177 -0
- package/linguo.config.schema.json +53 -0
- package/package.json +38 -0
- package/src/cli.d.ts +2 -0
- package/src/cli.js +287 -0
- package/src/cli.js.map +1 -0
- package/src/index.d.ts +10 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/interactive.d.ts +12 -0
- package/src/interactive.js +679 -0
- package/src/interactive.js.map +1 -0
- package/src/lib/apply.d.ts +20 -0
- package/src/lib/apply.js +43 -0
- package/src/lib/apply.js.map +1 -0
- package/src/lib/clipboard.d.ts +17 -0
- package/src/lib/clipboard.js +96 -0
- package/src/lib/clipboard.js.map +1 -0
- package/src/lib/compile.d.ts +12 -0
- package/src/lib/compile.js +29 -0
- package/src/lib/compile.js.map +1 -0
- package/src/lib/config.d.ts +104 -0
- package/src/lib/config.js +185 -0
- package/src/lib/config.js.map +1 -0
- package/src/lib/merge.d.ts +13 -0
- package/src/lib/merge.js +34 -0
- package/src/lib/merge.js.map +1 -0
- package/src/lib/normalize.d.ts +15 -0
- package/src/lib/normalize.js +21 -0
- package/src/lib/normalize.js.map +1 -0
- package/src/lib/po.d.ts +25 -0
- package/src/lib/po.js +110 -0
- package/src/lib/po.js.map +1 -0
- package/src/lib/prompt.d.ts +33 -0
- package/src/lib/prompt.js +80 -0
- package/src/lib/prompt.js.map +1 -0
- package/src/lib/runner.d.ts +62 -0
- package/src/lib/runner.js +102 -0
- package/src/lib/runner.js.map +1 -0
- package/src/lib/scan.d.ts +31 -0
- package/src/lib/scan.js +183 -0
- package/src/lib/scan.js.map +1 -0
- package/src/lib/translation-prompt.txt +214 -0
- package/src/lib/translator.d.ts +83 -0
- package/src/lib/translator.js +91 -0
- package/src/lib/translator.js.map +1 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runInit = runInit;
|
|
4
|
+
exports.runInteractive = runInteractive;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const apply_1 = require("./lib/apply");
|
|
8
|
+
const clipboard_1 = require("./lib/clipboard");
|
|
9
|
+
const config_1 = require("./lib/config");
|
|
10
|
+
const po_1 = require("./lib/po");
|
|
11
|
+
const prompt_1 = require("./lib/prompt");
|
|
12
|
+
const runner_1 = require("./lib/runner");
|
|
13
|
+
const translator_1 = require("./lib/translator");
|
|
14
|
+
// ── Banner ───────────────────────────────────────────────────────────────────
|
|
15
|
+
/** A fixed-width block font (5 rows per letter) for the letters in "linguo". */
|
|
16
|
+
const GLYPH_ROWS = 5;
|
|
17
|
+
const GLYPHS = {
|
|
18
|
+
l: ['█ ', '█ ', '█ ', '█ ', '████'],
|
|
19
|
+
i: ['███', ' █ ', ' █ ', ' █ ', '███'],
|
|
20
|
+
n: ['█ █', '██ █', '█ ██', '█ █', '█ █'],
|
|
21
|
+
g: ['████', '█ ', '█ ██', '█ █', '████'],
|
|
22
|
+
u: ['█ █', '█ █', '█ █', '█ █', '████'],
|
|
23
|
+
o: ['████', '█ █', '█ █', '█ █', '████'],
|
|
24
|
+
};
|
|
25
|
+
function lerp(a, b, t) {
|
|
26
|
+
return Math.round(a + (b - a) * t);
|
|
27
|
+
}
|
|
28
|
+
const RESET = '\x1b[0m';
|
|
29
|
+
const BOLD = '\x1b[1m';
|
|
30
|
+
const DIM = '\x1b[2m';
|
|
31
|
+
const RED = '\x1b[31m';
|
|
32
|
+
/** The cyan→fuchsia gradient RGB at position `t` in `[0, 1]`. */
|
|
33
|
+
function gradientRgb(t) {
|
|
34
|
+
return [lerp(56, 232, t), lerp(189, 121, t), lerp(248, 249, t)];
|
|
35
|
+
}
|
|
36
|
+
/** A cyan→fuchsia gradient colour for position `t` in `[0, 1]`. */
|
|
37
|
+
function gradient(t) {
|
|
38
|
+
const [r, g, b] = gradientRgb(t);
|
|
39
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
40
|
+
}
|
|
41
|
+
/** Wrap text in a 24-bit colour. */
|
|
42
|
+
function rgb(r, g, b, text) {
|
|
43
|
+
return `\x1b[38;2;${r};${g};${b}m${text}${RESET}`;
|
|
44
|
+
}
|
|
45
|
+
/** Paint a line of text with the horizontal gradient. */
|
|
46
|
+
function paintGradient(line) {
|
|
47
|
+
const width = line.length;
|
|
48
|
+
let out = BOLD;
|
|
49
|
+
for (let i = 0; i < width; i += 1) {
|
|
50
|
+
out += gradient(width <= 1 ? 0 : i / (width - 1)) + (line[i] ?? ' ');
|
|
51
|
+
}
|
|
52
|
+
return out + RESET;
|
|
53
|
+
}
|
|
54
|
+
/** Pick a random element from a non-empty list. */
|
|
55
|
+
function pick(arr) {
|
|
56
|
+
return arr[Math.floor(Math.random() * arr.length)] ?? arr[0];
|
|
57
|
+
}
|
|
58
|
+
// Palette + glyphs for the procedural "growth" sprouting from the letters.
|
|
59
|
+
const GREENS = [
|
|
60
|
+
[74, 222, 128],
|
|
61
|
+
[34, 197, 94],
|
|
62
|
+
[132, 204, 22],
|
|
63
|
+
[22, 163, 74],
|
|
64
|
+
[163, 230, 53],
|
|
65
|
+
];
|
|
66
|
+
const BLOOMS = [
|
|
67
|
+
[244, 114, 182],
|
|
68
|
+
[251, 191, 36],
|
|
69
|
+
[248, 113, 113],
|
|
70
|
+
[167, 139, 250],
|
|
71
|
+
[249, 168, 212],
|
|
72
|
+
[226, 232, 240],
|
|
73
|
+
];
|
|
74
|
+
const GRASS = ["'", ',', '.', '`', '*', 'ʻ'];
|
|
75
|
+
const STEMS = ['|', '¦', "'", '!'];
|
|
76
|
+
const HEADS = ['❀', '✿', '❁', '✾', '⚘'];
|
|
77
|
+
/** Colour a growth glyph: flower heads bloom, everything else is grassy green. */
|
|
78
|
+
function colorGrowth(ch) {
|
|
79
|
+
if (ch === ' ') {
|
|
80
|
+
return ' ';
|
|
81
|
+
}
|
|
82
|
+
const [r, g, b] = HEADS.includes(ch) ? pick(BLOOMS) : pick(GREENS);
|
|
83
|
+
return rgb(r, g, b, ch);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build two growth rows (a base layer of grass and a sparser layer of taller
|
|
87
|
+
* blades + flowers) that sprout, unevenly and randomly, from the top edge of
|
|
88
|
+
* the letters — so the banner looks a little different every run.
|
|
89
|
+
*/
|
|
90
|
+
function growGarden(topRow, width) {
|
|
91
|
+
const tall = new Array(width).fill(' ');
|
|
92
|
+
const base = new Array(width).fill(' ');
|
|
93
|
+
for (let c = 0; c < width; c += 1) {
|
|
94
|
+
if (topRow[c] !== '█') {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const roll = Math.random();
|
|
98
|
+
if (roll < 0.4) {
|
|
99
|
+
continue; // bare patches keep the top edge uneven and organic
|
|
100
|
+
}
|
|
101
|
+
if (roll < 0.82) {
|
|
102
|
+
base[c] = pick(GRASS);
|
|
103
|
+
if (Math.random() < 0.25) {
|
|
104
|
+
tall[c] = pick(GRASS);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
base[c] = pick(STEMS);
|
|
109
|
+
tall[c] = pick(HEADS); // a flower on a stem
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return [tall.map(colorGrowth).join(''), base.map(colorGrowth).join('')];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A coloured "pill" chip for a locale code. We deliberately avoid flag emoji:
|
|
116
|
+
* Windows ships no country-flag glyphs, so 🇵🇱 renders as bare "PL" there. A
|
|
117
|
+
* coloured background pill renders identically on every platform.
|
|
118
|
+
*/
|
|
119
|
+
function chip(label, t) {
|
|
120
|
+
const [r, g, b] = gradientRgb(t);
|
|
121
|
+
const bg = `\x1b[48;2;${r};${g};${b}m`;
|
|
122
|
+
const fg = '\x1b[38;2;17;20;28m';
|
|
123
|
+
return `${bg}${fg}${BOLD} ${label} ${RESET}`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Print a large, artistic banner: the word "linguo" in a 2×-wide gradient block
|
|
127
|
+
* font, with grass and flowers procedurally sprouting from the letters' top edge
|
|
128
|
+
* (Terraria-style — different every run), a tagline, and a chip per locale.
|
|
129
|
+
*/
|
|
130
|
+
function printBanner(locales) {
|
|
131
|
+
// Render each glyph row, doubling every cell horizontally for extra heft.
|
|
132
|
+
const rawRows = Array.from({ length: GLYPH_ROWS }, (_unused, r) => [...'linguo']
|
|
133
|
+
.map((ch) => [...(GLYPHS[ch]?.[r] ?? '')].map((cell) => cell + cell).join(''))
|
|
134
|
+
.join(' '));
|
|
135
|
+
const width = Math.max(...rawRows.map((line) => line.length));
|
|
136
|
+
const art = rawRows.map((line) => paintGradient(line));
|
|
137
|
+
const [tallGrowth, baseGrowth] = growGarden(rawRows[0] ?? '', width);
|
|
138
|
+
const chips = locales.length > 0
|
|
139
|
+
? locales.map((l, i) => chip(l, locales.length > 1 ? i / (locales.length - 1) : 0)).join(' ')
|
|
140
|
+
: chip('i18n', 0);
|
|
141
|
+
const lines = [
|
|
142
|
+
'',
|
|
143
|
+
` ${tallGrowth}`,
|
|
144
|
+
` ${baseGrowth}`,
|
|
145
|
+
...art.map((line) => ` ${line}`),
|
|
146
|
+
'',
|
|
147
|
+
` ${rgb(147, 197, 253, '✦ Angular i18n')} ${DIM}— a modern translation toolkit${RESET}`,
|
|
148
|
+
` ${DIM}✺ extract ✺ translate ✺ compile${RESET}`,
|
|
149
|
+
` 🌍 ${chips}`,
|
|
150
|
+
'',
|
|
151
|
+
];
|
|
152
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
153
|
+
}
|
|
154
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
155
|
+
function formatBytes(bytes) {
|
|
156
|
+
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} kB`;
|
|
157
|
+
}
|
|
158
|
+
function formatStats(stats) {
|
|
159
|
+
const width = Math.max(...stats.locales.map((l) => l.locale.length));
|
|
160
|
+
return stats.locales
|
|
161
|
+
.map((l) => `${l.locale.padEnd(width)} ${String(l.total).padStart(3)} total ` +
|
|
162
|
+
`+${l.added} new -${l.removed} ${l.missing} missing`)
|
|
163
|
+
.join('\n');
|
|
164
|
+
}
|
|
165
|
+
// (merge + untranslated helpers live in ./lib/apply, unit-tested)
|
|
166
|
+
function countMissing(entries) {
|
|
167
|
+
return entries.filter((e) => (0, apply_1.isUntranslated)(e.msgstr)).length;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The untranslated-entry count for a locale's catalog, or `undefined` when no
|
|
171
|
+
* catalog exists yet. Used to flag languages in the translate picker.
|
|
172
|
+
*/
|
|
173
|
+
function untranslatedCount(baseDir, config, locale) {
|
|
174
|
+
const poPath = (0, node_path_1.resolve)(baseDir, config.catalogs, `${locale}.po`);
|
|
175
|
+
if (!(0, node_fs_1.existsSync)(poPath)) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
return countMissing((0, po_1.parsePo)((0, node_fs_1.readFileSync)(poPath, 'utf8')));
|
|
179
|
+
}
|
|
180
|
+
/** A select hint describing how many entries a locale still needs translated. */
|
|
181
|
+
function localeHint(missing) {
|
|
182
|
+
if (missing === undefined) {
|
|
183
|
+
return 'no catalog yet — extract first';
|
|
184
|
+
}
|
|
185
|
+
if (missing === 0) {
|
|
186
|
+
return 'fully translated';
|
|
187
|
+
}
|
|
188
|
+
return `${RED}${missing} untranslated${RESET}`;
|
|
189
|
+
}
|
|
190
|
+
// ── Actions ──────────────────────────────────────────────────────────────────
|
|
191
|
+
function runExtract(p, config, baseDir) {
|
|
192
|
+
const s = p.spinner();
|
|
193
|
+
s.start('Scanning source for messages');
|
|
194
|
+
const stats = (0, runner_1.extractToCatalogs)({
|
|
195
|
+
srcDir: (0, node_path_1.resolve)(baseDir, config.src),
|
|
196
|
+
outDir: (0, node_path_1.resolve)(baseDir, config.catalogs),
|
|
197
|
+
locales: config.locales,
|
|
198
|
+
sourceLocale: config.sourceLocale,
|
|
199
|
+
cwd: config.referenceBase === 'workspace' ? process.cwd() : baseDir,
|
|
200
|
+
});
|
|
201
|
+
s.stop(`Found ${stats.messages} message(s) in ${stats.files} file(s)`);
|
|
202
|
+
p.note(formatStats(stats), 'Catalogs');
|
|
203
|
+
}
|
|
204
|
+
function runCompile(p, config, baseDir) {
|
|
205
|
+
const s = p.spinner();
|
|
206
|
+
s.start('Compiling catalogs to runtime JSON');
|
|
207
|
+
(0, runner_1.compileCatalogs)({
|
|
208
|
+
poDir: (0, node_path_1.resolve)(baseDir, config.catalogs),
|
|
209
|
+
outDir: (0, node_path_1.resolve)(baseDir, config.output),
|
|
210
|
+
});
|
|
211
|
+
const n = config.locales.length;
|
|
212
|
+
s.stop(`Compiled ${n} dictionar${n === 1 ? 'y' : 'ies'} → ${config.output}`);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Translate a catalog automatically by calling the consumer's configured
|
|
216
|
+
* `translator` module, then compile. The caller has already established that the
|
|
217
|
+
* catalog has untranslated entries and that `config.translator` is set.
|
|
218
|
+
*/
|
|
219
|
+
async function runAutoTranslate(p, config, baseDir, locale, poPath, poText, count) {
|
|
220
|
+
if (config.translator === undefined) {
|
|
221
|
+
return; // guarded by the caller; keeps the type narrow
|
|
222
|
+
}
|
|
223
|
+
const label = (0, prompt_1.localeLabel)(locale);
|
|
224
|
+
const s = p.spinner();
|
|
225
|
+
s.start(`Translating ${count} entr${count === 1 ? 'y' : 'ies'} with your translator`);
|
|
226
|
+
try {
|
|
227
|
+
const translate = await (0, translator_1.loadTranslator)((0, node_path_1.resolve)(baseDir, config.translator));
|
|
228
|
+
const outcome = await (0, translator_1.autoTranslateCatalog)({
|
|
229
|
+
translate,
|
|
230
|
+
poText,
|
|
231
|
+
targetLocale: locale,
|
|
232
|
+
targetLabel: label,
|
|
233
|
+
sourceLocale: config.sourceLocale,
|
|
234
|
+
});
|
|
235
|
+
if (outcome.applied === 0) {
|
|
236
|
+
s.stop('No translations applied');
|
|
237
|
+
p.log.warn('The translator returned nothing usable — the catalog is unchanged.');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
(0, node_fs_1.writeFileSync)(poPath, outcome.po, 'utf8');
|
|
241
|
+
s.stop(`Applied ${outcome.applied} translation(s)`);
|
|
242
|
+
runCompile(p, config, baseDir);
|
|
243
|
+
if (outcome.remaining === 0) {
|
|
244
|
+
p.log.success(`${label} is fully translated.`);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
p.log.success(`${label} — ${outcome.remaining} still missing.`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
s.stop('Translation failed');
|
|
252
|
+
p.log.error(error instanceof Error ? error.message : String(error));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async function runTranslate(p, config, baseDir) {
|
|
256
|
+
const locale = await p.select({
|
|
257
|
+
message: 'Which language do you want to translate?',
|
|
258
|
+
options: config.locales.map((l) => ({
|
|
259
|
+
value: l,
|
|
260
|
+
label: (0, prompt_1.localeLabel)(l),
|
|
261
|
+
hint: localeHint(untranslatedCount(baseDir, config, l)),
|
|
262
|
+
})),
|
|
263
|
+
});
|
|
264
|
+
if (p.isCancel(locale)) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const poPath = (0, node_path_1.resolve)(baseDir, config.catalogs, `${locale}.po`);
|
|
268
|
+
if (!(0, node_fs_1.existsSync)(poPath)) {
|
|
269
|
+
p.log.error(`No catalog at ${poPath}. Run "Extract messages" first.`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const poText = (0, node_fs_1.readFileSync)(poPath, 'utf8');
|
|
273
|
+
const entries = (0, po_1.parsePo)(poText);
|
|
274
|
+
const untranslated = entries.filter((e) => (0, apply_1.isUntranslated)(e.msgstr));
|
|
275
|
+
if (untranslated.length === 0) {
|
|
276
|
+
p.log.success(`${(0, prompt_1.localeLabel)(locale)} is already fully translated.`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// When a translator module is configured, let the user pick automatic (call
|
|
280
|
+
// it directly) over the manual copy-prompt/paste-reply round-trip.
|
|
281
|
+
if (config.translator !== undefined) {
|
|
282
|
+
const method = await p.select({
|
|
283
|
+
message: 'How do you want to translate?',
|
|
284
|
+
options: [
|
|
285
|
+
{ value: 'auto', label: 'Automatic', hint: `call your translator (${config.translator})` },
|
|
286
|
+
{ value: 'manual', label: 'Manual', hint: 'copy a prompt, paste the reply' },
|
|
287
|
+
],
|
|
288
|
+
initialValue: 'auto',
|
|
289
|
+
});
|
|
290
|
+
if (p.isCancel(method)) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (method === 'auto') {
|
|
294
|
+
await runAutoTranslate(p, config, baseDir, locale, poPath, poText, untranslated.length);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Only the untranslated entries go to the model — not the whole catalog.
|
|
299
|
+
const prompt = (0, prompt_1.buildTranslationPrompt)((0, prompt_1.localeLabel)(locale), (0, po_1.serializePo)(untranslated));
|
|
300
|
+
if ((0, clipboard_1.copyToClipboard)(prompt)) {
|
|
301
|
+
p.note(`Copied a prompt for ${untranslated.length} untranslated entr${untranslated.length === 1 ? 'y' : 'ies'} (${formatBytes(prompt.length)}).\n` +
|
|
302
|
+
`Paste it into an LLM, then copy the model's reply (the .po block) back\n` +
|
|
303
|
+
`to your clipboard.`, `${(0, prompt_1.localeLabel)(locale)} · ${untranslated.length}/${entries.length} to translate`);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const promptPath = (0, node_path_1.resolve)(baseDir, config.catalogs, `${locale}.prompt.txt`);
|
|
307
|
+
(0, node_fs_1.writeFileSync)(promptPath, prompt, 'utf8');
|
|
308
|
+
p.note(`Clipboard unavailable — wrote the prompt to ${(0, node_path_1.basename)(promptPath)}.`, 'Heads up');
|
|
309
|
+
}
|
|
310
|
+
const next = await p.confirm({
|
|
311
|
+
message: "Copied the model's reply? Read it back from the clipboard and apply it now?",
|
|
312
|
+
initialValue: true,
|
|
313
|
+
});
|
|
314
|
+
if (p.isCancel(next) || !next) {
|
|
315
|
+
p.log.info('No changes applied.');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const reply = (0, clipboard_1.readClipboard)();
|
|
319
|
+
if (reply === undefined || reply.trim() === '') {
|
|
320
|
+
p.log.error('Could not read the clipboard.');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// Fold the reply's translations into the full catalog (matched by identity).
|
|
324
|
+
const { po: mergedPo, applied } = (0, apply_1.applyTranslations)(poText, reply);
|
|
325
|
+
if (applied === 0) {
|
|
326
|
+
p.log.warn('No matching translations found in the clipboard. Nothing changed.');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
(0, node_fs_1.writeFileSync)(poPath, mergedPo, 'utf8');
|
|
330
|
+
runCompile(p, config, baseDir);
|
|
331
|
+
const remaining = countMissing((0, po_1.parsePo)(mergedPo));
|
|
332
|
+
if (remaining === 0) {
|
|
333
|
+
p.log.success(`Applied ${applied} translation(s). ${(0, prompt_1.localeLabel)(locale)} is fully translated.`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
p.log.success(`Applied ${applied} translation(s) · ${(0, prompt_1.localeLabel)(locale)} ${remaining} still missing.`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// ── Configuration ───────────────────────────────────────────────────────────
|
|
340
|
+
function parseLocales(input) {
|
|
341
|
+
return input
|
|
342
|
+
.split(',')
|
|
343
|
+
.map((s) => s.trim())
|
|
344
|
+
.filter(Boolean);
|
|
345
|
+
}
|
|
346
|
+
function writeConfigFile(path, config) {
|
|
347
|
+
// Stamp a $schema for editor tooling, preserving one the config already has.
|
|
348
|
+
const withSchema = config.$schema !== undefined ? config : { ...config, $schema: config_1.CONFIG_SCHEMA_REF };
|
|
349
|
+
(0, node_fs_1.writeFileSync)(path, (0, config_1.serializeConfig)(withSchema), 'utf8');
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Interactive form to create or edit a {@link LinguoConfig}. Pre-fills from
|
|
353
|
+
* `current` when editing. Returns `undefined` if the user cancels.
|
|
354
|
+
*/
|
|
355
|
+
async function configForm(p, current) {
|
|
356
|
+
const localesInput = await p.text({
|
|
357
|
+
message: 'Languages — comma-separated locale codes',
|
|
358
|
+
placeholder: 'en, pl, de',
|
|
359
|
+
initialValue: current?.locales.join(', ') ?? '',
|
|
360
|
+
validate: (value) => parseLocales(value ?? '').length === 0 ? 'Enter at least one locale code' : undefined,
|
|
361
|
+
});
|
|
362
|
+
if (p.isCancel(localesInput))
|
|
363
|
+
return undefined;
|
|
364
|
+
const locales = parseLocales(localesInput);
|
|
365
|
+
const defaultSource = locales[0] ?? config_1.DEFAULT_CONFIG.sourceLocale;
|
|
366
|
+
const sourceLocale = await p.select({
|
|
367
|
+
message: 'Source language (the one your source strings are written in)',
|
|
368
|
+
options: locales.map((l) => ({ value: l, label: (0, prompt_1.localeLabel)(l) })),
|
|
369
|
+
initialValue: current?.sourceLocale !== undefined && locales.includes(current.sourceLocale)
|
|
370
|
+
? current.sourceLocale
|
|
371
|
+
: defaultSource,
|
|
372
|
+
});
|
|
373
|
+
if (p.isCancel(sourceLocale))
|
|
374
|
+
return undefined;
|
|
375
|
+
const src = await p.text({
|
|
376
|
+
message: 'Source directory to scan for messages',
|
|
377
|
+
placeholder: config_1.DEFAULT_CONFIG.src,
|
|
378
|
+
initialValue: current?.src ?? config_1.DEFAULT_CONFIG.src,
|
|
379
|
+
});
|
|
380
|
+
if (p.isCancel(src))
|
|
381
|
+
return undefined;
|
|
382
|
+
const catalogs = await p.text({
|
|
383
|
+
message: 'Directory for the .po translation catalogs',
|
|
384
|
+
placeholder: config_1.DEFAULT_CONFIG.catalogs,
|
|
385
|
+
initialValue: current?.catalogs ?? config_1.DEFAULT_CONFIG.catalogs,
|
|
386
|
+
});
|
|
387
|
+
if (p.isCancel(catalogs))
|
|
388
|
+
return undefined;
|
|
389
|
+
const output = await p.text({
|
|
390
|
+
message: 'Directory for the compiled runtime JSON',
|
|
391
|
+
placeholder: config_1.DEFAULT_CONFIG.output,
|
|
392
|
+
initialValue: current?.output ?? config_1.DEFAULT_CONFIG.output,
|
|
393
|
+
});
|
|
394
|
+
if (p.isCancel(output))
|
|
395
|
+
return undefined;
|
|
396
|
+
const referenceBase = await p.select({
|
|
397
|
+
message: 'How should .po source references (#:) be written?',
|
|
398
|
+
options: [
|
|
399
|
+
{ value: 'config', label: 'config', hint: 'relative to the config file — portable' },
|
|
400
|
+
{
|
|
401
|
+
value: 'workspace',
|
|
402
|
+
label: 'workspace',
|
|
403
|
+
hint: 'relative to the cwd — clickable in the terminal',
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
initialValue: current?.referenceBase ?? config_1.DEFAULT_CONFIG.referenceBase,
|
|
407
|
+
});
|
|
408
|
+
if (p.isCancel(referenceBase))
|
|
409
|
+
return undefined;
|
|
410
|
+
const translator = await p.text({
|
|
411
|
+
message: 'Translator module for automatic AI translation (optional, blank to skip)',
|
|
412
|
+
placeholder: './linguo.translator.mjs',
|
|
413
|
+
initialValue: current?.translator ?? '',
|
|
414
|
+
});
|
|
415
|
+
if (p.isCancel(translator))
|
|
416
|
+
return undefined;
|
|
417
|
+
const translatorPath = translator.trim();
|
|
418
|
+
return {
|
|
419
|
+
locales,
|
|
420
|
+
sourceLocale,
|
|
421
|
+
src: src.trim() || config_1.DEFAULT_CONFIG.src,
|
|
422
|
+
catalogs: catalogs.trim() || config_1.DEFAULT_CONFIG.catalogs,
|
|
423
|
+
output: output.trim() || config_1.DEFAULT_CONFIG.output,
|
|
424
|
+
referenceBase,
|
|
425
|
+
...(translatorPath ? { translator: translatorPath } : {}),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/** Edit a single directory value; returns `undefined` if cancelled, the kept
|
|
429
|
+
* current value if blanked, otherwise the trimmed new value. */
|
|
430
|
+
async function editText(p, message, current) {
|
|
431
|
+
const value = await p.text({ message, placeholder: current, initialValue: current });
|
|
432
|
+
if (p.isCancel(value))
|
|
433
|
+
return undefined;
|
|
434
|
+
return value.trim() || current;
|
|
435
|
+
}
|
|
436
|
+
/** A one-line description of every config field, shown when it is selected. */
|
|
437
|
+
const FIELD_HELP = {
|
|
438
|
+
locales: 'The languages you ship. Comma-separated BCP-47 codes, e.g. en, pl, de.',
|
|
439
|
+
sourceLocale: 'The language your source strings are authored in. Its catalog is never flagged as missing.',
|
|
440
|
+
src: 'Directory scanned for translatable strings (the t pipe/directive and t() calls).',
|
|
441
|
+
catalogs: 'Where the editable <locale>.po catalogs are kept.',
|
|
442
|
+
output: 'Where compiled runtime <locale>.json dictionaries are written for the app to load.',
|
|
443
|
+
referenceBase: 'How #: source references are written: relative to the config file (portable) or to the cwd (clickable).',
|
|
444
|
+
translator: 'Path to a module exporting a translate() function for automatic AI translation. Blank uses the clipboard flow.',
|
|
445
|
+
};
|
|
446
|
+
/** A copy without the optional translator key (exactOptionalPropertyTypes-safe). */
|
|
447
|
+
function withoutTranslator(config) {
|
|
448
|
+
return {
|
|
449
|
+
...(config.$schema !== undefined ? { $schema: config.$schema } : {}),
|
|
450
|
+
locales: config.locales,
|
|
451
|
+
sourceLocale: config.sourceLocale,
|
|
452
|
+
src: config.src,
|
|
453
|
+
catalogs: config.catalogs,
|
|
454
|
+
output: config.output,
|
|
455
|
+
referenceBase: config.referenceBase,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* BIOS-style settings editor: lists every value with its current setting and
|
|
460
|
+
* lets you change one at a time, then Save or Discard. Used when editing an
|
|
461
|
+
* existing config (first-time creation uses the guided {@link configForm}).
|
|
462
|
+
* Returns the updated config, or `undefined` if discarded.
|
|
463
|
+
*/
|
|
464
|
+
async function editConfigMenu(p, current) {
|
|
465
|
+
let draft = { ...current };
|
|
466
|
+
for (;;) {
|
|
467
|
+
const field = await p.select({
|
|
468
|
+
message: 'Configuration — choose a value to change',
|
|
469
|
+
options: [
|
|
470
|
+
{ value: 'locales', label: 'Languages', hint: draft.locales.join(', ') },
|
|
471
|
+
{ value: 'sourceLocale', label: 'Source language', hint: draft.sourceLocale },
|
|
472
|
+
{ value: 'src', label: 'Source directory', hint: draft.src },
|
|
473
|
+
{ value: 'catalogs', label: 'Catalogs directory', hint: draft.catalogs },
|
|
474
|
+
{ value: 'output', label: 'Output directory', hint: draft.output },
|
|
475
|
+
{ value: 'referenceBase', label: 'Reference base', hint: draft.referenceBase },
|
|
476
|
+
{
|
|
477
|
+
value: 'translator',
|
|
478
|
+
label: 'AI translator',
|
|
479
|
+
hint: draft.translator ?? '(none — clipboard)',
|
|
480
|
+
},
|
|
481
|
+
{ value: 'save', label: '✓ Save & exit' },
|
|
482
|
+
{ value: 'cancel', label: '✗ Discard changes' },
|
|
483
|
+
],
|
|
484
|
+
});
|
|
485
|
+
if (p.isCancel(field) || field === 'cancel')
|
|
486
|
+
return undefined;
|
|
487
|
+
if (field === 'save')
|
|
488
|
+
return draft;
|
|
489
|
+
// Every setting gets a description, shown as you drill into it.
|
|
490
|
+
const help = FIELD_HELP[field];
|
|
491
|
+
if (help !== undefined) {
|
|
492
|
+
p.note(help, 'About this setting');
|
|
493
|
+
}
|
|
494
|
+
if (field === 'locales') {
|
|
495
|
+
const input = await p.text({
|
|
496
|
+
message: 'Languages — comma-separated locale codes',
|
|
497
|
+
placeholder: 'en, pl, de',
|
|
498
|
+
initialValue: draft.locales.join(', '),
|
|
499
|
+
validate: (v) => parseLocales(v ?? '').length === 0 ? 'Enter at least one locale code' : undefined,
|
|
500
|
+
});
|
|
501
|
+
if (p.isCancel(input))
|
|
502
|
+
continue;
|
|
503
|
+
const locales = parseLocales(input);
|
|
504
|
+
// Keep the source language valid against the new list.
|
|
505
|
+
const sourceLocale = locales.includes(draft.sourceLocale)
|
|
506
|
+
? draft.sourceLocale
|
|
507
|
+
: (locales[0] ?? draft.sourceLocale);
|
|
508
|
+
draft = { ...draft, locales, sourceLocale };
|
|
509
|
+
}
|
|
510
|
+
else if (field === 'sourceLocale') {
|
|
511
|
+
const value = await p.select({
|
|
512
|
+
message: 'Source language',
|
|
513
|
+
options: draft.locales.map((l) => ({ value: l, label: (0, prompt_1.localeLabel)(l) })),
|
|
514
|
+
initialValue: draft.sourceLocale,
|
|
515
|
+
});
|
|
516
|
+
if (!p.isCancel(value))
|
|
517
|
+
draft = { ...draft, sourceLocale: value };
|
|
518
|
+
}
|
|
519
|
+
else if (field === 'referenceBase') {
|
|
520
|
+
const value = await p.select({
|
|
521
|
+
message: 'Reference base for .po #: lines',
|
|
522
|
+
options: [
|
|
523
|
+
{ value: 'config', label: 'config', hint: 'relative to the config file' },
|
|
524
|
+
{ value: 'workspace', label: 'workspace', hint: 'relative to the cwd' },
|
|
525
|
+
],
|
|
526
|
+
initialValue: draft.referenceBase,
|
|
527
|
+
});
|
|
528
|
+
if (!p.isCancel(value))
|
|
529
|
+
draft = { ...draft, referenceBase: value };
|
|
530
|
+
}
|
|
531
|
+
else if (field === 'src') {
|
|
532
|
+
const value = await editText(p, 'Source directory to scan', draft.src);
|
|
533
|
+
if (value !== undefined)
|
|
534
|
+
draft = { ...draft, src: value };
|
|
535
|
+
}
|
|
536
|
+
else if (field === 'catalogs') {
|
|
537
|
+
const value = await editText(p, 'Directory for the .po catalogs', draft.catalogs);
|
|
538
|
+
if (value !== undefined)
|
|
539
|
+
draft = { ...draft, catalogs: value };
|
|
540
|
+
}
|
|
541
|
+
else if (field === 'translator') {
|
|
542
|
+
const value = await p.text({
|
|
543
|
+
message: 'Translator module path (blank to disable automatic translation)',
|
|
544
|
+
placeholder: './linguo.translator.mjs',
|
|
545
|
+
initialValue: draft.translator ?? '',
|
|
546
|
+
});
|
|
547
|
+
if (!p.isCancel(value)) {
|
|
548
|
+
const trimmed = value.trim();
|
|
549
|
+
draft = trimmed ? { ...draft, translator: trimmed } : withoutTranslator(draft);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
const value = await editText(p, 'Directory for the compiled JSON', draft.output);
|
|
554
|
+
if (value !== undefined)
|
|
555
|
+
draft = { ...draft, output: value };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Run the standalone config wizard (the `init` command on a TTY): edit an
|
|
561
|
+
* existing `linguo.config.json`, or create one in the current directory.
|
|
562
|
+
*/
|
|
563
|
+
async function runInit() {
|
|
564
|
+
const p = await import('@clack/prompts');
|
|
565
|
+
p.intro('ng-linguo · configuration');
|
|
566
|
+
const existing = (0, config_1.findConfigFile)(process.cwd());
|
|
567
|
+
let current;
|
|
568
|
+
if (existing !== undefined) {
|
|
569
|
+
try {
|
|
570
|
+
current = (0, config_1.parseConfig)((0, node_fs_1.readFileSync)(existing, 'utf8'));
|
|
571
|
+
p.log.info(`Editing ${existing}`);
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
p.log.warn(`Existing config is invalid (${String(error)}); starting fresh.`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Existing config → BIOS-style settings editor; fresh → guided wizard.
|
|
578
|
+
const config = current !== undefined ? await editConfigMenu(p, current) : await configForm(p);
|
|
579
|
+
if (config === undefined) {
|
|
580
|
+
p.cancel('Cancelled — no changes written.');
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const path = existing ?? (0, node_path_1.resolve)(process.cwd(), 'linguo.config.json');
|
|
584
|
+
writeConfigFile(path, config);
|
|
585
|
+
p.outro(`${existing !== undefined ? 'Updated' : 'Created'} ${path}`);
|
|
586
|
+
}
|
|
587
|
+
// ── Menu ───────────────────────────────────────────────────────────────────
|
|
588
|
+
/**
|
|
589
|
+
* Run the guided interactive menu (clack). Invoked by the CLI when it is given
|
|
590
|
+
* no command and is attached to a TTY. Discovers `linguo.config.json`, then
|
|
591
|
+
* loops: extract, compile, translate via an LLM, run the full pipeline, or exit.
|
|
592
|
+
* All actions are also available non-interactively as commands.
|
|
593
|
+
*/
|
|
594
|
+
async function runInteractive() {
|
|
595
|
+
const p = await import('@clack/prompts');
|
|
596
|
+
let configPath = (0, config_1.findConfigFile)(process.cwd());
|
|
597
|
+
let config;
|
|
598
|
+
let configError;
|
|
599
|
+
if (configPath !== undefined) {
|
|
600
|
+
try {
|
|
601
|
+
config = (0, config_1.parseConfig)((0, node_fs_1.readFileSync)(configPath, 'utf8'));
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
configError = error instanceof Error ? error.message : String(error);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
printBanner(config?.locales ?? []);
|
|
608
|
+
p.intro('ng-linguo');
|
|
609
|
+
if (configError !== undefined) {
|
|
610
|
+
p.cancel(configError);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
// No config yet — offer to create one rather than dead-ending.
|
|
614
|
+
if (config === undefined) {
|
|
615
|
+
const create = await p.confirm({
|
|
616
|
+
message: 'No linguo.config.json found here. Create one now?',
|
|
617
|
+
initialValue: true,
|
|
618
|
+
});
|
|
619
|
+
if (p.isCancel(create) || !create) {
|
|
620
|
+
p.outro('Nothing to do — add a linguo.config.json to get started.');
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const created = await configForm(p);
|
|
624
|
+
if (created === undefined) {
|
|
625
|
+
p.cancel('Cancelled.');
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
configPath = (0, node_path_1.resolve)(process.cwd(), 'linguo.config.json');
|
|
629
|
+
writeConfigFile(configPath, created);
|
|
630
|
+
config = created;
|
|
631
|
+
p.log.success(`Created ${configPath}`);
|
|
632
|
+
}
|
|
633
|
+
if (configPath === undefined) {
|
|
634
|
+
p.cancel('Could not load configuration.'); // unreachable; satisfies the type narrowing
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const baseDir = (0, node_path_1.dirname)(configPath);
|
|
638
|
+
p.log.info(`${configPath}\nLocales: ${config.locales.join(', ')}`);
|
|
639
|
+
for (;;) {
|
|
640
|
+
const action = await p.select({
|
|
641
|
+
message: 'What do you want to do?',
|
|
642
|
+
options: [
|
|
643
|
+
{ value: 'extract', label: 'Extract messages', hint: 'scan source → update .po catalogs' },
|
|
644
|
+
{ value: 'compile', label: 'Compile catalogs', hint: '.po → runtime .json' },
|
|
645
|
+
{
|
|
646
|
+
value: 'translate',
|
|
647
|
+
label: 'Translate with an LLM',
|
|
648
|
+
hint: config.translator !== undefined ? 'automatic or clipboard' : 'copy prompt, paste reply',
|
|
649
|
+
},
|
|
650
|
+
{ value: 'all', label: 'Run the full pipeline', hint: 'extract, then compile' },
|
|
651
|
+
{ value: 'config', label: 'Edit configuration', hint: 'languages, paths, references' },
|
|
652
|
+
{ value: 'exit', label: 'Exit' },
|
|
653
|
+
],
|
|
654
|
+
});
|
|
655
|
+
if (p.isCancel(action) || action === 'exit') {
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
if (action === 'config') {
|
|
659
|
+
const updated = await editConfigMenu(p, config);
|
|
660
|
+
if (updated !== undefined) {
|
|
661
|
+
writeConfigFile(configPath, updated);
|
|
662
|
+
config = updated;
|
|
663
|
+
p.log.success(`Saved ${configPath}`);
|
|
664
|
+
}
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (action === 'extract' || action === 'all') {
|
|
668
|
+
runExtract(p, config, baseDir);
|
|
669
|
+
}
|
|
670
|
+
if (action === 'compile' || action === 'all') {
|
|
671
|
+
runCompile(p, config, baseDir);
|
|
672
|
+
}
|
|
673
|
+
if (action === 'translate') {
|
|
674
|
+
await runTranslate(p, config, baseDir);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
p.outro('Goodbye 👋');
|
|
678
|
+
}
|
|
679
|
+
//# sourceMappingURL=interactive.js.map
|