gulp-mu-gulp-api 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,25 @@ The module is deliberately **dependency-free** and knows nothing about µGulp™
32
32
  npm install gulp-mu-gulp-api
33
33
  ```
34
34
 
35
+ ### 0.3.8
36
+
37
+ - **Added** structured log elements rendered directly in the run log (JSON payloads, Content-Security-Policy safe, no plugin/iframe needed): `LogCallout()` (info/success/warning/error box), `LogKeyValue()` (metrics/summary list), `LogBadges()` (status chips), `LogCode()` (monospace code block) and `LogChart()` (inline-SVG bar chart).
38
+ - Each new helper degrades to a readable stdout fallback when running outside µGulp (bracketed callout, aligned key/value lines, bracketed badges, verbatim code, ASCII bar chart).
39
+
40
+ ### 0.3.7
41
+
42
+ - **Added** engine date/time placeholders: `weekofyear`, `dayofyear`, `utcweekofyear`, `utcdayofyear`, `timezoneoffset`, `timezoneshort`, `timezoneplace`.
43
+ - **Added** `TimeZoneOffset()` — JavaScript-compatible offset in minutes (same sign as `Date.getTimezoneOffset()`).
44
+ - **Added** hyphenation API: `WordHyphenation()`, `Hyphenation()`, `LoadHyphenData()`, `InstallHyphenPrototypes()` with µLib (`WordHyphenation`/`Hyphenation`) and i18xe-sync (`wordHyphenation`/`hyphenation`) aliases; without `<lid>.hyphen.json` data the methods no-op.
45
+ - **Added** i18xe-sync lowercase format aliases: `Number.prototype.format()` / `.formatTimestamp()` / `.formatTicks()`, `Date.prototype.format()`.
46
+ - **Changed** `Translate(text, values?, timeZone?)` and `.i18xTrans(values?, timeZone?)` accept an optional IANA timezone override.
47
+
48
+ ### 0.3.6
49
+
50
+ - **Added** full eval-free i18x engine (`i18x-engine.mjs`): `FormatValue()`/`Translate()` now honor the complete i18x notation — `if`/`ifnot`/`ifin` conditions, nested formats, `expression="…"`, enumerations, digit fill/padding, date/time parts and roman numerals.
51
+ - **Added** µLib-compatible `Number.prototype.Format()`, `Number.prototype.FormatTimestamp()`, `Number.prototype.FormatTicks()`, `Date.prototype.Format()` and `String.prototype.Format()` (installed automatically on import; see `InstallFormatPrototypes()`).
52
+ - **Changed** `FormatValue(value, format, lid?, timeZone?)` gained the optional `timeZone` argument; date/time formats interpret the value as Unix milliseconds.
53
+
35
54
  ### 0.3.5
36
55
 
37
56
  - **Added** `PlaySound(sample, { loop? })` and `StopSound(sample)` — play named samples from the dashboard µAU sound atlas; `loop: true` repeats until `StopSound`.
@@ -113,9 +132,12 @@ let values = await RequestForm({
113
132
  | Function | Purpose |
114
133
  | :--- | :--- |
115
134
  | `IsMicroGulp()` / `IsµGulp()` | `true` when the task runs under µGulp™ with an attached dashboard |
116
- | `InstallStringExtensions()` | Installs µLib-compatible `String.prototype.i18xTrans()`/`i18xRegister()` (auto-run on import) |
135
+ | `InstallStringExtensions()` | Installs µLib-compatible `String.prototype.i18xTrans()`/`i18xRegister()` and the Format prototypes (auto-run on import) |
136
+ | `InstallFormatPrototypes()` | Installs `Number`/`Date`/`String.prototype.Format()` (auto-run on import) |
117
137
  | `Log(text, values?)` / `Warn` / `LogError` | Localized `console.*` via i18x (`<context="task log"/>` etc.) — preferred for task log output |
118
138
  | `Translate(text, values?)` | Low-level: returns the translated string without logging (rarely needed directly) |
139
+ | `FormatValue(value, format, lid?, timeZone?)` | Formats a number/date with a named i18x format (`int`, `floatFix2`, `byteSize`, `stdDateTime`, …) |
140
+ | `(1536).Format('byteSize')` | Prototype sugar for `FormatValue`; `.FormatTimestamp()`/`.FormatTicks()` on numbers, `.Format()` on dates |
119
141
  | `"phrase".i18xTrans(values?)` | Prototype sugar for `Translate` — same result, ergonomic in expressions |
120
142
  | `"phrase".i18xRegister()` | Registers metadata text (returns the source phrase unchanged as the i18x key) |
121
143
  | `GetLid()` / `SetLid(lid?)` | Active language id; `SetLid` clears to automatic when omitted |
@@ -239,6 +261,25 @@ Das Modul ist bewusst **abhängigkeitsfrei** und kennt keine µGulp™-Interna:
239
261
  npm install gulp-mu-gulp-api
240
262
  ```
241
263
 
264
+ ### 0.3.8
265
+
266
+ - **Neu** strukturierte Log-Elemente, die direkt im Run-Log gerendert werden (JSON-Payloads, Content-Security-Policy-sicher, ohne Plugin/iframe): `LogCallout()` (Info/Success/Warning/Error-Box), `LogKeyValue()` (Metriken/Zusammenfassung), `LogBadges()` (Status-Chips), `LogCode()` (Monospace-Codeblock) und `LogChart()` (Inline-SVG-Balkendiagramm).
267
+ - Jeder neue Helfer fällt außerhalb von µGulp auf eine lesbare stdout-Ausgabe zurück (Callout in Klammern, ausgerichtete Key/Value-Zeilen, Badges in Klammern, Code wortgetreu, ASCII-Balkendiagramm).
268
+
269
+ ### 0.3.7
270
+
271
+ - **Neu** Engine-Datum/Zeit-Platzhalter: `weekofyear`, `dayofyear`, `utcweekofyear`, `utcdayofyear`, `timezoneoffset`, `timezoneshort`, `timezoneplace`.
272
+ - **Neu** `TimeZoneOffset()` — JavaScript-kompatibler Offset in Minuten (gleiches Vorzeichen wie `Date.getTimezoneOffset()`).
273
+ - **Neu** Silbentrennungs-API: `WordHyphenation()`, `Hyphenation()`, `LoadHyphenData()`, `InstallHyphenPrototypes()` mit µLib- (`WordHyphenation`/`Hyphenation`) und i18xe-sync-Aliasen (`wordHyphenation`/`hyphenation`); ohne `<lid>.hyphen.json`-Daten No-op.
274
+ - **Neu** i18xe-sync-Lowercase-Format-Aliase: `Number.prototype.format()` / `.formatTimestamp()` / `.formatTicks()`, `Date.prototype.format()`.
275
+ - **Geändert** `Translate(text, values?, timeZone?)` und `.i18xTrans(values?, timeZone?)` akzeptieren optional eine IANA-Zeitzone.
276
+
277
+ ### 0.3.6
278
+
279
+ - **Neu** vollständige eval-freie i18x-Engine (`i18x-engine.mjs`): `FormatValue()`/`Translate()` beherrschen jetzt die komplette i18x-Notation — `if`/`ifnot`/`ifin`-Bedingungen, verschachtelte Formate, `expression="…"`, Enumerationen, Ziffern-Fill/Padding, Datum/Zeit-Teile und römische Zahlen.
280
+ - **Neu** µLib-kompatible `Number.prototype.Format()`, `Number.prototype.FormatTimestamp()`, `Number.prototype.FormatTicks()`, `Date.prototype.Format()` und `String.prototype.Format()` (werden beim Import automatisch installiert; siehe `InstallFormatPrototypes()`).
281
+ - **Geändert** `FormatValue(value, format, lid?, timeZone?)` um das optionale `timeZone`-Argument erweitert; Datum/Zeit-Formate interpretieren den Wert als Unix-Millisekunden.
282
+
242
283
  ### 0.3.5
243
284
 
244
285
  - **Neu** `PlaySound(sample, { loop? })` und `StopSound(sample)` — benannte Samples aus dem Dashboard-µAU-Sound-Atlas; `loop: true` wiederholt bis `StopSound`.
@@ -320,7 +361,10 @@ let values = await RequestForm({
320
361
  | Funktion | Zweck |
321
362
  | :--- | :--- |
322
363
  | `IsMicroGulp()` / `IsµGulp()` | `true`, wenn der Task unter µGulp™ mit angebundenem Dashboard läuft |
323
- | `InstallStringExtensions()` | Installiert µLib-kompatible `String.prototype.i18xTrans()`/`i18xRegister()` (läuft beim Import automatisch) |
364
+ | `InstallStringExtensions()` | Installiert µLib-kompatible `String.prototype.i18xTrans()`/`i18xRegister()` und die Format-Prototypes (läuft beim Import automatisch) |
365
+ | `InstallFormatPrototypes()` | Installiert `Number`/`Date`/`String.prototype.Format()` (läuft beim Import automatisch) |
366
+ | `FormatValue(value, format, lid?, timeZone?)` | Formatiert Zahl/Datum mit einem benannten i18x-Format (`int`, `floatFix2`, `byteSize`, `stdDateTime`, …) |
367
+ | `(1536).Format('byteSize')` | Prototype-Kurzform für `FormatValue`; `.FormatTimestamp()`/`.FormatTicks()` an Zahlen, `.Format()` an Dates |
324
368
  | `Log(text, values?)` / `Warn` / `LogError` | Lokalisierte `console.*`-Ausgabe über i18x — bevorzugt für Task-Logs |
325
369
  | `Translate(text, values?)` | Low-Level: übersetzter String ohne Log (selten direkt nötig) |
326
370
  | `"phrase".i18xTrans(values?)` | Prototype-Kurzform für `Translate` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gulp-mu-gulp-api",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Public task API for the µGulp orchestrator: progress reporting, sound signals, speech output, localized console output (i18x) and interactive UI inputs (text, password, number, textarea, color, font, select, radio, multi-select checkbox, range slider, date/time, file) from within gulp tasks — with graceful CLI fallbacks when running without µGulp.",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
@@ -243,7 +243,7 @@ export function ParseFormatDef(_def) {
243
243
  attrs,
244
244
  xml,
245
245
  maxes: _ScanDigitMaxes(xml),
246
- hasDateTags: /<\/?(?:utc)?(?:day|month|year|hour|minute|second|millisecond|weekday|weekofyear|dayofyear|timezone)[\s/>]/.test(xml),
246
+ hasDateTags: /<\/?(?:utc)?(?:day|month|year|hour|minute|second|millisecond|weekday|weekofyear|dayofyear|timezone(?:offset|short|place)?|summertimeoffset)[\s/>]/.test(xml),
247
247
  };
248
248
  }
249
249
 
@@ -401,19 +401,80 @@ function _WallClockParts(_timestampMs, _timeZone) {
401
401
  };
402
402
  }
403
403
 
404
+ /** ISO-8601 week number (1..53) for a calendar date (month is 0-based). */
405
+ function _IsoWeekOfYear(_year, _month, _day) {
406
+ let d = new Date(Date.UTC(_year, _month, _day));
407
+ let dayOfWeek = d.getUTCDay();
408
+ if (dayOfWeek === 0) dayOfWeek = 7;
409
+ d.setUTCDate(d.getUTCDate() + 4 - dayOfWeek);
410
+ let yearStart = Date.UTC(d.getUTCFullYear(), 0, 1);
411
+ return Math.ceil(((d.getTime() - yearStart) / 86400000 + 1) / 7);
412
+ }
413
+
414
+ /** Day of year (1..366) for a calendar date (month is 0-based). */
415
+ function _DayOfYear(_year, _month, _day) {
416
+ let start = Date.UTC(_year, 0, 1);
417
+ let current = Date.UTC(_year, _month, _day);
418
+ return Math.floor((current - start) / 86400000) + 1;
419
+ }
420
+
421
+ /** JavaScript-compatible getTimezoneOffset (minutes to add to local time to get UTC). */
422
+ function _TimeZoneOffsetJsMinutes(_ms, _timeZone) {
423
+ let ms = Number(_ms) || 0;
424
+ let local = _WallClockParts(ms, _timeZone);
425
+ let utcDate = new Date(ms);
426
+ let wallAsUTC = Date.UTC(
427
+ local.year, local.month, local.day,
428
+ local.hour, local.minute, local.second, utcDate.getUTCMilliseconds(),
429
+ );
430
+ return Math.round((ms - wallAsUTC) / 60000);
431
+ }
432
+
433
+ const TIME_ZONE_SHORT_FORMATTERS = new Map();
434
+
435
+ /** Best-effort timezone abbreviation via Intl (no TZNames database). */
436
+ function _TimeZoneShort(_ms, _timeZone, _lid) {
437
+ let key = (_lid || 'en-US') + '\0' + (_timeZone || 'UTC');
438
+ let formatter = TIME_ZONE_SHORT_FORMATTERS.get(key);
439
+ if (!formatter) {
440
+ try {
441
+ formatter = new Intl.DateTimeFormat(_lid || 'en-US', { timeZone: _timeZone || 'UTC', timeZoneName: 'short' });
442
+ } catch {
443
+ formatter = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', timeZoneName: 'short' });
444
+ }
445
+ TIME_ZONE_SHORT_FORMATTERS.set(key, formatter);
446
+ }
447
+ try {
448
+ for (let part of formatter.formatToParts(new Date(Number(_ms) || 0))) {
449
+ if (part.type === 'timeZoneName') return part.value;
450
+ }
451
+ } catch {
452
+ // Invalid timezone — leave empty.
453
+ }
454
+ return '';
455
+ }
456
+
404
457
  /** Decomposes a timestamp into date/time placeholders (local tz + utc variants). */
405
- function _DatePlaceholders(_value, _timeZone) {
458
+ function _DatePlaceholders(_value, _timeZone, _lid) {
406
459
  let ms = Number(_value) || 0;
407
- let local = _WallClockParts(ms, _timeZone);
460
+ let tz = _timeZone || 'UTC';
461
+ let local = _WallClockParts(ms, tz);
408
462
  let utcDate = new Date(ms);
409
463
  return {
410
464
  day: local.day, month: local.month, year: local.year,
411
465
  hour: local.hour, minute: local.minute, second: local.second,
412
466
  weekday: local.weekday, millisecond: utcDate.getUTCMilliseconds(),
413
- timezone: _timeZone || 'UTC',
467
+ weekofyear: _IsoWeekOfYear(local.year, local.month, local.day),
468
+ dayofyear: _DayOfYear(local.year, local.month, local.day),
469
+ timezone: tz,
470
+ timezoneoffset: _TimeZoneOffsetJsMinutes(ms, tz),
471
+ timezoneshort: _TimeZoneShort(ms, tz, _lid),
472
+ timezoneplace: tz,
414
473
  utcday: utcDate.getUTCDate(), utcmonth: utcDate.getUTCMonth(), utcyear: utcDate.getUTCFullYear(),
415
474
  utchour: utcDate.getUTCHours(), utcminute: utcDate.getUTCMinutes(), utcsecond: utcDate.getUTCSeconds(),
416
475
  utcweekday: utcDate.getUTCDay(), utcmillisecond: utcDate.getUTCMilliseconds(),
476
+ utcweekofyear: _IsoWeekOfYear(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate()),
477
+ utcdayofyear: _DayOfYear(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate()),
417
478
  };
418
479
  }
419
480
 
@@ -432,7 +493,7 @@ export function FormatValue(_value, _formatName, _ctx) {
432
493
  let def = _ResolveFormat(_formatName, _ctx);
433
494
  if (!def) return String(_value);
434
495
  let placeholders = _NumberPlaceholders(_value, def);
435
- if (def.hasDateTags) Object.assign(placeholders, _DatePlaceholders(_value, _ctx.timeZone), { x: Number(_value) || 0 });
496
+ if (def.hasDateTags) Object.assign(placeholders, _DatePlaceholders(_value, _ctx.timeZone, _ctx.lid), { x: Number(_value) || 0 });
436
497
  return Trans(def.xml, placeholders, _ctx);
437
498
  }
438
499
 
@@ -0,0 +1,166 @@
1
+ // ===========================================
2
+ // i18x-hyphen.mjs — TeX-style hyphenation (µLib / i18xe-compatible)
3
+ // © 2026 Meinolf Amekudzi
4
+ // ===========================================
5
+
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ const HYPHEN_CHAR = '\xAD'; // U+00AD soft hyphen
10
+ const hyphenData = {};
11
+ const hyphenCache = {};
12
+
13
+ /** Directory holding hyphen rule files: env override or `<cwd>/i18x/prod`. */
14
+ function _HyphenDir() {
15
+ return process.env.MICROGULP_I18X_HYPHEN_DIR || join(process.cwd(), 'i18x', 'prod');
16
+ }
17
+
18
+ /**
19
+ * Loads hyphen rules for a localization id from `<dir>/<lid>.hyphen.json`.
20
+ * Missing or broken files yield a no-op catalog (`{ exist: false }`).
21
+ * @param {string} _lid
22
+ * @returns {object}
23
+ */
24
+ export function LoadHyphenData(_lid) {
25
+ if (hyphenData[_lid]) return hyphenData[_lid];
26
+ let data = { exist: false };
27
+ try {
28
+ let filePath = join(_HyphenDir(), _lid + '.hyphen.json');
29
+ if (existsSync(filePath)) data = JSON.parse(readFileSync(filePath, 'utf8'));
30
+ } catch {
31
+ // Malformed hyphen data must not break the build — fall back to no-op.
32
+ }
33
+ if (!data || typeof data !== 'object') data = { exist: false };
34
+ hyphenData[_lid] = data;
35
+ hyphenCache[_lid] = {};
36
+ return data;
37
+ }
38
+
39
+ /**
40
+ * Hyphenates a single word; returns the original when no rules exist.
41
+ * @param {string} _word
42
+ * @param {string} _lid
43
+ * @returns {string}
44
+ */
45
+ export function WordHyphenation(_word, _lid) {
46
+ let word = String(_word);
47
+ let data = LoadHyphenData(_lid);
48
+ if (!data.exist) return word;
49
+ if (!hyphenCache[_lid]) hyphenCache[_lid] = {};
50
+ if (hyphenCache[_lid][word]) return hyphenCache[_lid][word];
51
+
52
+ let lower = word.toLowerCase();
53
+ let ret = word;
54
+
55
+ if (!data.exceptions?.[lower]) {
56
+ let sylbs = data.sylbs;
57
+ let minSylbs = data.minSylbs;
58
+ let maxSylbs = data.maxSylbs;
59
+ let l = word.length + 1;
60
+ let myword = '.' + lower;
61
+ let vals = new Array(l + 1).fill(0);
62
+ for (let i = 0; i < l; i++) {
63
+ let sylb = '';
64
+ for (let j = 0; (i + j) < l; j++) {
65
+ sylb += myword.charAt(i + j);
66
+ if (j >= minSylbs && j <= maxSylbs) {
67
+ if (Object.prototype.hasOwnProperty.call(sylbs, sylb)) {
68
+ let sylbVals = sylbs[sylb];
69
+ let sl = sylb.length;
70
+ for (let k = -1; k <= sl; k++) vals[i + k] = Math.max(sylbVals[k + 1], vals[i + k]);
71
+ }
72
+ } else if (j > maxSylbs) break;
73
+ }
74
+ }
75
+ ret = '';
76
+ let k = l - 3;
77
+ let m = 2;
78
+ l--;
79
+ for (let i = 0; i < l; i++) {
80
+ if ((vals[i] % 2) === 1 && i > m && i < k) ret += HYPHEN_CHAR;
81
+ ret += word.charAt(i);
82
+ }
83
+ } else {
84
+ let w = data.exceptions[lower];
85
+ let j = 0;
86
+ for (let i = 0; i < w.length; i++) {
87
+ if (w.charAt(i) === HYPHEN_CHAR) {
88
+ ret += word.charAt(j++);
89
+ } else {
90
+ ret += w.charAt(i);
91
+ }
92
+ }
93
+ }
94
+
95
+ hyphenCache[_lid][word] = ret;
96
+ return ret;
97
+ }
98
+
99
+ /**
100
+ * Hyphenates every word in a text; preserves HTML/XML tags.
101
+ * @param {string} _text
102
+ * @param {string} _lid
103
+ * @returns {string}
104
+ */
105
+ export function Hyphenation(_text, _lid) {
106
+ let txts = String(_text);
107
+ let ret = [];
108
+
109
+ if (txts.indexOf('<') >= 0 && txts.indexOf('>') >= 0) {
110
+ let tags = {};
111
+ let matches = txts.match(/(<[^>]+>)/g);
112
+ if (matches != null) {
113
+ for (let i = 0; i < matches.length; i++) {
114
+ tags[matches[i]] = '\x7F' + i + '\x7F';
115
+ txts = txts.replace(matches[i], tags[matches[i]]);
116
+ }
117
+ }
118
+ let words = txts.split(' ');
119
+ for (let i = 0; i < words.length; i++) {
120
+ let t = words[i].trim();
121
+ ret.push(t ? WordHyphenation(t, _lid) : t);
122
+ }
123
+ txts = ret.join(' ');
124
+ if (matches != null) {
125
+ for (let i = 0; i < matches.length; i++) txts = txts.replace(tags[matches[i]], matches[i]);
126
+ }
127
+ return txts;
128
+ }
129
+
130
+ let words = txts.split(' ');
131
+ for (let i = 0; i < words.length; i++) {
132
+ let t = words[i].trim();
133
+ ret.push(t ? WordHyphenation(t, _lid) : t);
134
+ }
135
+ return ret.join(' ');
136
+ }
137
+
138
+ /** @param {string} _target @param {string} _name @param {Function} _value */
139
+ function _DefinePrototype(_target, _name, _value) {
140
+ if (typeof _target[_name] !== 'function') {
141
+ Object.defineProperty(_target, _name, { value: _value, writable: true, configurable: true });
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Installs µLib- and i18xe-compatible String hyphenation prototypes.
147
+ * @param {function(): string} [_resolveLid] defaults to empty string when omitted
148
+ */
149
+ export function InstallHyphenPrototypes(_resolveLid) {
150
+ function _ResolveLid(_lid) {
151
+ if (_lid) return _lid;
152
+ try {
153
+ return _resolveLid?.() ?? '';
154
+ } catch {
155
+ return '';
156
+ }
157
+ }
158
+ let wordFn = function (_lid = '') { return WordHyphenation(String(this), _ResolveLid(_lid)); };
159
+ let textFn = function (_lid = '') { return Hyphenation(String(this), _ResolveLid(_lid)); };
160
+ _DefinePrototype(String.prototype, 'WordHyphenation', wordFn);
161
+ _DefinePrototype(String.prototype, 'Hyphenation', textFn);
162
+ _DefinePrototype(String.prototype, 'wordHyphenation', wordFn);
163
+ _DefinePrototype(String.prototype, 'hyphenation', textFn);
164
+ }
165
+
166
+ export default { HYPHEN_CHAR, LoadHyphenData, WordHyphenation, Hyphenation, InstallHyphenPrototypes };
package/src/i18x.mjs CHANGED
@@ -32,6 +32,7 @@
32
32
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
33
33
  import { join } from 'node:path';
34
34
  import { Trans as EngineTrans, FormatValue as EngineFormatValue, RegisterFormat } from './i18x-engine.mjs';
35
+ import { InstallHyphenPrototypes as _InstallHyphenPrototypes } from './i18x-hyphen.mjs';
35
36
 
36
37
  const DEFAULT_LID = 'en-US';
37
38
 
@@ -92,6 +93,22 @@ export function DateInTimeZone(_date, _timeZone) {
92
93
  }
93
94
  }
94
95
 
96
+ /**
97
+ * Offset in minutes (UTC minus wall-clock, same sign as Date.getTimezoneOffset).
98
+ * @param {Date|number} _date
99
+ * @param {string} [_timeZone]
100
+ * @returns {number}
101
+ */
102
+ export function TimeZoneOffset(_date, _timeZone) {
103
+ let date = _date instanceof Date ? _date : new Date(Number(_date) || 0);
104
+ let shifted = DateInTimeZone(date, _timeZone);
105
+ let wallAsUTC = Date.UTC(
106
+ shifted.getFullYear(), shifted.getMonth(), shifted.getDate(),
107
+ shifted.getHours(), shifted.getMinutes(), shifted.getSeconds(), shifted.getMilliseconds(),
108
+ );
109
+ return Math.round((date.getTime() - wallAsUTC) / 60000);
110
+ }
111
+
95
112
  /** Normalizes casing: language lower, 2-letter region upper, 4-letter script Title. */
96
113
  function _NormalizeLid(_lid) {
97
114
  if (!_lid) return '';
@@ -229,9 +246,9 @@ function _FormatRegistry(_lid) {
229
246
  }
230
247
 
231
248
  /** Assembles the engine render context (language, timezone, format registry). */
232
- function _RenderContext(_lid) {
249
+ function _RenderContext(_lid, _timeZone) {
233
250
  let lid = _lid ?? GetLid();
234
- return { lid, timeZone: GetTimeZone(), formats: _FormatRegistry(lid) };
251
+ return { lid, timeZone: _timeZone ?? GetTimeZone(), formats: _FormatRegistry(lid) };
235
252
  }
236
253
 
237
254
  /**
@@ -239,13 +256,15 @@ function _RenderContext(_lid) {
239
256
  * project's i18x/gulp dictionaries (synced from i18xe prod). Delegates to the
240
257
  * eval-free i18x engine, so the full i18x notation (fill/padding, `if`
241
258
  * conditions, nested formats, expressions, enumerations, date parts) is honored.
259
+ * Date/time formats interpret the value as milliseconds since the Unix epoch.
242
260
  * @param {number|string} _value
243
261
  * @param {string} _formatName e.g. 'int', 'floatFix2', 'byteSize', 'stdDateTime'
244
262
  * @param {string} [_lid]
263
+ * @param {string} [_timeZone] IANA timezone id (defaults to GetTimeZone())
245
264
  * @returns {string}
246
265
  */
247
- export function FormatValue(_value, _formatName, _lid) {
248
- let context = _RenderContext(_lid);
266
+ export function FormatValue(_value, _formatName, _lid, _timeZone) {
267
+ let context = _RenderContext(_lid, _timeZone);
249
268
  if (!Object.prototype.hasOwnProperty.call(context.formats, _formatName)) return String(_value);
250
269
  return EngineFormatValue(_value, _formatName, context);
251
270
  }
@@ -256,9 +275,10 @@ export function FormatValue(_value, _formatName, _lid) {
256
275
  * `if` conditions, …). Unknown phrases fall back to the en-US source text.
257
276
  * @param {string} _text source phrase, e.g. 'Build ready<context="task log"/>'
258
277
  * @param {object} [_values] placeholder values for `<name/>` tags
278
+ * @param {string} [_timeZone] IANA timezone id (defaults to GetTimeZone())
259
279
  * @returns {string}
260
280
  */
261
- export function Translate(_text, _values) {
281
+ export function Translate(_text, _values, _timeZone) {
262
282
  if (_text == null) return '';
263
283
  let source = String(_text);
264
284
  let lid = GetLid();
@@ -266,7 +286,7 @@ export function Translate(_text, _values) {
266
286
  let translated = (dictionary && Object.prototype.hasOwnProperty.call(dictionary, source)) ? dictionary[source] : source;
267
287
  // The context tag is part of the dictionary key only; strip it before rendering.
268
288
  translated = translated.replace(/<context=[^>]*>/g, '');
269
- return EngineTrans(translated, _values ?? {}, _RenderContext(lid));
289
+ return EngineTrans(translated, _values ?? {}, _RenderContext(lid, _timeZone));
270
290
  }
271
291
 
272
292
  /**
@@ -308,7 +328,7 @@ export function LogError(_text, _values) {
308
328
  export function InstallStringExtensions() {
309
329
  if (typeof String.prototype.I18xTrans !== 'function') {
310
330
  Object.defineProperty(String.prototype, 'I18xTrans', {
311
- value: function (_values) { return Translate(String(this), _values); },
331
+ value: function (_values, _timeZone) { return Translate(String(this), _values, _timeZone); },
312
332
  writable: true, configurable: true,
313
333
  });
314
334
  }
@@ -330,11 +350,73 @@ export function InstallStringExtensions() {
330
350
  writable: true, configurable: true,
331
351
  });
332
352
  }
353
+ InstallFormatPrototypes();
354
+ InstallHyphenPrototypes();
355
+ }
356
+
357
+ /**
358
+ * Installs String hyphenation prototypes (µLib + i18xe aliases). Called from
359
+ * InstallStringExtensions (auto-run on import); also exported for explicit control.
360
+ */
361
+ export function InstallHyphenPrototypes() {
362
+ _InstallHyphenPrototypes(GetLid);
363
+ }
364
+
365
+ // .NET ticks are 100-nanosecond intervals since 0001-01-01; this offset is the
366
+ // tick count at the Unix epoch (1970-01-01), so ms = (ticks - offset) / 10000.
367
+ const TICKS_EPOCH_OFFSET = 621355968000000000;
368
+ const TICKS_PER_MILLISECOND = 10000;
369
+
370
+ /** @param {string} _name @param {Function} _value install a prototype method only if absent (µLib wins) */
371
+ function _DefinePrototype(_target, _name, _value) {
372
+ if (typeof _target[_name] !== 'function') {
373
+ Object.defineProperty(_target, _name, { value: _value, writable: true, configurable: true });
374
+ }
375
+ }
376
+
377
+ /**
378
+ * µLib-compatible Number/Date/String `Format` prototypes for gulpfiles that
379
+ * prefer `(1536).Format('byteSize')` or `new Date().Format('stdDateTime')`
380
+ * over the FormatValue() helper. Date/time formats interpret their value as
381
+ * milliseconds since the Unix epoch (µGulp convention), except FormatTicks
382
+ * which takes .NET ticks. Never overwrites an implementation already provided
383
+ * by µLib (µLib wins when both are loaded). Called from InstallStringExtensions
384
+ * (auto-run on import); also exported for explicit control.
385
+ */
386
+ export function InstallFormatPrototypes() {
387
+ // Number.prototype.Format — number formats use the value directly, date
388
+ // formats interpret it as milliseconds since the Unix epoch.
389
+ _DefinePrototype(Number.prototype, 'Format', function (_format, _lid, _timeZone) {
390
+ return FormatValue(this.valueOf(), _format, _lid, _timeZone);
391
+ });
392
+ // Number.prototype.FormatTimestamp — an explicit Unix millisecond timestamp.
393
+ _DefinePrototype(Number.prototype, 'FormatTimestamp', function (_format, _lid, _timeZone) {
394
+ return FormatValue(this.valueOf(), _format, _lid, _timeZone);
395
+ });
396
+ // Number.prototype.FormatTicks — a .NET tick count, converted to ms first.
397
+ _DefinePrototype(Number.prototype, 'FormatTicks', function (_format, _lid, _timeZone) {
398
+ let ms = (this.valueOf() - TICKS_EPOCH_OFFSET) / TICKS_PER_MILLISECOND;
399
+ return FormatValue(ms, _format, _lid, _timeZone);
400
+ });
401
+ // i18xe-sync lowercase aliases.
402
+ _DefinePrototype(Number.prototype, 'format', Number.prototype.Format);
403
+ _DefinePrototype(Number.prototype, 'formatTimestamp', Number.prototype.FormatTimestamp);
404
+ _DefinePrototype(Number.prototype, 'formatTicks', Number.prototype.FormatTicks);
405
+ // Date.prototype.Format — formats via the date's millisecond timestamp.
406
+ _DefinePrototype(Date.prototype, 'Format', function (_format, _lid, _timeZone) {
407
+ return FormatValue(this.getTime(), _format, _lid, _timeZone);
408
+ });
409
+ _DefinePrototype(Date.prototype, 'format', Date.prototype.Format);
410
+ // String.prototype.Format — µLib parity: a string has no numeric format.
411
+ _DefinePrototype(String.prototype, 'Format', function () {
412
+ return this.valueOf();
413
+ });
333
414
  }
334
415
 
335
416
  InstallStringExtensions();
336
417
 
337
418
  export default {
338
- GetLid, SetLid, GetTimeZone, DateInTimeZone,
339
- Translate, FormatValue, Log, Warn, LogError, InstallStringExtensions,
419
+ GetLid, SetLid, GetTimeZone, DateInTimeZone, TimeZoneOffset,
420
+ Translate, FormatValue, Log, Warn, LogError,
421
+ InstallStringExtensions, InstallFormatPrototypes, InstallHyphenPrototypes,
340
422
  };
package/src/index.mjs CHANGED
@@ -30,7 +30,8 @@
30
30
 
31
31
  // Localized console output (i18x). Re-exported so tasks import everything
32
32
  // from one place: `import { Log, Warn, ReportProgress } from 'gulp-mu-gulp-api'`.
33
- export { Log, Warn, LogError, Translate, FormatValue, GetLid, SetLid, GetTimeZone, DateInTimeZone, InstallStringExtensions } from './i18x.mjs';
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';
34
35
  import * as I18x from './i18x.mjs';
35
36
 
36
37
  let uiRequestCounter = 0;
@@ -207,6 +208,77 @@ export function LogBuildDebugReport(_report, _options = {}) {
207
208
  });
208
209
  }
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
+
210
282
  // -------------------------------------------------
211
283
  // sound & speech
212
284
  // -------------------------------------------------
@@ -553,6 +625,98 @@ function _AsciiGallery(_payload) {
553
625
  return lines.join('\n');
554
626
  }
555
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
+
556
720
  export default {
557
721
  IsMicroGulp,
558
722
  IsµGulp,
@@ -576,10 +740,18 @@ export default {
576
740
  SetLid: I18x.SetLid,
577
741
  GetTimeZone: I18x.GetTimeZone,
578
742
  DateInTimeZone: I18x.DateInTimeZone,
743
+ TimeZoneOffset: I18x.TimeZoneOffset,
579
744
  InstallStringExtensions: I18x.InstallStringExtensions,
745
+ InstallFormatPrototypes: I18x.InstallFormatPrototypes,
746
+ InstallHyphenPrototypes: I18x.InstallHyphenPrototypes,
580
747
  LogTable,
581
748
  LogTree,
582
749
  LogGallery,
583
750
  LogAssetPreviews,
584
751
  LogBuildDebugReport,
752
+ LogCallout,
753
+ LogKeyValue,
754
+ LogBadges,
755
+ LogCode,
756
+ LogChart,
585
757
  };