gulp-mu-gulp-api 0.3.9 → 0.3.11

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 (3) hide show
  1. package/README.md +24 -0
  2. package/package.json +1 -1
  3. package/src/index.mjs +843 -770
package/src/index.mjs CHANGED
@@ -1,770 +1,843 @@
1
- // ===========================================
2
- // gulp-mu-gulp-api — µGulp task API
3
- // © 2026 Meinolf Amekudzi
4
- // (published under MIT license)
5
- // ===========================================
6
-
7
- /**
8
- * Public API for gulp tasks running under the µGulp orchestrator.
9
- *
10
- * Under µGulp every task runs inside a forked worker process with an IPC
11
- * channel to the engine; this module talks to that channel directly, so it
12
- * has zero coupling to µGulp internals and works from any npm package in
13
- * the pipeline. Without µGulp (plain "gulp" CLI run) every function
14
- * degrades gracefully: progress renders on the terminal, inputs fall back
15
- * to readline prompts on a TTY or to the declared defaults otherwise.
16
- *
17
- * @example
18
- * import { ReportProgress, RequestTextInput, RequestColorInput } from 'gulp-mu-gulp-api';
19
- *
20
- * export async function BUILD_THEME() {
21
- * let accent = await RequestColorInput({ label: 'Accent color', default: '#00e5ff' });
22
- * for (let step = 0; step < 10; step++) {
23
- * // ... work ...
24
- * ReportProgress((step + 1) / 10, 'compiling theme');
25
- * }
26
- * }
27
- *
28
- * @module gulp-mu-gulp-api
29
- */
30
-
31
- // Localized console output (i18x). Re-exported so tasks import everything
32
- // from one place: `import { Log, Warn, ReportProgress } from 'gulp-mu-gulp-api'`.
33
- export { Log, Warn, LogError, Translate, FormatValue, GetLid, SetLid, GetTimeZone, DateInTimeZone, TimeZoneOffset, InstallStringExtensions, InstallFormatPrototypes, InstallHyphenPrototypes } from './i18x.mjs';
34
- export { WordHyphenation, Hyphenation, LoadHyphenData } from './i18x-hyphen.mjs';
35
- import * as I18x from './i18x.mjs';
36
-
37
- let uiRequestCounter = 0;
38
- let ipcListenerAttached = false;
39
- let lastLoggedPercent = -1;
40
- const pendingUiRequests = new Map();
41
-
42
- /**
43
- * @returns {boolean} true when the task runs inside a µGulp worker with an
44
- * attached dashboard (progress and inputs are rendered in the webview)
45
- */
46
- export function IsMicroGulp() {
47
- // The worker pool sets MICROGULP=1 for every forked task process. Relying
48
- // only on process.send was too strict — some hosts still stream stdout
49
- // correctly while structured IPC must be attempted separately.
50
- return process.env.MICROGULP === '1';
51
- }
52
-
53
- /** Brand-aligned alias for {@link IsMicroGulp}. */
54
- export const IsµGulp = IsMicroGulp;
55
-
56
- /*
57
- * Task metadata (µDisplayName, µDescription, µTooltip, µGroup) is localized
58
- * the same way as any other i18x phrase: with the context tag written *into*
59
- * the source phrase and a String.prototype.i18xRegister() marker so the
60
- * i18xe-sync tooling extracts it and the dashboard can translate it later.
61
- *
62
- * MAKE_BUILDS.µDisplayName =
63
- * 'Make Build V<version/><context="µDisplayName"/>'.i18xRegister();
64
- *
65
- * i18xRegister() returns the phrase unchanged (the i18x key), so the classic
66
- * gulp CLI keeps seeing a plain string while the dashboard translates it via
67
- * the merged project dictionary. The prototype is installed by
68
- * InstallStringExtensions() (auto-run on import of this module).
69
- */
70
-
71
- // -------------------------------------------------
72
- // progress
73
- // -------------------------------------------------
74
-
75
- /**
76
- * Reports task progress to the µGulp dashboard (determinate neon progress
77
- * bar in the task's sector). CLI fallback: an updating line on a TTY,
78
- * 10%-step log lines otherwise.
79
- *
80
- * @param {number} _value progress in the range 0..1
81
- * @param {string} [_label] short status label shown next to the bar
82
- */
83
- export function ReportProgress(_value, _label) {
84
- let value = Math.max(0, Math.min(1, Number(_value) || 0));
85
- if (IsMicroGulp()) {
86
- process.send({ type: 'progress', value, label: _label });
87
- return;
88
- }
89
- let percent = Math.round(value * 100);
90
- if (process.stdout.isTTY) {
91
- process.stdout.write('\r' + (_label ?? 'progress') + ' ' + percent + '%' + (value >= 1 ? '\n' : ''));
92
- } else if (percent >= 100 || percent - lastLoggedPercent >= 10) {
93
- lastLoggedPercent = percent >= 100 ? -1 : percent;
94
- console.log((_label ?? 'progress') + ' ' + percent + '%');
95
- }
96
- }
97
-
98
- /**
99
- * Convenience wrapper that carries a fixed label.
100
- *
101
- * @param {string} [_label]
102
- * @returns {{Update: function(number, string=): void, Done: function(): void}}
103
- */
104
- export function CreateProgress(_label) {
105
- return {
106
- Update(_value, _stepLabel) {
107
- ReportProgress(_value, _stepLabel ?? _label);
108
- },
109
- Done() {
110
- ReportProgress(1, _label);
111
- },
112
- };
113
- }
114
-
115
- function _SendStructuredLog(_format, _payload) {
116
- if (!IsMicroGulp()) return false;
117
- if (typeof process.send !== 'function') return false;
118
- process.send({ type: 'structured-log', format: _format, payload: _payload });
119
- return true;
120
- }
121
-
122
- /**
123
- * Sends a table to the dashboard log pane. The payload is JSON:
124
- * `{ columns: string[] | {id, label}[], rows: (string|number)[][] | object[], hyphenate?: boolean }`.
125
- * CLI fallback: a simple ASCII table on stdout.
126
- *
127
- * @param {object} _spec table descriptor
128
- */
129
- export function LogTable(_spec) {
130
- let payload = _NormalizeTableSpec(_spec);
131
- if (_SendStructuredLog('table', payload)) return;
132
- console.log(_AsciiTable(payload));
133
- }
134
-
135
- /**
136
- * Sends a tree structure to the dashboard log pane. The payload is JSON:
137
- * `{ label?: string, children?: object[], roots?: object[] }` where each node
138
- * has `{ label, children? }`. CLI fallback: indented text lines.
139
- *
140
- * @param {object} _spec tree descriptor (single root or `{ roots: [...] }`)
141
- */
142
- export function LogTree(_spec) {
143
- let payload = _NormalizeTreeSpec(_spec);
144
- if (_SendStructuredLog('tree', payload)) return;
145
- for (let line of _AsciiTreeLines(payload)) console.log(line);
146
- }
147
-
148
- /**
149
- * Sends an image gallery to the dashboard log pane. Each item needs a `src`
150
- * (data URI or https URL) and a `label`; optional `caption` and per-item `title`.
151
- * CLI fallback: lists labels and src lengths on stdout.
152
- *
153
- * @param {object} _spec `{ title?, columns?, items: { label, src, caption?, title? }[] }`
154
- */
155
- export function LogGallery(_spec) {
156
- let payload = _NormalizeGallerySpec(_spec);
157
- if (_SendStructuredLog('gallery', payload)) return;
158
- console.log(_AsciiGallery(payload));
159
- }
160
-
161
- /**
162
- * Renders µCSS BuildSkin `report.debug.previews` as a thumbnail gallery.
163
- * Items without `thumbDataUri` are omitted (paths-only assets stay in the text log).
164
- *
165
- * @param {object[]} _previews preview entries from `report.debug.previews`
166
- * @param {object} [_options] `{ title?, columns? }`
167
- */
168
- export function LogAssetPreviews(_previews, _options = {}) {
169
- let items = (_previews ?? [])
170
- .filter((_entry) => _entry?.thumbDataUri)
171
- .map((_entry) => ({
172
- label: String(_entry.relPath ?? _entry.path ?? ''),
173
- src: _entry.thumbDataUri,
174
- caption: [
175
- _entry.step,
176
- _entry.skipped ? 'cached' : null,
177
- _entry.width && _entry.height ? `${_entry.width}\u00d7${_entry.height}` : null,
178
- ].filter(Boolean).join(' \u00b7 '),
179
- }));
180
- if (!items.length) return;
181
- LogGallery({
182
- title: _options.title,
183
- columns: _options.columns ?? 4,
184
- items,
185
- });
186
- }
187
-
188
- /**
189
- * Convenience wrapper for a full µCSS `BuildSkin` debug block: summary table
190
- * plus thumbnail gallery when `report.debug` is present.
191
- *
192
- * @param {object} _report BuildSkin return value
193
- * @param {object} [_options] `{ title?, columns?, table?: boolean }`
194
- */
195
- export function LogBuildDebugReport(_report, _options = {}) {
196
- let debug = _report?.debug;
197
- if (!debug) return;
198
- if (_options.table !== false && debug.summary) {
199
- LogTable({
200
- columns: [{ id: 'metric', label: 'Metric' }, { id: 'value', label: 'Value' }],
201
- rows: Object.entries(debug.summary).map(([metric, value]) => ({ metric, value: String(value) })),
202
- hyphenate: false,
203
- });
204
- }
205
- LogAssetPreviews(debug.previews, {
206
- title: _options.title ?? 'Generated assets',
207
- columns: _options.columns,
208
- });
209
- }
210
-
211
- /**
212
- * Sends a highlighted callout box (info/success/warning/error) to the dashboard
213
- * log pane. The payload is JSON: `{ variant, title?, message }`.
214
- * CLI fallback: a bracketed line on stdout.
215
- *
216
- * @param {object} _spec `{ variant?: 'info'|'success'|'warning'|'error', title?, message }`
217
- */
218
- export function LogCallout(_spec) {
219
- let payload = _NormalizeCalloutSpec(_spec);
220
- if (_SendStructuredLog('callout', payload)) return;
221
- let head = payload.variant.toUpperCase() + (payload.title ? ': ' + payload.title : '');
222
- console.log('[' + head + '] ' + payload.message);
223
- }
224
-
225
- /**
226
- * Sends a key/value list (metrics, build summary, environment) to the dashboard
227
- * log pane. The payload is JSON: `{ title?, items: {key, value}[] }`.
228
- * CLI fallback: aligned `key: value` lines on stdout.
229
- *
230
- * @param {object} _spec `{ title?, items: Array<{key, value}> | Record<string, any> }`
231
- */
232
- export function LogKeyValue(_spec) {
233
- let payload = _NormalizeKeyValueSpec(_spec);
234
- if (_SendStructuredLog('key-value', payload)) return;
235
- if (payload.title) console.log(payload.title);
236
- let width = payload.items.reduce((_max, _item) => Math.max(_max, _item.key.length), 0);
237
- for (let item of payload.items) console.log(' ' + item.key.padEnd(width) + ' ' + item.value);
238
- }
239
-
240
- /**
241
- * Sends a row of status badges/chips to the dashboard log pane. The payload is
242
- * JSON: `{ title?, items: {label, variant?}[] }`.
243
- * CLI fallback: bracketed labels on one stdout line.
244
- *
245
- * @param {object} _spec `{ title?, items: Array<string|{label, variant?}> }`
246
- */
247
- export function LogBadges(_spec) {
248
- let payload = _NormalizeBadgesSpec(_spec);
249
- if (_SendStructuredLog('badges', payload)) return;
250
- let chips = payload.items.map((_item) => '[' + _item.label + ']').join(' ');
251
- console.log((payload.title ? payload.title + ' ' : '') + chips);
252
- }
253
-
254
- /**
255
- * Sends a code block (monospace, preserved whitespace) to the dashboard log
256
- * pane. The payload is JSON: `{ title?, language?, code }`.
257
- * CLI fallback: the code printed verbatim on stdout.
258
- *
259
- * @param {object} _spec `{ title?, language?, code }`
260
- */
261
- export function LogCode(_spec) {
262
- let payload = _NormalizeCodeSpec(_spec);
263
- if (_SendStructuredLog('code', payload)) return;
264
- if (payload.title) console.log(payload.title + (payload.language ? ' (' + payload.language + ')' : ''));
265
- console.log(payload.code);
266
- }
267
-
268
- /**
269
- * Sends a bar chart to the dashboard log pane, rendered as inline SVG (no
270
- * scripts Content-Security-Policy safe). The payload is JSON:
271
- * `{ title?, type?: 'bar', series: {label, value, color?}[], max?, unit? }`.
272
- * CLI fallback: an ASCII bar chart on stdout.
273
- *
274
- * @param {object} _spec `{ title?, type?, series: Array<{label, value, color?}>, max?, unit? }`
275
- */
276
- export function LogChart(_spec) {
277
- let payload = _NormalizeChartSpec(_spec);
278
- if (_SendStructuredLog('chart', payload)) return;
279
- for (let line of _AsciiChart(payload)) console.log(line);
280
- }
281
-
282
- // -------------------------------------------------
283
- // sound & speech
284
- // -------------------------------------------------
285
-
286
- /**
287
- * Plays an acoustic signal in the µGulp dashboard (WebAudio, respects the
288
- * dashboard audio settings: mute/volume). CLI fallback: terminal bell for
289
- * 'attention' and 'error' on a TTY, silent otherwise.
290
- *
291
- * @param {'success'|'error'|'attention'} [_signal]
292
- */
293
- export function PlaySignal(_signal = 'attention') {
294
- if (IsMicroGulp()) {
295
- process.send({ type: 'sound', signal: _signal });
296
- return;
297
- }
298
- if (process.stdout.isTTY && (_signal === 'attention' || _signal === 'error')) {
299
- process.stdout.write('\u0007');
300
- }
301
- }
302
-
303
- /**
304
- * Plays a named sample from the dashboard sound atlas (µAU). Sample names match
305
- * the files bundled with µGulp (`success`, `error`, `attention`, or custom
306
- * atlas entries in the consumer skin). When `loop` is true the sample repeats
307
- * until `StopSound(sample)` is called.
308
- *
309
- * CLI fallback: logs `[sound] <name>` on a TTY, silent otherwise.
310
- *
311
- * @param {string} _sample atlas sample name (e.g. `'success'`)
312
- * @param {object} [_options]
313
- * @param {boolean} [_options.loop] repeat until stopped (default `false`)
314
- */
315
- export function PlaySound(_sample, _options = {}) {
316
- let sample = String(_sample ?? '').trim();
317
- if (!sample) return;
318
- if (IsMicroGulp()) {
319
- process.send({ type: 'sound', sample, loop: !!_options.loop });
320
- return;
321
- }
322
- if (process.stdout.isTTY) {
323
- console.log(`[sound] ${sample}${_options.loop ? ' (loop)' : ''}`);
324
- }
325
- }
326
-
327
- /**
328
- * Stops a looping sample started with `PlaySound(name, { loop: true })`.
329
- * No-op for one-shot samples and outside µGulp (CLI logs `[sound-stop]` on TTY).
330
- *
331
- * @param {string} _sample atlas sample name passed to `PlaySound`
332
- */
333
- export function StopSound(_sample) {
334
- let sample = String(_sample ?? '').trim();
335
- if (!sample) return;
336
- if (IsMicroGulp()) {
337
- process.send({ type: 'sound-stop', sample });
338
- return;
339
- }
340
- if (process.stdout.isTTY) {
341
- console.log(`[sound-stop] ${sample}`);
342
- }
343
- }
344
-
345
- /**
346
- * Speaks a text through the µGulp dashboard speech output (Web Speech API,
347
- * respects the dashboard speech settings: enabled/volume/rate/pitch).
348
- * CLI fallback: the text is printed as a log line.
349
- *
350
- * @param {string} _text
351
- * @param {object} [_options] { rate?, pitch?, volume? } — per-call overrides (0..2 / 0..2 / 0..1)
352
- */
353
- export function Speak(_text, _options) {
354
- let text = String(_text ?? '').trim();
355
- if (!text) return;
356
- if (IsMicroGulp()) {
357
- process.send({ type: 'speech', text, options: _options ?? null });
358
- return;
359
- }
360
- console.log('[speech] ' + text);
361
- }
362
-
363
- // -------------------------------------------------
364
- // interactive inputs
365
- // -------------------------------------------------
366
-
367
- /**
368
- * Requests a complete form from the µGulp dashboard.
369
- *
370
- * Field descriptor: { id, type, label, default?, options?, validate?,
371
- * min?, max?, step?, rows?, placeholder? }
372
- *
373
- * Supported types: 'text' | 'password' | 'number' | 'textarea' | 'color' |
374
- * 'font' | 'select' | 'radio' | 'checkbox' (multi-select, resolves to a
375
- * string array) | 'range' (slider with synced number box) | 'date' |
376
- * 'time' | 'datetime' | 'file'.
377
- *
378
- * Options (select/radio/checkbox) accept plain strings or objects
379
- * { value, label?, checked?, disabled? }.
380
- *
381
- * Validation rules: { required?, minLength?, pattern?, minSelected? } —
382
- * checks run asynchronously in the webview before submission.
383
- *
384
- * @param {object} _form { title, fields: [...] }
385
- * @returns {Promise<object>} map fieldId -> value
386
- */
387
- export async function RequestForm(_form) {
388
- if (IsMicroGulp()) {
389
- _EnsureIpcListener();
390
- let requestId = 'api-req-' + process.pid + '-' + (++uiRequestCounter);
391
- return new Promise((_resolve) => {
392
- pendingUiRequests.set(requestId, _resolve);
393
- process.send({ type: 'ui-request', requestId, form: _form });
394
- });
395
- }
396
- return _FallbackForm(_form);
397
- }
398
-
399
- /**
400
- * Requests a single validated text value.
401
- * @param {object} _options { label, title?, default?, validate? }
402
- * @returns {Promise<string|null>}
403
- */
404
- export async function RequestTextInput(_options) {
405
- return _SingleField({ ..._options, type: 'text' });
406
- }
407
-
408
- /**
409
- * Requests a color (native color picker in the dashboard).
410
- * @param {object} _options { label, title?, default? } — default as #rrggbb
411
- * @returns {Promise<string|null>}
412
- */
413
- export async function RequestColorInput(_options) {
414
- return _SingleField({ ..._options, type: 'color' });
415
- }
416
-
417
- /**
418
- * Requests a font selection.
419
- * @param {object} _options { label, title?, default?, options? } — options: font family names
420
- * @returns {Promise<string|null>}
421
- */
422
- export async function RequestFontInput(_options) {
423
- return _SingleField({ ..._options, type: 'font' });
424
- }
425
-
426
- /**
427
- * Requests a selection from a fixed option list.
428
- * @param {object} _options { label, title?, default?, options: string[] }
429
- * @returns {Promise<string|null>}
430
- */
431
- export async function RequestSelectInput(_options) {
432
- return _SingleField({ ..._options, type: 'select' });
433
- }
434
-
435
- /**
436
- * Requests a multi-selection (checkbox group in the dashboard).
437
- * @param {object} _options { label, title?, options: Array<string|{value, label?, checked?, disabled?}>, validate? }
438
- * @returns {Promise<string[]>} selected values (empty array when nothing was picked)
439
- */
440
- export async function RequestMultiSelectInput(_options) {
441
- let value = await _SingleField({ ..._options, type: 'checkbox' });
442
- return Array.isArray(value) ? value : (value != null ? [value] : []);
443
- }
444
-
445
- // -------------------------------------------------
446
- // internal
447
- // -------------------------------------------------
448
-
449
- function _EnsureIpcListener() {
450
- if (ipcListenerAttached) return;
451
- ipcListenerAttached = true;
452
- process.on('message', (_message) => {
453
- if (_message && _message.type === 'ui-response' && pendingUiRequests.has(_message.requestId)) {
454
- let resolver = pendingUiRequests.get(_message.requestId);
455
- pendingUiRequests.delete(_message.requestId);
456
- resolver(_message.payload);
457
- }
458
- });
459
- }
460
-
461
- async function _SingleField(_options) {
462
- let values = await RequestForm({
463
- title: _options.title ?? _options.label,
464
- fields: [{
465
- id: 'value',
466
- type: _options.type,
467
- label: _options.label,
468
- default: _options.default,
469
- options: _options.options,
470
- validate: _options.validate,
471
- }],
472
- });
473
- return values.value ?? null;
474
- }
475
-
476
- async function _FallbackForm(_form) {
477
- let values = {};
478
- for (let field of _form.fields ?? []) {
479
- values[field.id] = await _FallbackField(field);
480
- }
481
- return values;
482
- }
483
-
484
- function _NormalizeOptions(_options) {
485
- let result = [];
486
- for (let option of _options ?? []) {
487
- if (option == null) continue;
488
- if (typeof option === 'object') {
489
- if (option.separator) continue;
490
- let value = option.value ?? option.label ?? option.name ?? '';
491
- result.push({
492
- value: String(value),
493
- label: String(option.label ?? option.name ?? value),
494
- checked: !!option.checked,
495
- });
496
- } else {
497
- result.push({ value: String(option), label: String(option), checked: false });
498
- }
499
- }
500
- return result;
501
- }
502
-
503
- function _FallbackDefault(_field) {
504
- if (_field.type === 'checkbox') {
505
- return _NormalizeOptions(_field.options).filter((_option) => _option.checked).map((_option) => _option.value);
506
- }
507
- return _field.default ?? null;
508
- }
509
-
510
- async function _FallbackField(_field) {
511
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
512
- return _FallbackDefault(_field);
513
- }
514
- const readline = await import('node:readline/promises');
515
- let prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
516
- try {
517
- let label = _field.label ?? _field.id;
518
- let options = _NormalizeOptions(_field.options);
519
- let hasOptions = options.length > 0;
520
- if (_field.type === 'checkbox' && hasOptions) {
521
- options.forEach((_option, _index) => console.log(' [' + (_index + 1) + '] ' + _option.label + (_option.checked ? ' *' : '')));
522
- let answer = await prompt.question(label + ' (comma-separated numbers, Enter = defaults marked *): ');
523
- let picked = answer.split(',')
524
- .map((_part) => parseInt(_part.trim(), 10))
525
- .filter((_index) => _index >= 1 && _index <= options.length)
526
- .map((_index) => options[_index - 1].value);
527
- return picked.length > 0 ? picked : _FallbackDefault(_field);
528
- }
529
- if ((_field.type === 'select' || _field.type === 'font' || _field.type === 'radio') && hasOptions) {
530
- options.forEach((_option, _index) => console.log(' [' + (_index + 1) + '] ' + _option.label));
531
- let answer = await prompt.question(label + ' (1-' + options.length + (_field.default ? ', default "' + _field.default + '"' : '') + '): ');
532
- let index = parseInt(answer, 10);
533
- if (index >= 1 && index <= options.length) return options[index - 1].value;
534
- return _field.default ?? null;
535
- }
536
- let hint = _field.type === 'range' || _field.type === 'number'
537
- ? ' (' + (_field.min ?? 0) + '-' + (_field.max ?? 100) + (_field.default != null ? ', default ' + _field.default : '') + ')'
538
- : (_field.default ? ' [' + _field.default + ']' : '');
539
- let answer = await prompt.question(label + hint + ': ');
540
- return answer || (_field.default ?? null);
541
- } finally {
542
- prompt.close();
543
- }
544
- }
545
-
546
- function _NormalizeColumn(_column, _index) {
547
- if (typeof _column === 'string') return { id: 'c' + _index, label: _column };
548
- return { id: String(_column.id ?? 'c' + _index), label: String(_column.label ?? _column.id ?? 'c' + _index) };
549
- }
550
-
551
- function _NormalizeTableSpec(_spec) {
552
- let columns = (_spec?.columns ?? []).map(_NormalizeColumn);
553
- let rows = _spec?.rows ?? [];
554
- let normalizedRows = rows.map((_row) => {
555
- if (Array.isArray(_row)) {
556
- let obj = {};
557
- for (let c = 0; c < columns.length; c++) obj[columns[c].id] = _row[c] ?? '';
558
- return obj;
559
- }
560
- return _row;
561
- });
562
- return { columns, rows: normalizedRows, hyphenate: _spec?.hyphenate !== false };
563
- }
564
-
565
- function _NormalizeTreeSpec(_spec) {
566
- if (_spec?.roots) return { roots: _spec.roots };
567
- if (_spec?.label != null || _spec?.children) return { roots: [_spec] };
568
- return { roots: [] };
569
- }
570
-
571
- function _AsciiTable(_payload) {
572
- let headers = _payload.columns.map((_c) => _c.label);
573
- let widths = headers.map((_h) => _h.length);
574
- for (let row of _payload.rows) {
575
- for (let c = 0; c < _payload.columns.length; c++) {
576
- let cell = String(row[_payload.columns[c].id] ?? '');
577
- widths[c] = Math.max(widths[c], cell.length);
578
- }
579
- }
580
- let sep = widths.map((_w) => '-'.repeat(_w)).join('-+-');
581
- let lines = [headers.map((_h, _i) => _h.padEnd(widths[_i])).join(' | ')];
582
- lines.push(sep);
583
- for (let row of _payload.rows) {
584
- lines.push(_payload.columns.map((_col, _i) => String(row[_col.id] ?? '').padEnd(widths[_i])).join(' | '));
585
- }
586
- return lines.join('\n');
587
- }
588
-
589
- function _AsciiTreeLines(_payload, _indent = '', _roots = null) {
590
- let roots = _roots ?? _payload.roots ?? [];
591
- let lines = [];
592
- for (let i = 0; i < roots.length; i++) {
593
- let node = roots[i];
594
- let branch = i < roots.length - 1 ? '├─ ' : '└─ ';
595
- lines.push(_indent + branch + String(node.label ?? ''));
596
- let childIndent = _indent + (i < roots.length - 1 ? '│ ' : ' ');
597
- if (node.children?.length) lines.push(..._AsciiTreeLines(_payload, childIndent, node.children));
598
- }
599
- return lines;
600
- }
601
-
602
- function _NormalizeGallerySpec(_spec) {
603
- let columns = Math.max(1, Math.min(8, Number(_spec?.columns) || 4));
604
- let items = (_spec?.items ?? []).map((_item) => ({
605
- label: String(_item?.label ?? ''),
606
- src: String(_item?.src ?? ''),
607
- caption: _item?.caption != null ? String(_item.caption) : '',
608
- title: _item?.title != null ? String(_item.title) : '',
609
- })).filter((_item) => _item.src);
610
- return {
611
- title: _spec?.title != null ? String(_spec.title) : '',
612
- columns,
613
- items,
614
- };
615
- }
616
-
617
- function _AsciiGallery(_payload) {
618
- let lines = [];
619
- if (_payload.title) lines.push(_payload.title);
620
- for (let item of _payload.items) {
621
- let meta = item.caption ? ` (${item.caption})` : '';
622
- let srcHint = item.src.startsWith('data:') ? `[data URI, ${item.src.length} chars]` : item.src;
623
- lines.push(` ${item.label}${meta}: ${srcHint}`);
624
- }
625
- return lines.join('\n');
626
- }
627
-
628
- const CALLOUT_VARIANTS = new Set(['info', 'success', 'warning', 'error']);
629
-
630
- function _NormalizeCalloutSpec(_spec) {
631
- let variant = String(_spec?.variant ?? 'info').toLowerCase();
632
- if (!CALLOUT_VARIANTS.has(variant)) variant = 'info';
633
- return {
634
- variant,
635
- title: _spec?.title != null ? String(_spec.title) : '',
636
- message: String(_spec?.message ?? _spec?.text ?? ''),
637
- };
638
- }
639
-
640
- function _NormalizeKeyValueSpec(_spec) {
641
- let rawItems = _spec?.items ?? _spec ?? [];
642
- let items = [];
643
- if (Array.isArray(rawItems)) {
644
- for (let item of rawItems) {
645
- if (item == null) continue;
646
- if (typeof item === 'object' && !Array.isArray(item)) {
647
- items.push({ key: String(item.key ?? ''), value: String(item.value ?? '') });
648
- } else if (Array.isArray(item)) {
649
- items.push({ key: String(item[0] ?? ''), value: String(item[1] ?? '') });
650
- }
651
- }
652
- } else if (rawItems && typeof rawItems === 'object') {
653
- for (let [key, value] of Object.entries(rawItems)) {
654
- if (key === 'title') continue;
655
- items.push({ key: String(key), value: String(value) });
656
- }
657
- }
658
- return { title: _spec?.title != null ? String(_spec.title) : '', items };
659
- }
660
-
661
- const BADGE_VARIANTS = new Set(['neutral', 'info', 'success', 'warning', 'error']);
662
-
663
- function _NormalizeBadgesSpec(_spec) {
664
- let items = (_spec?.items ?? []).map((_item) => {
665
- if (_item == null) return null;
666
- if (typeof _item === 'object') {
667
- let variant = String(_item.variant ?? 'neutral').toLowerCase();
668
- return { label: String(_item.label ?? _item.value ?? ''), variant: BADGE_VARIANTS.has(variant) ? variant : 'neutral' };
669
- }
670
- return { label: String(_item), variant: 'neutral' };
671
- }).filter((_item) => _item && _item.label);
672
- return { title: _spec?.title != null ? String(_spec.title) : '', items };
673
- }
674
-
675
- function _NormalizeCodeSpec(_spec) {
676
- return {
677
- title: _spec?.title != null ? String(_spec.title) : '',
678
- language: _spec?.language != null ? String(_spec.language) : '',
679
- code: String(_spec?.code ?? _spec?.text ?? ''),
680
- };
681
- }
682
-
683
- function _NormalizeChartSpec(_spec) {
684
- let series = (_spec?.series ?? _spec?.data ?? []).map((_entry) => {
685
- if (_entry == null) return null;
686
- if (typeof _entry === 'object' && !Array.isArray(_entry)) {
687
- return {
688
- label: String(_entry.label ?? ''),
689
- value: Number(_entry.value) || 0,
690
- color: _entry.color != null ? String(_entry.color) : '',
691
- };
692
- }
693
- if (Array.isArray(_entry)) return { label: String(_entry[0] ?? ''), value: Number(_entry[1]) || 0, color: '' };
694
- return { label: '', value: Number(_entry) || 0, color: '' };
695
- }).filter(Boolean);
696
- let explicitMax = Number(_spec?.max);
697
- let dataMax = series.reduce((_max, _entry) => Math.max(_max, _entry.value), 0);
698
- return {
699
- title: _spec?.title != null ? String(_spec.title) : '',
700
- type: 'bar',
701
- unit: _spec?.unit != null ? String(_spec.unit) : '',
702
- max: Number.isFinite(explicitMax) && explicitMax > 0 ? explicitMax : (dataMax || 1),
703
- series,
704
- };
705
- }
706
-
707
- function _AsciiChart(_payload) {
708
- let lines = [];
709
- if (_payload.title) lines.push(_payload.title);
710
- let labelWidth = _payload.series.reduce((_max, _entry) => Math.max(_max, _entry.label.length), 0);
711
- const BAR_WIDTH = 24;
712
- for (let entry of _payload.series) {
713
- let filled = _payload.max > 0 ? Math.round((entry.value / _payload.max) * BAR_WIDTH) : 0;
714
- let bar = '\u2588'.repeat(filled) + '\u00b7'.repeat(Math.max(0, BAR_WIDTH - filled));
715
- lines.push(' ' + entry.label.padEnd(labelWidth) + ' ' + bar + ' ' + entry.value + (_payload.unit ? ' ' + _payload.unit : ''));
716
- }
717
- return lines;
718
- }
719
-
720
- /**
721
- * Asks µGulp to re-evaluate dynamic task metadata (µEnabled predicates,
722
- * disabled tooltips) and refresh the dashboard task list. Call after
723
- * changing module-level state that drives enable/disable rules.
724
- * No-op outside a µGulp worker.
725
- */
726
- export function NotifyTasksChanged() {
727
- if (typeof process.send === 'function') {
728
- process.send({ type: 'refresh-tasks' });
729
- }
730
- }
731
-
732
- export default {
733
- IsMicroGulp,
734
- IsµGulp,
735
- NotifyTasksChanged,
736
- ReportProgress,
737
- CreateProgress,
738
- PlaySignal,
739
- PlaySound,
740
- StopSound,
741
- Speak,
742
- RequestForm,
743
- RequestTextInput,
744
- RequestColorInput,
745
- RequestFontInput,
746
- RequestSelectInput,
747
- RequestMultiSelectInput,
748
- Log: I18x.Log,
749
- Warn: I18x.Warn,
750
- LogError: I18x.LogError,
751
- Translate: I18x.Translate,
752
- GetLid: I18x.GetLid,
753
- SetLid: I18x.SetLid,
754
- GetTimeZone: I18x.GetTimeZone,
755
- DateInTimeZone: I18x.DateInTimeZone,
756
- TimeZoneOffset: I18x.TimeZoneOffset,
757
- InstallStringExtensions: I18x.InstallStringExtensions,
758
- InstallFormatPrototypes: I18x.InstallFormatPrototypes,
759
- InstallHyphenPrototypes: I18x.InstallHyphenPrototypes,
760
- LogTable,
761
- LogTree,
762
- LogGallery,
763
- LogAssetPreviews,
764
- LogBuildDebugReport,
765
- LogCallout,
766
- LogKeyValue,
767
- LogBadges,
768
- LogCode,
769
- LogChart,
770
- };
1
+ // ===========================================
2
+ // gulp-mu-gulp-api — µGulp task API
3
+ // © 2026 Meinolf Amekudzi
4
+ // (published under MIT license)
5
+ // ===========================================
6
+
7
+ /**
8
+ * Public API for gulp tasks running under the µGulp orchestrator.
9
+ *
10
+ * Under µGulp every task runs inside a forked worker process with an IPC
11
+ * channel to the engine; this module talks to that channel directly, so it
12
+ * has zero coupling to µGulp internals and works from any npm package in
13
+ * the pipeline. Without µGulp (plain "gulp" CLI run) every function
14
+ * degrades gracefully: progress renders on the terminal, inputs fall back
15
+ * to readline prompts on a TTY or to the declared defaults otherwise.
16
+ *
17
+ * @example
18
+ * import { ReportProgress, RequestTextInput, RequestColorInput } from 'gulp-mu-gulp-api';
19
+ *
20
+ * export async function BUILD_THEME() {
21
+ * let accent = await RequestColorInput({ label: 'Accent color', default: '#00e5ff' });
22
+ * for (let step = 0; step < 10; step++) {
23
+ * // ... work ...
24
+ * ReportProgress((step + 1) / 10, 'compiling theme');
25
+ * }
26
+ * }
27
+ *
28
+ * @module gulp-mu-gulp-api
29
+ */
30
+
31
+ // Localized console output (i18x). Re-exported so tasks import everything
32
+ // from one place: `import { Log, Warn, ReportProgress } from 'gulp-mu-gulp-api'`.
33
+ export { Log, Warn, LogError, Translate, FormatValue, GetLid, SetLid, GetTimeZone, DateInTimeZone, TimeZoneOffset, InstallStringExtensions, InstallFormatPrototypes, InstallHyphenPrototypes } from './i18x.mjs';
34
+ export { WordHyphenation, Hyphenation, LoadHyphenData } from './i18x-hyphen.mjs';
35
+ import * as I18x from './i18x.mjs';
36
+
37
+ let uiRequestCounter = 0;
38
+ let ipcListenerAttached = false;
39
+ let lastLoggedPercent = -1;
40
+ const pendingUiRequests = new Map();
41
+
42
+ /**
43
+ * @returns {boolean} true when the task runs inside a µGulp worker with an
44
+ * attached dashboard (progress and inputs are rendered in the webview)
45
+ */
46
+ export function IsMicroGulp() {
47
+ // The worker pool sets MICROGULP=1 for every forked task process and
48
+ // connects an IPC channel (process.send). Child processes spawned from
49
+ // within a task inherit the env flag but not IPC — they must not be
50
+ // treated as dashboard workers.
51
+ return process.env.MICROGULP === '1' && typeof process.send === 'function';
52
+ }
53
+
54
+ /** Brand-aligned alias for {@link IsMicroGulp}. */
55
+ export const IsµGulp = IsMicroGulp;
56
+
57
+ /*
58
+ * Task metadata (µDisplayName, µDescription, µTooltip, µGroup) is localized
59
+ * the same way as any other i18x phrase: with the context tag written *into*
60
+ * the source phrase and a String.prototype.i18xRegister() marker so the
61
+ * i18xe-sync tooling extracts it and the dashboard can translate it later.
62
+ *
63
+ * MAKE_BUILDS.µDisplayName =
64
+ * 'Make Build V<version/><context="µDisplayName"/>'.i18xRegister();
65
+ *
66
+ * i18xRegister() returns the phrase unchanged (the i18x key), so the classic
67
+ * gulp CLI keeps seeing a plain string while the dashboard translates it via
68
+ * the merged project dictionary. The prototype is installed by
69
+ * InstallStringExtensions() (auto-run on import of this module).
70
+ */
71
+
72
+ // -------------------------------------------------
73
+ // progress
74
+ // -------------------------------------------------
75
+
76
+ /**
77
+ * Reports task progress to the µGulp dashboard (determinate neon progress
78
+ * bar in the task's sector). CLI fallback: an updating line on a TTY,
79
+ * 10%-step log lines otherwise.
80
+ *
81
+ * @param {number} _value progress in the range 0..1
82
+ * @param {string} [_label] short status label shown next to the bar
83
+ */
84
+ export function ReportProgress(_value, _label) {
85
+ let value = Math.max(0, Math.min(1, Number(_value) || 0));
86
+ if (IsMicroGulp()) {
87
+ process.send({ type: 'progress', value, label: _label });
88
+ return;
89
+ }
90
+ let percent = Math.round(value * 100);
91
+ if (process.stdout.isTTY) {
92
+ process.stdout.write('\r' + (_label ?? 'progress') + ' ' + percent + '%' + (value >= 1 ? '\n' : ''));
93
+ } else if (percent >= 100 || percent - lastLoggedPercent >= 10) {
94
+ lastLoggedPercent = percent >= 100 ? -1 : percent;
95
+ console.log((_label ?? 'progress') + ' ' + percent + '%');
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Convenience wrapper that carries a fixed label.
101
+ *
102
+ * @param {string} [_label]
103
+ * @returns {{Update: function(number, string=): void, Done: function(): void}}
104
+ */
105
+ export function CreateProgress(_label) {
106
+ return {
107
+ Update(_value, _stepLabel) {
108
+ ReportProgress(_value, _stepLabel ?? _label);
109
+ },
110
+ Done() {
111
+ ReportProgress(1, _label);
112
+ },
113
+ };
114
+ }
115
+
116
+ function _SendStructuredLog(_format, _payload) {
117
+ if (!IsMicroGulp()) return false;
118
+ if (typeof process.send !== 'function') return false;
119
+ process.send({ type: 'structured-log', format: _format, payload: _payload });
120
+ return true;
121
+ }
122
+
123
+ /**
124
+ * Sends a table to the dashboard log pane. The payload is JSON:
125
+ * `{ columns: string[] | {id, label}[], rows: (string|number)[][] | object[], hyphenate?: boolean }`.
126
+ * CLI fallback: a simple ASCII table on stdout.
127
+ *
128
+ * @param {object} _spec table descriptor
129
+ */
130
+ export function LogTable(_spec) {
131
+ let payload = _NormalizeTableSpec(_spec);
132
+ if (_SendStructuredLog('table', payload)) return;
133
+ console.log(_AsciiTable(payload));
134
+ }
135
+
136
+ /**
137
+ * Sends a tree structure to the dashboard log pane. The payload is JSON:
138
+ * `{ label?: string, children?: object[], roots?: object[] }` where each node
139
+ * has `{ label, children? }`. CLI fallback: indented text lines.
140
+ *
141
+ * @param {object} _spec tree descriptor (single root or `{ roots: [...] }`)
142
+ */
143
+ export function LogTree(_spec) {
144
+ let payload = _NormalizeTreeSpec(_spec);
145
+ if (_SendStructuredLog('tree', payload)) return;
146
+ for (let line of _AsciiTreeLines(payload)) console.log(line);
147
+ }
148
+
149
+ /**
150
+ * Sends an image gallery to the dashboard log pane. Each item needs a `src`
151
+ * (data URI or https URL) and a `label`; optional `caption` and per-item `title`.
152
+ * CLI fallback: lists labels and src lengths on stdout.
153
+ *
154
+ * @param {object} _spec `{ title?, columns?, items: { label, src, caption?, title? }[] }`
155
+ */
156
+ export function LogGallery(_spec) {
157
+ let payload = _NormalizeGallerySpec(_spec);
158
+ if (_SendStructuredLog('gallery', payload)) return;
159
+ console.log(_AsciiGallery(payload));
160
+ }
161
+
162
+ /**
163
+ * Renders µCSS BuildSkin `report.debug.previews` as a thumbnail gallery.
164
+ * Items without `thumbDataUri` are omitted (paths-only assets stay in the text log).
165
+ *
166
+ * @param {object[]} _previews preview entries from `report.debug.previews`
167
+ * @param {object} [_options] `{ title?, columns? }`
168
+ */
169
+ export function LogAssetPreviews(_previews, _options = {}) {
170
+ let items = (_previews ?? [])
171
+ .filter((_entry) => _entry?.thumbDataUri)
172
+ .map((_entry) => ({
173
+ label: String(_entry.relPath ?? _entry.path ?? ''),
174
+ src: _entry.thumbDataUri,
175
+ caption: [
176
+ _entry.step,
177
+ _entry.skipped ? 'cached' : null,
178
+ _entry.width && _entry.height ? `${_entry.width}\u00d7${_entry.height}` : null,
179
+ ].filter(Boolean).join(' \u00b7 '),
180
+ }));
181
+ if (!items.length) return;
182
+ LogGallery({
183
+ title: _options.title,
184
+ columns: _options.columns ?? 4,
185
+ items,
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Convenience wrapper for a full µCSS `BuildSkin` debug block: summary table
191
+ * plus thumbnail gallery when `report.debug` is present.
192
+ *
193
+ * @param {object} _report BuildSkin return value
194
+ * @param {object} [_options] `{ title?, columns?, table?: boolean }`
195
+ */
196
+ export function LogBuildDebugReport(_report, _options = {}) {
197
+ let debug = _report?.debug;
198
+ if (!debug) return;
199
+ if (_options.table !== false && debug.summary) {
200
+ LogTable({
201
+ columns: [{ id: 'metric', label: 'Metric' }, { id: 'value', label: 'Value' }],
202
+ rows: Object.entries(debug.summary).map(([metric, value]) => ({ metric, value: String(value) })),
203
+ hyphenate: false,
204
+ });
205
+ }
206
+ LogAssetPreviews(debug.previews, {
207
+ title: _options.title ?? 'Generated assets',
208
+ columns: _options.columns,
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Sends a highlighted callout box (info/success/warning/error) to the dashboard
214
+ * log pane. The payload is JSON: `{ variant, title?, message }`.
215
+ * CLI fallback: a bracketed line on stdout.
216
+ *
217
+ * @param {object} _spec `{ variant?: 'info'|'success'|'warning'|'error', title?, message }`
218
+ */
219
+ export function LogCallout(_spec) {
220
+ let payload = _NormalizeCalloutSpec(_spec);
221
+ if (_SendStructuredLog('callout', payload)) return;
222
+ let head = payload.variant.toUpperCase() + (payload.title ? ': ' + payload.title : '');
223
+ console.log('[' + head + '] ' + payload.message);
224
+ }
225
+
226
+ /**
227
+ * Sends a key/value list (metrics, build summary, environment) to the dashboard
228
+ * log pane. The payload is JSON: `{ title?, items: {key, value}[] }`.
229
+ * CLI fallback: aligned `key: value` lines on stdout.
230
+ *
231
+ * @param {object} _spec `{ title?, items: Array<{key, value}> | Record<string, any> }`
232
+ */
233
+ export function LogKeyValue(_spec) {
234
+ let payload = _NormalizeKeyValueSpec(_spec);
235
+ if (_SendStructuredLog('key-value', payload)) return;
236
+ if (payload.title) console.log(payload.title);
237
+ let width = payload.items.reduce((_max, _item) => Math.max(_max, _item.key.length), 0);
238
+ for (let item of payload.items) console.log(' ' + item.key.padEnd(width) + ' ' + item.value);
239
+ }
240
+
241
+ /**
242
+ * Sends a row of status badges/chips to the dashboard log pane. The payload is
243
+ * JSON: `{ title?, items: {label, variant?}[] }`.
244
+ * CLI fallback: bracketed labels on one stdout line.
245
+ *
246
+ * @param {object} _spec `{ title?, items: Array<string|{label, variant?}> }`
247
+ */
248
+ export function LogBadges(_spec) {
249
+ let payload = _NormalizeBadgesSpec(_spec);
250
+ if (_SendStructuredLog('badges', payload)) return;
251
+ let chips = payload.items.map((_item) => '[' + _item.label + ']').join(' ');
252
+ console.log((payload.title ? payload.title + ' ' : '') + chips);
253
+ }
254
+
255
+ /**
256
+ * Sends a code block (monospace, preserved whitespace) to the dashboard log
257
+ * pane. The payload is JSON: `{ title?, language?, code }`.
258
+ * CLI fallback: the code printed verbatim on stdout.
259
+ *
260
+ * @param {object} _spec `{ title?, language?, code }`
261
+ */
262
+ export function LogCode(_spec) {
263
+ let payload = _NormalizeCodeSpec(_spec);
264
+ if (_SendStructuredLog('code', payload)) return;
265
+ if (payload.title) console.log(payload.title + (payload.language ? ' (' + payload.language + ')' : ''));
266
+ console.log(payload.code);
267
+ }
268
+
269
+ /**
270
+ * Sends a bar chart to the dashboard log pane, rendered as inline SVG (no
271
+ * scripts Content-Security-Policy safe). The payload is JSON:
272
+ * `{ title?, type?: 'bar', series: {label, value, color?}[], max?, unit? }`.
273
+ * CLI fallback: an ASCII bar chart on stdout.
274
+ *
275
+ * @param {object} _spec `{ title?, type?, series: Array<{label, value, color?}>, max?, unit? }`
276
+ */
277
+ export function LogChart(_spec) {
278
+ let payload = _NormalizeChartSpec(_spec);
279
+ if (_SendStructuredLog('chart', payload)) return;
280
+ for (let line of _AsciiChart(payload)) console.log(line);
281
+ }
282
+
283
+ // -------------------------------------------------
284
+ // sound & speech
285
+ // -------------------------------------------------
286
+
287
+ /**
288
+ * Plays an acoustic signal in the µGulp dashboard (WebAudio, respects the
289
+ * dashboard audio settings: mute/volume). CLI fallback: terminal bell for
290
+ * 'attention' and 'error' on a TTY, silent otherwise.
291
+ *
292
+ * @param {'success'|'error'|'attention'} [_signal]
293
+ */
294
+ export function PlaySignal(_signal = 'attention') {
295
+ if (IsMicroGulp()) {
296
+ process.send({ type: 'sound', signal: _signal });
297
+ return;
298
+ }
299
+ if (process.stdout.isTTY && (_signal === 'attention' || _signal === 'error')) {
300
+ process.stdout.write('\u0007');
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Plays a named sample from the dashboard sound atlas (µAU). Sample names match
306
+ * the files bundled with µGulp (`success`, `error`, `attention`, or custom
307
+ * atlas entries in the consumer skin). When `loop` is true the sample repeats
308
+ * until `StopSound(sample)` is called.
309
+ *
310
+ * CLI fallback: logs `[sound] <name>` on a TTY, silent otherwise.
311
+ *
312
+ * @param {string} _sample atlas sample name (e.g. `'success'`)
313
+ * @param {object} [_options]
314
+ * @param {boolean} [_options.loop] repeat until stopped (default `false`)
315
+ */
316
+ export function PlaySound(_sample, _options = {}) {
317
+ let sample = String(_sample ?? '').trim();
318
+ if (!sample) return;
319
+ if (IsMicroGulp()) {
320
+ process.send({ type: 'sound', sample, loop: !!_options.loop });
321
+ return;
322
+ }
323
+ if (process.stdout.isTTY) {
324
+ console.log(`[sound] ${sample}${_options.loop ? ' (loop)' : ''}`);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Stops a looping sample started with `PlaySound(name, { loop: true })`.
330
+ * No-op for one-shot samples and outside µGulp (CLI logs `[sound-stop]` on TTY).
331
+ *
332
+ * @param {string} _sample atlas sample name passed to `PlaySound`
333
+ */
334
+ export function StopSound(_sample) {
335
+ let sample = String(_sample ?? '').trim();
336
+ if (!sample) return;
337
+ if (IsMicroGulp()) {
338
+ process.send({ type: 'sound-stop', sample });
339
+ return;
340
+ }
341
+ if (process.stdout.isTTY) {
342
+ console.log(`[sound-stop] ${sample}`);
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Speaks a text through the µGulp dashboard speech output (Web Speech API,
348
+ * respects the dashboard speech settings: enabled/volume/rate/pitch).
349
+ * CLI fallback: the text is printed as a log line.
350
+ *
351
+ * @param {string} _text
352
+ * @param {object} [_options] { rate?, pitch?, volume? } — per-call overrides (0..2 / 0..2 / 0..1)
353
+ */
354
+ export function Speak(_text, _options) {
355
+ let text = String(_text ?? '').trim();
356
+ if (!text) return;
357
+ if (IsMicroGulp()) {
358
+ process.send({ type: 'speech', text, options: _options ?? null });
359
+ return;
360
+ }
361
+ console.log('[speech] ' + text);
362
+ }
363
+
364
+ // -------------------------------------------------
365
+ // interactive inputs
366
+ // -------------------------------------------------
367
+
368
+ /**
369
+ * Requests a complete form from the µGulp dashboard.
370
+ *
371
+ * Field descriptor: { id, type, label, default?, options?, validate?,
372
+ * min?, max?, step?, rows?, placeholder? }
373
+ *
374
+ * Supported types: 'text' | 'password' | 'number' | 'textarea' | 'color' |
375
+ * 'font' | 'select' | 'radio' | 'checkbox' (multi-select, resolves to a
376
+ * string array) | 'range' (slider with synced number box) | 'date' |
377
+ * 'time' | 'datetime' | 'file'.
378
+ *
379
+ * Options (select/radio/checkbox) accept plain strings or objects
380
+ * { value, label?, checked?, disabled? }.
381
+ *
382
+ * Validation rules: { required?, minLength?, pattern?, minSelected? } —
383
+ * checks run asynchronously in the webview before submission.
384
+ *
385
+ * @param {object} _form { title, fields: [...] }
386
+ * @returns {Promise<object>} map fieldId -> value
387
+ */
388
+ export async function RequestForm(_form) {
389
+ if (IsMicroGulp()) {
390
+ _EnsureIpcListener();
391
+ let requestId = 'api-req-' + process.pid + '-' + (++uiRequestCounter);
392
+ return new Promise((_resolve) => {
393
+ pendingUiRequests.set(requestId, _resolve);
394
+ process.send({ type: 'ui-request', requestId, form: _form });
395
+ });
396
+ }
397
+ return _FallbackForm(_form);
398
+ }
399
+
400
+ /**
401
+ * Shows a blocking modal message inside the µGulp webview only (info / warning / error).
402
+ * Opt-in — use when a task needs explicit acknowledgment, not for every log line.
403
+ * CLI fallback: bracketed console output and `{ button: 'ok' }`.
404
+ *
405
+ * @param {object} _spec `{ variant?, title?, message?, buttons?, presentation? }`
406
+ * @param {'webview'|'ide'|'auto'} [_spec.presentation] `ide` = native host dialog when
407
+ * available (VS Code/Cursor); `webview` = in-dashboard glass modal; `auto` = follow host setting
408
+ * @returns {Promise<{ button: string }>}
409
+ */
410
+ export async function ShowModalMessage(_spec) {
411
+ if (IsMicroGulp()) {
412
+ _EnsureIpcListener();
413
+ let requestId = 'api-modal-' + process.pid + '-' + (++uiRequestCounter);
414
+ return new Promise((_resolve) => {
415
+ pendingUiRequests.set(requestId, _resolve);
416
+ process.send({ type: 'ui-modal', requestId, modal: _NormalizeModalSpec(_spec) });
417
+ });
418
+ }
419
+ return _FallbackModalMessage(_spec);
420
+ }
421
+
422
+ /**
423
+ * @param {object|undefined|null} _spec
424
+ * @returns {object}
425
+ */
426
+ function _NormalizeModalSpec(_spec) {
427
+ let variant = ['info', 'warning', 'error'].includes(_spec?.variant) ? _spec.variant : 'info';
428
+ let presentation = ['webview', 'ide', 'auto'].includes(_spec?.presentation) ? _spec.presentation : undefined;
429
+ return {
430
+ variant,
431
+ title: _spec?.title != null ? String(_spec.title) : '',
432
+ message: _spec?.message != null ? String(_spec.message) : '',
433
+ buttons: Array.isArray(_spec?.buttons) ? _spec.buttons : undefined,
434
+ presentation,
435
+ };
436
+ }
437
+
438
+ /**
439
+ * @param {object|undefined|null} _spec
440
+ * @returns {Promise<{ button: string }>}
441
+ */
442
+ function _FallbackModalMessage(_spec) {
443
+ let normalized = _NormalizeModalSpec(_spec);
444
+ console.log(`[modal:${normalized.variant}] ${normalized.title}`.trim());
445
+ if (normalized.message) console.log(normalized.message);
446
+ return { button: 'ok' };
447
+ }
448
+
449
+ /**
450
+ * Confirmation dialog (Yes/No or OK/Cancel). Wrapper around {@link ShowModalMessage}.
451
+ *
452
+ * @param {object} _spec `{ title?, message?, variant?, style?, presentation? }`
453
+ * @param {'yes-no'|'ok-cancel'} [_spec.style] default `yes-no`
454
+ * @returns {Promise<{ button: string }>} `yes`, `no`, `ok`, or `cancel`
455
+ */
456
+ export async function ShowConfirmMessage(_spec) {
457
+ let style = _spec?.style === 'ok-cancel' ? 'ok-cancel' : 'yes-no';
458
+ let buttons = style === 'ok-cancel'
459
+ ? [{ id: 'cancel' }, { id: 'ok', primary: true }]
460
+ : [{ id: 'no' }, { id: 'yes', primary: true }];
461
+ return ShowModalMessage({
462
+ variant: _spec?.variant ?? 'warning',
463
+ title: _spec?.title,
464
+ message: _spec?.message,
465
+ presentation: _spec?.presentation,
466
+ buttons,
467
+ });
468
+ }
469
+
470
+ /**
471
+ * Requests a single validated text value.
472
+ * @param {object} _options { label, title?, default?, validate? }
473
+ * @returns {Promise<string|null>}
474
+ */
475
+ export async function RequestTextInput(_options) {
476
+ return _SingleField({ ..._options, type: 'text' });
477
+ }
478
+
479
+ /**
480
+ * Requests a color (native color picker in the dashboard).
481
+ * @param {object} _options { label, title?, default? } — default as #rrggbb
482
+ * @returns {Promise<string|null>}
483
+ */
484
+ export async function RequestColorInput(_options) {
485
+ return _SingleField({ ..._options, type: 'color' });
486
+ }
487
+
488
+ /**
489
+ * Requests a font selection.
490
+ * @param {object} _options { label, title?, default?, options? } — options: font family names
491
+ * @returns {Promise<string|null>}
492
+ */
493
+ export async function RequestFontInput(_options) {
494
+ return _SingleField({ ..._options, type: 'font' });
495
+ }
496
+
497
+ /**
498
+ * Requests a selection from a fixed option list.
499
+ * @param {object} _options { label, title?, default?, options: string[] }
500
+ * @returns {Promise<string|null>}
501
+ */
502
+ export async function RequestSelectInput(_options) {
503
+ return _SingleField({ ..._options, type: 'select' });
504
+ }
505
+
506
+ /**
507
+ * Requests a multi-selection (checkbox group in the dashboard).
508
+ * @param {object} _options { label, title?, options: Array<string|{value, label?, checked?, disabled?}>, validate? }
509
+ * @returns {Promise<string[]>} selected values (empty array when nothing was picked)
510
+ */
511
+ export async function RequestMultiSelectInput(_options) {
512
+ let value = await _SingleField({ ..._options, type: 'checkbox' });
513
+ return Array.isArray(value) ? value : (value != null ? [value] : []);
514
+ }
515
+
516
+ // -------------------------------------------------
517
+ // internal
518
+ // -------------------------------------------------
519
+
520
+ function _EnsureIpcListener() {
521
+ if (ipcListenerAttached) return;
522
+ ipcListenerAttached = true;
523
+ process.on('message', (_message) => {
524
+ if (_message && _message.type === 'ui-response' && pendingUiRequests.has(_message.requestId)) {
525
+ let resolver = pendingUiRequests.get(_message.requestId);
526
+ pendingUiRequests.delete(_message.requestId);
527
+ resolver(_message.payload);
528
+ }
529
+ });
530
+ }
531
+
532
+ async function _SingleField(_options) {
533
+ let values = await RequestForm({
534
+ title: _options.title ?? _options.label,
535
+ fields: [{
536
+ id: 'value',
537
+ type: _options.type,
538
+ label: _options.label,
539
+ default: _options.default,
540
+ options: _options.options,
541
+ validate: _options.validate,
542
+ }],
543
+ });
544
+ return values.value ?? null;
545
+ }
546
+
547
+ async function _FallbackForm(_form) {
548
+ let values = {};
549
+ for (let field of _form.fields ?? []) {
550
+ values[field.id] = await _FallbackField(field);
551
+ }
552
+ return values;
553
+ }
554
+
555
+ function _NormalizeOptions(_options) {
556
+ let result = [];
557
+ for (let option of _options ?? []) {
558
+ if (option == null) continue;
559
+ if (typeof option === 'object') {
560
+ if (option.separator) continue;
561
+ let value = option.value ?? option.label ?? option.name ?? '';
562
+ result.push({
563
+ value: String(value),
564
+ label: String(option.label ?? option.name ?? value),
565
+ checked: !!option.checked,
566
+ });
567
+ } else {
568
+ result.push({ value: String(option), label: String(option), checked: false });
569
+ }
570
+ }
571
+ return result;
572
+ }
573
+
574
+ function _FallbackDefault(_field) {
575
+ if (_field.type === 'checkbox') {
576
+ return _NormalizeOptions(_field.options).filter((_option) => _option.checked).map((_option) => _option.value);
577
+ }
578
+ return _field.default ?? null;
579
+ }
580
+
581
+ async function _FallbackField(_field) {
582
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
583
+ return _FallbackDefault(_field);
584
+ }
585
+ const readline = await import('node:readline/promises');
586
+ let prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
587
+ try {
588
+ let label = _field.label ?? _field.id;
589
+ let options = _NormalizeOptions(_field.options);
590
+ let hasOptions = options.length > 0;
591
+ if (_field.type === 'checkbox' && hasOptions) {
592
+ options.forEach((_option, _index) => console.log(' [' + (_index + 1) + '] ' + _option.label + (_option.checked ? ' *' : '')));
593
+ let answer = await prompt.question(label + ' (comma-separated numbers, Enter = defaults marked *): ');
594
+ let picked = answer.split(',')
595
+ .map((_part) => parseInt(_part.trim(), 10))
596
+ .filter((_index) => _index >= 1 && _index <= options.length)
597
+ .map((_index) => options[_index - 1].value);
598
+ return picked.length > 0 ? picked : _FallbackDefault(_field);
599
+ }
600
+ if ((_field.type === 'select' || _field.type === 'font' || _field.type === 'radio') && hasOptions) {
601
+ options.forEach((_option, _index) => console.log(' [' + (_index + 1) + '] ' + _option.label));
602
+ let answer = await prompt.question(label + ' (1-' + options.length + (_field.default ? ', default "' + _field.default + '"' : '') + '): ');
603
+ let index = parseInt(answer, 10);
604
+ if (index >= 1 && index <= options.length) return options[index - 1].value;
605
+ return _field.default ?? null;
606
+ }
607
+ let hint = _field.type === 'range' || _field.type === 'number'
608
+ ? ' (' + (_field.min ?? 0) + '-' + (_field.max ?? 100) + (_field.default != null ? ', default ' + _field.default : '') + ')'
609
+ : (_field.default ? ' [' + _field.default + ']' : '');
610
+ let answer = await prompt.question(label + hint + ': ');
611
+ return answer || (_field.default ?? null);
612
+ } finally {
613
+ prompt.close();
614
+ }
615
+ }
616
+
617
+ function _NormalizeColumn(_column, _index) {
618
+ if (typeof _column === 'string') return { id: 'c' + _index, label: _column };
619
+ return { id: String(_column.id ?? 'c' + _index), label: String(_column.label ?? _column.id ?? 'c' + _index) };
620
+ }
621
+
622
+ function _NormalizeTableSpec(_spec) {
623
+ let columns = (_spec?.columns ?? []).map(_NormalizeColumn);
624
+ let rows = _spec?.rows ?? [];
625
+ let normalizedRows = rows.map((_row) => {
626
+ if (Array.isArray(_row)) {
627
+ let obj = {};
628
+ for (let c = 0; c < columns.length; c++) obj[columns[c].id] = _row[c] ?? '';
629
+ return obj;
630
+ }
631
+ return _row;
632
+ });
633
+ return { columns, rows: normalizedRows, hyphenate: _spec?.hyphenate !== false };
634
+ }
635
+
636
+ function _NormalizeTreeSpec(_spec) {
637
+ if (_spec?.roots) return { roots: _spec.roots };
638
+ if (_spec?.label != null || _spec?.children) return { roots: [_spec] };
639
+ return { roots: [] };
640
+ }
641
+
642
+ function _AsciiTable(_payload) {
643
+ let headers = _payload.columns.map((_c) => _c.label);
644
+ let widths = headers.map((_h) => _h.length);
645
+ for (let row of _payload.rows) {
646
+ for (let c = 0; c < _payload.columns.length; c++) {
647
+ let cell = String(row[_payload.columns[c].id] ?? '');
648
+ widths[c] = Math.max(widths[c], cell.length);
649
+ }
650
+ }
651
+ let sep = widths.map((_w) => '-'.repeat(_w)).join('-+-');
652
+ let lines = [headers.map((_h, _i) => _h.padEnd(widths[_i])).join(' | ')];
653
+ lines.push(sep);
654
+ for (let row of _payload.rows) {
655
+ lines.push(_payload.columns.map((_col, _i) => String(row[_col.id] ?? '').padEnd(widths[_i])).join(' | '));
656
+ }
657
+ return lines.join('\n');
658
+ }
659
+
660
+ function _AsciiTreeLines(_payload, _indent = '', _roots = null) {
661
+ let roots = _roots ?? _payload.roots ?? [];
662
+ let lines = [];
663
+ for (let i = 0; i < roots.length; i++) {
664
+ let node = roots[i];
665
+ let branch = i < roots.length - 1 ? '├─ ' : '└─ ';
666
+ lines.push(_indent + branch + String(node.label ?? ''));
667
+ let childIndent = _indent + (i < roots.length - 1 ? '' : ' ');
668
+ if (node.children?.length) lines.push(..._AsciiTreeLines(_payload, childIndent, node.children));
669
+ }
670
+ return lines;
671
+ }
672
+
673
+ function _NormalizeGallerySpec(_spec) {
674
+ let columns = Math.max(1, Math.min(8, Number(_spec?.columns) || 4));
675
+ let items = (_spec?.items ?? []).map((_item) => ({
676
+ label: String(_item?.label ?? ''),
677
+ src: String(_item?.src ?? ''),
678
+ caption: _item?.caption != null ? String(_item.caption) : '',
679
+ title: _item?.title != null ? String(_item.title) : '',
680
+ })).filter((_item) => _item.src);
681
+ return {
682
+ title: _spec?.title != null ? String(_spec.title) : '',
683
+ columns,
684
+ items,
685
+ };
686
+ }
687
+
688
+ function _AsciiGallery(_payload) {
689
+ let lines = [];
690
+ if (_payload.title) lines.push(_payload.title);
691
+ for (let item of _payload.items) {
692
+ let meta = item.caption ? ` (${item.caption})` : '';
693
+ let srcHint = item.src.startsWith('data:') ? `[data URI, ${item.src.length} chars]` : item.src;
694
+ lines.push(` ${item.label}${meta}: ${srcHint}`);
695
+ }
696
+ return lines.join('\n');
697
+ }
698
+
699
+ const CALLOUT_VARIANTS = new Set(['info', 'success', 'warning', 'error']);
700
+
701
+ function _NormalizeCalloutSpec(_spec) {
702
+ let variant = String(_spec?.variant ?? 'info').toLowerCase();
703
+ if (!CALLOUT_VARIANTS.has(variant)) variant = 'info';
704
+ return {
705
+ variant,
706
+ title: _spec?.title != null ? String(_spec.title) : '',
707
+ message: String(_spec?.message ?? _spec?.text ?? ''),
708
+ };
709
+ }
710
+
711
+ function _NormalizeKeyValueSpec(_spec) {
712
+ let rawItems = _spec?.items ?? _spec ?? [];
713
+ let items = [];
714
+ if (Array.isArray(rawItems)) {
715
+ for (let item of rawItems) {
716
+ if (item == null) continue;
717
+ if (typeof item === 'object' && !Array.isArray(item)) {
718
+ items.push({ key: String(item.key ?? ''), value: String(item.value ?? '') });
719
+ } else if (Array.isArray(item)) {
720
+ items.push({ key: String(item[0] ?? ''), value: String(item[1] ?? '') });
721
+ }
722
+ }
723
+ } else if (rawItems && typeof rawItems === 'object') {
724
+ for (let [key, value] of Object.entries(rawItems)) {
725
+ if (key === 'title') continue;
726
+ items.push({ key: String(key), value: String(value) });
727
+ }
728
+ }
729
+ return { title: _spec?.title != null ? String(_spec.title) : '', items };
730
+ }
731
+
732
+ const BADGE_VARIANTS = new Set(['neutral', 'info', 'success', 'warning', 'error']);
733
+
734
+ function _NormalizeBadgesSpec(_spec) {
735
+ let items = (_spec?.items ?? []).map((_item) => {
736
+ if (_item == null) return null;
737
+ if (typeof _item === 'object') {
738
+ let variant = String(_item.variant ?? 'neutral').toLowerCase();
739
+ return { label: String(_item.label ?? _item.value ?? ''), variant: BADGE_VARIANTS.has(variant) ? variant : 'neutral' };
740
+ }
741
+ return { label: String(_item), variant: 'neutral' };
742
+ }).filter((_item) => _item && _item.label);
743
+ return { title: _spec?.title != null ? String(_spec.title) : '', items };
744
+ }
745
+
746
+ function _NormalizeCodeSpec(_spec) {
747
+ return {
748
+ title: _spec?.title != null ? String(_spec.title) : '',
749
+ language: _spec?.language != null ? String(_spec.language) : '',
750
+ code: String(_spec?.code ?? _spec?.text ?? ''),
751
+ };
752
+ }
753
+
754
+ function _NormalizeChartSpec(_spec) {
755
+ let series = (_spec?.series ?? _spec?.data ?? []).map((_entry) => {
756
+ if (_entry == null) return null;
757
+ if (typeof _entry === 'object' && !Array.isArray(_entry)) {
758
+ return {
759
+ label: String(_entry.label ?? ''),
760
+ value: Number(_entry.value) || 0,
761
+ color: _entry.color != null ? String(_entry.color) : '',
762
+ };
763
+ }
764
+ if (Array.isArray(_entry)) return { label: String(_entry[0] ?? ''), value: Number(_entry[1]) || 0, color: '' };
765
+ return { label: '', value: Number(_entry) || 0, color: '' };
766
+ }).filter(Boolean);
767
+ let explicitMax = Number(_spec?.max);
768
+ let dataMax = series.reduce((_max, _entry) => Math.max(_max, _entry.value), 0);
769
+ return {
770
+ title: _spec?.title != null ? String(_spec.title) : '',
771
+ type: 'bar',
772
+ unit: _spec?.unit != null ? String(_spec.unit) : '',
773
+ max: Number.isFinite(explicitMax) && explicitMax > 0 ? explicitMax : (dataMax || 1),
774
+ series,
775
+ };
776
+ }
777
+
778
+ function _AsciiChart(_payload) {
779
+ let lines = [];
780
+ if (_payload.title) lines.push(_payload.title);
781
+ let labelWidth = _payload.series.reduce((_max, _entry) => Math.max(_max, _entry.label.length), 0);
782
+ const BAR_WIDTH = 24;
783
+ for (let entry of _payload.series) {
784
+ let filled = _payload.max > 0 ? Math.round((entry.value / _payload.max) * BAR_WIDTH) : 0;
785
+ let bar = '\u2588'.repeat(filled) + '\u00b7'.repeat(Math.max(0, BAR_WIDTH - filled));
786
+ lines.push(' ' + entry.label.padEnd(labelWidth) + ' ' + bar + ' ' + entry.value + (_payload.unit ? ' ' + _payload.unit : ''));
787
+ }
788
+ return lines;
789
+ }
790
+
791
+ /**
792
+ * Asks µGulp to re-evaluate dynamic task metadata (µEnabled predicates,
793
+ * disabled tooltips) and refresh the dashboard task list. Call after
794
+ * changing module-level state that drives enable/disable rules.
795
+ * No-op outside a µGulp worker.
796
+ */
797
+ export function NotifyTasksChanged() {
798
+ if (typeof process.send === 'function') {
799
+ process.send({ type: 'refresh-tasks' });
800
+ }
801
+ }
802
+
803
+ export default {
804
+ IsMicroGulp,
805
+ IsµGulp,
806
+ NotifyTasksChanged,
807
+ ReportProgress,
808
+ CreateProgress,
809
+ PlaySignal,
810
+ PlaySound,
811
+ StopSound,
812
+ Speak,
813
+ RequestForm,
814
+ RequestTextInput,
815
+ RequestColorInput,
816
+ RequestFontInput,
817
+ RequestSelectInput,
818
+ RequestMultiSelectInput,
819
+ Log: I18x.Log,
820
+ Warn: I18x.Warn,
821
+ LogError: I18x.LogError,
822
+ Translate: I18x.Translate,
823
+ GetLid: I18x.GetLid,
824
+ SetLid: I18x.SetLid,
825
+ GetTimeZone: I18x.GetTimeZone,
826
+ DateInTimeZone: I18x.DateInTimeZone,
827
+ TimeZoneOffset: I18x.TimeZoneOffset,
828
+ InstallStringExtensions: I18x.InstallStringExtensions,
829
+ InstallFormatPrototypes: I18x.InstallFormatPrototypes,
830
+ InstallHyphenPrototypes: I18x.InstallHyphenPrototypes,
831
+ LogTable,
832
+ LogTree,
833
+ LogGallery,
834
+ LogAssetPreviews,
835
+ LogBuildDebugReport,
836
+ LogCallout,
837
+ ShowModalMessage,
838
+ ShowConfirmMessage,
839
+ LogKeyValue,
840
+ LogBadges,
841
+ LogCode,
842
+ LogChart,
843
+ };