@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.
Files changed (46) hide show
  1. package/README.md +177 -0
  2. package/linguo.config.schema.json +53 -0
  3. package/package.json +38 -0
  4. package/src/cli.d.ts +2 -0
  5. package/src/cli.js +287 -0
  6. package/src/cli.js.map +1 -0
  7. package/src/index.d.ts +10 -0
  8. package/src/index.js +18 -0
  9. package/src/index.js.map +1 -0
  10. package/src/interactive.d.ts +12 -0
  11. package/src/interactive.js +679 -0
  12. package/src/interactive.js.map +1 -0
  13. package/src/lib/apply.d.ts +20 -0
  14. package/src/lib/apply.js +43 -0
  15. package/src/lib/apply.js.map +1 -0
  16. package/src/lib/clipboard.d.ts +17 -0
  17. package/src/lib/clipboard.js +96 -0
  18. package/src/lib/clipboard.js.map +1 -0
  19. package/src/lib/compile.d.ts +12 -0
  20. package/src/lib/compile.js +29 -0
  21. package/src/lib/compile.js.map +1 -0
  22. package/src/lib/config.d.ts +104 -0
  23. package/src/lib/config.js +185 -0
  24. package/src/lib/config.js.map +1 -0
  25. package/src/lib/merge.d.ts +13 -0
  26. package/src/lib/merge.js +34 -0
  27. package/src/lib/merge.js.map +1 -0
  28. package/src/lib/normalize.d.ts +15 -0
  29. package/src/lib/normalize.js +21 -0
  30. package/src/lib/normalize.js.map +1 -0
  31. package/src/lib/po.d.ts +25 -0
  32. package/src/lib/po.js +110 -0
  33. package/src/lib/po.js.map +1 -0
  34. package/src/lib/prompt.d.ts +33 -0
  35. package/src/lib/prompt.js +80 -0
  36. package/src/lib/prompt.js.map +1 -0
  37. package/src/lib/runner.d.ts +62 -0
  38. package/src/lib/runner.js +102 -0
  39. package/src/lib/runner.js.map +1 -0
  40. package/src/lib/scan.d.ts +31 -0
  41. package/src/lib/scan.js +183 -0
  42. package/src/lib/scan.js.map +1 -0
  43. package/src/lib/translation-prompt.txt +214 -0
  44. package/src/lib/translator.d.ts +83 -0
  45. package/src/lib/translator.js +91 -0
  46. 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