gulp-mu-gulp-api 0.3.5 → 0.3.6

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
@@ -198,19 +198,30 @@ MIT — © 2026 Meinolf Amekudzi
198
198
 
199
199
  ### Publishing (maintainers)
200
200
 
201
- From the µGulp repo root (requires `npm login` and 2FA OTP when enabled):
201
+ npm accounts with 2FA reject `npm publish` with `EOTP` when `~/.npmrc` holds a
202
+ web-login session token (`npm login --auth-type=web`). The reliable fix is a
203
+ **Granular Access Token** with **Bypass 2FA** and **write** access to this package:
204
+
205
+ 1. npmjs.com → Access Tokens → Generate → Granular
206
+ 2. Permissions: **Read and write**, Packages: `gulp-mu-gulp-api` (or All packages)
207
+ 3. Enable **Bypass 2FA**, copy the token, then:
202
208
 
203
209
  ```powershell
204
- # PowerShell
205
- $env:NPM_OTP="123456"
206
- npm run publish:api
210
+ npm config set //registry.npmjs.org/:_authToken <TOKEN>
207
211
  ```
208
212
 
209
- ```bash
210
- NPM_OTP=123456 npm run publish:api
213
+ After that, publish without any OTP:
214
+
215
+ ```powershell
216
+ npm run publish:api
211
217
  ```
212
218
 
213
- Bump `gulp-mu-gulp-api/package.json` before publishing. The task runs the module tests first.
219
+ Bump `gulp-mu-gulp-api/package.json` before publishing. The task runs the module
220
+ tests first. As a one-off, a 2FA code still works via `NPM_OTP`:
221
+
222
+ ```powershell
223
+ $env:NPM_OTP="123456"; npm run publish:api
224
+ ```
214
225
 
215
226
  ---
216
227
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gulp-mu-gulp-api",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",
@@ -0,0 +1,636 @@
1
+ // ===========================================
2
+ // i18x-engine.mjs — eval-free i18x runtime core
3
+ // © 1996-2026 Meinolf Amekudzi
4
+ // (published under MIT license)
5
+ // ===========================================
6
+
7
+ /**
8
+ * Dependency-free, eval-free implementation of the i18x rendering engine.
9
+ *
10
+ * This is a faithful port of the canonical i18x runtime (µLib `i18x.mjs` and
11
+ * the i18xe C# `i18x.Trans`/`CreateFormatPlaceholders`), rebuilt as a pure
12
+ * interpreter so it runs unchanged in a Node process and inside a
13
+ * Content-Security-Policy-restricted webview (no `eval`, no `new Function`).
14
+ *
15
+ * The engine renders the i18x XML notation:
16
+ * - placeholder tags: `<name/>`, `<name format="int"/>`
17
+ * - conditionals: `<x if="0-">-</x>`, `<n if="1~">s</n>` (`if`/`ifnot`/`ifin`/`ifnotin`)
18
+ * - inline formats: `<format name="…" …>…</format>` (also nested)
19
+ * - value transforms: `expression="=round(x*100)/100"`
20
+ * - enumerations: `<hour enumeration="AM|PM"/>`
21
+ * - digit tags: `<i0/>`…`<iN/>` (integer), `<f0/>`… (fixed fraction),
22
+ * `<g0/>` (grouped fraction), `<r0/>` (roman), `<i/>`/`<f/>` (whole part)
23
+ * - date/time tags: `<day/>`, `<month/>` (0-based), `<year/>`, `<hour/>`, `<minute/>`,
24
+ * `<second/>`, `<weekday/>` and their `utc…` variants
25
+ * - entities: `<lt/>`, `<gt/>`, `<space/>`, `<nbsp/>`, `<br/>`, `<newline/>`, …
26
+ *
27
+ * NOTE: The `<m…/>` (zero-cut fraction) tags render empty here, mirroring the
28
+ * canonical µLib runtime. They are slated for a rework together with the i18x
29
+ * format catalog; do not rely on `<m…/>` output until then.
30
+ *
31
+ * @module gulp-mu-gulp-api/i18x-engine
32
+ */
33
+
34
+ // -------------------------------------------------
35
+ // expression evaluator (recursive descent, no eval)
36
+ // -------------------------------------------------
37
+
38
+ const EXPRESSION_FUNCTIONS = {
39
+ abs: (_a) => Math.abs(_a),
40
+ acos: (_a) => Math.acos(_a),
41
+ asin: (_a) => Math.asin(_a),
42
+ atan: (_a) => Math.atan(_a),
43
+ atan2: (_a, _b) => Math.atan2(_a, _b),
44
+ ceil: (_a) => Math.ceil(_a),
45
+ ceiling: (_a) => Math.ceil(_a),
46
+ cos: (_a) => Math.cos(_a),
47
+ cosh: (_a) => Math.cosh(_a),
48
+ exp: (_a) => Math.exp(_a),
49
+ fact: (_a) => {
50
+ let n = Math.floor(_a);
51
+ if (n < 0) return NaN;
52
+ let result = 1;
53
+ for (let k = 2; k <= n; k++) result *= k;
54
+ return result;
55
+ },
56
+ floor: (_a) => Math.floor(_a),
57
+ frac: (_a) => _a - Math.trunc(_a),
58
+ int: (_a) => Math.trunc(_a),
59
+ ln: (_a) => Math.log(_a),
60
+ log: (_a, _b) => (_b === undefined ? Math.log10(_a) : Math.log(_a) / Math.log(_b)),
61
+ max: (..._args) => Math.max(..._args),
62
+ min: (..._args) => Math.min(..._args),
63
+ pow: (_a, _b) => Math.pow(_a, _b),
64
+ round: (_a) => Math.round(_a),
65
+ sign: (_a) => Math.sign(_a),
66
+ sin: (_a) => Math.sin(_a),
67
+ sinh: (_a) => Math.sinh(_a),
68
+ sqr: (_a) => _a * _a,
69
+ sqrt: (_a) => Math.sqrt(_a),
70
+ tan: (_a) => Math.tan(_a),
71
+ tanh: (_a) => Math.tanh(_a),
72
+ trunc: (_a) => Math.trunc(_a),
73
+ };
74
+
75
+ const EXPRESSION_CACHE = new Map();
76
+
77
+ /** Tokenizes an i18x expression string into numbers, identifiers and operators. */
78
+ function _TokenizeExpression(_source) {
79
+ let tokens = [];
80
+ let index = 0;
81
+ let length = _source.length;
82
+ while (index < length) {
83
+ let ch = _source[index];
84
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
85
+ index++;
86
+ continue;
87
+ }
88
+ if ((ch >= '0' && ch <= '9') || ch === '.') {
89
+ let start = index;
90
+ while (index < length && ((_source[index] >= '0' && _source[index] <= '9') || _source[index] === '.')) index++;
91
+ tokens.push({ type: 'number', value: parseFloat(_source.slice(start, index)) });
92
+ continue;
93
+ }
94
+ if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_') {
95
+ let start = index;
96
+ while (index < length && ((_source[index] >= 'a' && _source[index] <= 'z') || (_source[index] >= 'A' && _source[index] <= 'Z') || (_source[index] >= '0' && _source[index] <= '9') || _source[index] === '_')) index++;
97
+ tokens.push({ type: 'ident', value: _source.slice(start, index) });
98
+ continue;
99
+ }
100
+ tokens.push({ type: 'op', value: ch });
101
+ index++;
102
+ }
103
+ return tokens;
104
+ }
105
+
106
+ /**
107
+ * Evaluates an i18x `expression="…"` value against the bound variable `x`.
108
+ * The leading `=` (if present) is stripped. Supports `+ - * / %`, unary minus,
109
+ * parentheses and the math functions listed in EXPRESSION_FUNCTIONS.
110
+ * @param {string} _source expression source (with or without leading `=`)
111
+ * @param {number} _x value bound to the variable `x`
112
+ * @returns {number}
113
+ */
114
+ export function EvalExpression(_source, _x) {
115
+ let source = String(_source);
116
+ if (source.charAt(0) === '=') source = source.slice(1);
117
+ let tokens = EXPRESSION_CACHE.get(source);
118
+ if (!tokens) {
119
+ tokens = _TokenizeExpression(source);
120
+ EXPRESSION_CACHE.set(source, tokens);
121
+ }
122
+ let position = 0;
123
+
124
+ function _Peek() { return tokens[position]; }
125
+ function _Next() { return tokens[position++]; }
126
+
127
+ function _ParsePrimary() {
128
+ let token = _Peek();
129
+ if (!token) return 0;
130
+ if (token.type === 'op' && token.value === '(') {
131
+ _Next();
132
+ let value = _ParseExpression();
133
+ if (_Peek() && _Peek().value === ')') _Next();
134
+ return value;
135
+ }
136
+ if (token.type === 'op' && token.value === '-') {
137
+ _Next();
138
+ return -_ParsePrimary();
139
+ }
140
+ if (token.type === 'op' && token.value === '+') {
141
+ _Next();
142
+ return _ParsePrimary();
143
+ }
144
+ if (token.type === 'number') {
145
+ _Next();
146
+ return token.value;
147
+ }
148
+ if (token.type === 'ident') {
149
+ _Next();
150
+ let name = token.value;
151
+ if (_Peek() && _Peek().value === '(') {
152
+ _Next();
153
+ let args = [];
154
+ if (!(_Peek() && _Peek().value === ')')) {
155
+ args.push(_ParseExpression());
156
+ while (_Peek() && _Peek().value === ',') {
157
+ _Next();
158
+ args.push(_ParseExpression());
159
+ }
160
+ }
161
+ if (_Peek() && _Peek().value === ')') _Next();
162
+ let fn = EXPRESSION_FUNCTIONS[name];
163
+ return fn ? Number(fn(...args)) : 0;
164
+ }
165
+ if (name === 'x') return Number(_x);
166
+ return 0;
167
+ }
168
+ _Next();
169
+ return 0;
170
+ }
171
+
172
+ function _ParseTerm() {
173
+ let value = _ParsePrimary();
174
+ while (_Peek() && _Peek().type === 'op' && (_Peek().value === '*' || _Peek().value === '/' || _Peek().value === '%')) {
175
+ let op = _Next().value;
176
+ let right = _ParsePrimary();
177
+ if (op === '*') value *= right;
178
+ else if (op === '/') value /= right;
179
+ else value %= right;
180
+ }
181
+ return value;
182
+ }
183
+
184
+ function _ParseExpression() {
185
+ let value = _ParseTerm();
186
+ while (_Peek() && _Peek().type === 'op' && (_Peek().value === '+' || _Peek().value === '-')) {
187
+ let op = _Next().value;
188
+ let right = _ParseTerm();
189
+ if (op === '+') value += right;
190
+ else value -= right;
191
+ }
192
+ return value;
193
+ }
194
+
195
+ let result = _ParseExpression();
196
+ return Number.isFinite(result) ? result : 0;
197
+ }
198
+
199
+ // -------------------------------------------------
200
+ // roman numerals
201
+ // -------------------------------------------------
202
+
203
+ const ROMAN_TABLE = [
204
+ [1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], [100, 'C'], [90, 'XC'],
205
+ [50, 'L'], [40, 'XL'], [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
206
+ ];
207
+
208
+ /** Converts a non-negative integer to its Roman-numeral string. */
209
+ export function ToRoman(_value) {
210
+ let n = Math.abs(Math.round(Number(_value) || 0));
211
+ let out = '';
212
+ for (let [amount, symbol] of ROMAN_TABLE) {
213
+ while (n >= amount) {
214
+ out += symbol;
215
+ n -= amount;
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
221
+ // -------------------------------------------------
222
+ // format definitions
223
+ // -------------------------------------------------
224
+
225
+ /**
226
+ * Parses a `<format …>…</format>` definition into a reusable descriptor:
227
+ * name, attribute map, inner XML body and the pre-scanned maximum digit index
228
+ * per digit class (needed for the fill/padding logic).
229
+ * @param {string} _def full `<format …>…</format>` source
230
+ * @returns {{ name: string, attrs: object, xml: string, maxes: object, flags: object, hasDateTags: boolean }}
231
+ */
232
+ export function ParseFormatDef(_def) {
233
+ let def = String(_def);
234
+ let start = def.indexOf('<format');
235
+ let headerEnd = def.indexOf('>', start);
236
+ let attrsText = def.slice(start + 7, headerEnd);
237
+ let bodyStart = headerEnd + 1;
238
+ let bodyEnd = def.lastIndexOf('</format>');
239
+ let xml = bodyEnd >= 0 ? def.slice(bodyStart, bodyEnd) : def.slice(bodyStart);
240
+ let attrs = _ParseAttributes(attrsText);
241
+ return {
242
+ name: attrs.name ?? '',
243
+ attrs,
244
+ xml,
245
+ maxes: _ScanDigitMaxes(xml),
246
+ hasDateTags: /<\/?(?:utc)?(?:day|month|year|hour|minute|second|millisecond|weekday|weekofyear|dayofyear|timezone)[\s/>]/.test(xml),
247
+ };
248
+ }
249
+
250
+ /** Parses an XML attribute list (`name="value" …`) into a plain object. */
251
+ function _ParseAttributes(_attrsText) {
252
+ let attrs = {};
253
+ let regex = /([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*"([^"]*)"/g;
254
+ let match;
255
+ while ((match = regex.exec(_attrsText)) !== null) attrs[match[1]] = match[2];
256
+ return attrs;
257
+ }
258
+
259
+ /** Pre-scans a format body for the highest `<iN/>`, `<fN/>`, `<gN/>`, `<rN/>` index. */
260
+ function _ScanDigitMaxes(_xml) {
261
+ let maxes = { i: 0, f: 0, g: 0, r: 0 };
262
+ let regex = /<([ifgr])(\d+)\s*\/?>/g;
263
+ let match;
264
+ while ((match = regex.exec(_xml)) !== null) {
265
+ let cls = match[1];
266
+ let idx = parseInt(match[2], 10);
267
+ if (idx + 1 > maxes[cls]) maxes[cls] = idx + 1;
268
+ }
269
+ return maxes;
270
+ }
271
+
272
+ /**
273
+ * Registers a `<format …>` definition into a name-keyed map.
274
+ * @param {Object<string, object>} _formats target map (mutated)
275
+ * @param {string} _def format source
276
+ * @param {boolean} [_onlyIfNotExists] keep an existing entry with the same name
277
+ */
278
+ export function RegisterFormat(_formats, _def, _onlyIfNotExists) {
279
+ let parsed = ParseFormatDef(_def);
280
+ if (!parsed.name) return;
281
+ if (_onlyIfNotExists && Object.prototype.hasOwnProperty.call(_formats, parsed.name)) return;
282
+ _formats[parsed.name] = parsed;
283
+ }
284
+
285
+ // -------------------------------------------------
286
+ // value decomposition (CreateFormatPlaceholders port)
287
+ // -------------------------------------------------
288
+
289
+ /** Builds the digit-fill array exactly as the canonical engine does. */
290
+ function _BuildFill(_count, _chr, _maxNo) {
291
+ let ret = [];
292
+ if (_maxNo > 0) {
293
+ let maxNo = _maxNo > _count ? _count : _maxNo;
294
+ for (let n = 0; n < maxNo; n++) ret.push(_chr);
295
+ for (let n = maxNo; n < _count; n++) ret.push('');
296
+ ret.reverse();
297
+ } else {
298
+ for (let n = 0; n < _count; n++) ret.push(_chr);
299
+ }
300
+ return ret;
301
+ }
302
+
303
+ /** Applies a `0=零|1=一`-style digit replacement map to a numeric string. */
304
+ function _ApplyDigitMap(_string, _digitsAttr) {
305
+ if (!_digitsAttr) return _string;
306
+ let map = {};
307
+ for (let pair of _digitsAttr.split('|')) {
308
+ let [key, value] = pair.split('=');
309
+ if (key !== undefined && value !== undefined) map[key] = value;
310
+ }
311
+ let out = '';
312
+ for (let ch of _string) out += Object.prototype.hasOwnProperty.call(map, ch) ? map[ch] : ch;
313
+ return out;
314
+ }
315
+
316
+ /**
317
+ * Decomposes a numeric value into the placeholder set consumed by a number
318
+ * format body: `x`, `xa`, whole-part `i`/`f`, indexed `i0…`, `f0…`, `g0…`,
319
+ * roman `r0…`.
320
+ */
321
+ function _NumberPlaceholders(_value, _def) {
322
+ let attrs = _def.attrs;
323
+ let base = parseInt(attrs.base ?? '10', 10) || 10;
324
+ let value = Number(_value) || 0;
325
+ if (attrs.expression) value = EvalExpression(attrs.expression, value);
326
+ let xa = Math.abs(value);
327
+ let numericString = base === 10 ? xa.toString() : Math.trunc(xa).toString(base);
328
+ numericString = _ApplyDigitMap(numericString, attrs.digits);
329
+ let parts = numericString.split('.');
330
+ let intString = parts[0] ?? '';
331
+ let fracString = parts[1] ?? '';
332
+
333
+ let placeholders = { x: value, xa, i: intString, f: fracString };
334
+
335
+ let iMax = _def.maxes.i;
336
+ if (iMax > 0) {
337
+ let fill = _BuildFill(iMax, attrs.ifillchr ?? '', parseInt(attrs.ifillmax ?? '0', 10) || 0);
338
+ let digits = intString.split('').reverse().concat(fill);
339
+ for (let n = 0; n < iMax; n++) placeholders['i' + n] = digits[n] ?? '';
340
+ }
341
+
342
+ let fMax = _def.maxes.f;
343
+ if (fMax > 0) {
344
+ let fill = _BuildFill(fMax, attrs.ffillchr ?? '', parseInt(attrs.ffillmax ?? '0', 10) || 0);
345
+ let digits = fracString.split('').concat(fill);
346
+ for (let n = 0; n < fMax; n++) placeholders['f' + n] = digits[n] ?? '';
347
+ }
348
+
349
+ let gMax = _def.maxes.g;
350
+ if (gMax > 0) {
351
+ let fill = _BuildFill(gMax, attrs.gfillchr ?? '', parseInt(attrs.gfillmax ?? '0', 10) || 0);
352
+ let digits = fracString.split('').concat(fill);
353
+ for (let n = 0; n < gMax; n++) placeholders['g' + n] = digits[n] ?? '';
354
+ }
355
+
356
+ let rMax = _def.maxes.r;
357
+ if (rMax > 0) {
358
+ let roman = ToRoman(value).split('').reverse();
359
+ let fill = _BuildFill(rMax, attrs.rfillchr ?? '', parseInt(attrs.rfillmax ?? '0', 10) || 0);
360
+ let digits = roman.concat(fill);
361
+ for (let n = 0; n < rMax; n++) placeholders['r' + n] = digits[n] ?? '';
362
+ }
363
+
364
+ return placeholders;
365
+ }
366
+
367
+ const WALL_CLOCK_FORMATTERS = new Map();
368
+
369
+ /** Extracts wall-clock components for a timestamp in a given IANA timezone. */
370
+ function _WallClockParts(_timestampMs, _timeZone) {
371
+ let tz = _timeZone || 'UTC';
372
+ let formatter = WALL_CLOCK_FORMATTERS.get(tz);
373
+ if (!formatter) {
374
+ try {
375
+ formatter = new Intl.DateTimeFormat('en-US', {
376
+ timeZone: tz, hour12: false, weekday: 'short',
377
+ year: 'numeric', month: '2-digit', day: '2-digit',
378
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
379
+ });
380
+ } catch {
381
+ formatter = new Intl.DateTimeFormat('en-US', {
382
+ hour12: false, weekday: 'short',
383
+ year: 'numeric', month: '2-digit', day: '2-digit',
384
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
385
+ });
386
+ }
387
+ WALL_CLOCK_FORMATTERS.set(tz, formatter);
388
+ }
389
+ let values = {};
390
+ for (let part of formatter.formatToParts(new Date(Number(_timestampMs) || 0))) values[part.type] = part.value;
391
+ let weekdayIndex = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }[values.weekday] ?? 0;
392
+ let hour = parseInt(values.hour, 10) % 24;
393
+ return {
394
+ day: parseInt(values.day, 10),
395
+ month: parseInt(values.month, 10) - 1,
396
+ year: parseInt(values.year, 10),
397
+ hour,
398
+ minute: parseInt(values.minute, 10),
399
+ second: parseInt(values.second, 10),
400
+ weekday: weekdayIndex,
401
+ };
402
+ }
403
+
404
+ /** Decomposes a timestamp into date/time placeholders (local tz + utc variants). */
405
+ function _DatePlaceholders(_value, _timeZone) {
406
+ let ms = Number(_value) || 0;
407
+ let local = _WallClockParts(ms, _timeZone);
408
+ let utcDate = new Date(ms);
409
+ return {
410
+ day: local.day, month: local.month, year: local.year,
411
+ hour: local.hour, minute: local.minute, second: local.second,
412
+ weekday: local.weekday, millisecond: utcDate.getUTCMilliseconds(),
413
+ timezone: _timeZone || 'UTC',
414
+ utcday: utcDate.getUTCDate(), utcmonth: utcDate.getUTCMonth(), utcyear: utcDate.getUTCFullYear(),
415
+ utchour: utcDate.getUTCHours(), utcminute: utcDate.getUTCMinutes(), utcsecond: utcDate.getUTCSeconds(),
416
+ utcweekday: utcDate.getUTCDay(), utcmillisecond: utcDate.getUTCMilliseconds(),
417
+ };
418
+ }
419
+
420
+ // -------------------------------------------------
421
+ // named-format rendering
422
+ // -------------------------------------------------
423
+
424
+ /**
425
+ * Formats a value with a named format definition.
426
+ * @param {number} _value
427
+ * @param {string} _formatName
428
+ * @param {object} _ctx render context `{ lid, timeZone, formats }`
429
+ * @returns {string}
430
+ */
431
+ export function FormatValue(_value, _formatName, _ctx) {
432
+ let def = _ResolveFormat(_formatName, _ctx);
433
+ if (!def) return String(_value);
434
+ let placeholders = _NumberPlaceholders(_value, def);
435
+ if (def.hasDateTags) Object.assign(placeholders, _DatePlaceholders(_value, _ctx.timeZone), { x: Number(_value) || 0 });
436
+ return Trans(def.xml, placeholders, _ctx);
437
+ }
438
+
439
+ /** Resolves a format by name from the local defs, then the context registry. */
440
+ function _ResolveFormat(_name, _ctx, _localFormats) {
441
+ if (_localFormats && Object.prototype.hasOwnProperty.call(_localFormats, _name)) return _localFormats[_name];
442
+ let formats = _ctx.formats;
443
+ if (formats && Object.prototype.hasOwnProperty.call(formats, _name)) return formats[_name];
444
+ return null;
445
+ }
446
+
447
+ // -------------------------------------------------
448
+ // entity tags
449
+ // -------------------------------------------------
450
+
451
+ const ENTITY_TAGS = {
452
+ lt: '<', gt: '>', obrc: '{', cbrc: '}', brl: '\u25C4', brr: '\u25BA',
453
+ br: '<br/>', newline: '\n', slashnewline: '\\n', quot: '"', quote: '"',
454
+ squot: "'", apos: "'", singlequote: "'", space: ' ', whitespace: ' ',
455
+ amp: '&', nbsp: '\u00A0', tab: '\t', hyphen: '\u00AD', '-': '\u00AD',
456
+ newpage: '\u21A1',
457
+ };
458
+
459
+ // -------------------------------------------------
460
+ // core interpreter (i18x.Trans port)
461
+ // -------------------------------------------------
462
+
463
+ /**
464
+ * Renders an i18x XML string against a placeholder set. This is the shared
465
+ * interpreter used both for translated phrases and for format bodies.
466
+ * @param {string} _text i18x XML source (already looked up / translated)
467
+ * @param {object} [_placeholders] values for `<name/>` tags
468
+ * @param {object} [_ctx] render context `{ lid, timeZone, formats }`
469
+ * @returns {string}
470
+ */
471
+ export function Trans(_text, _placeholders, _ctx) {
472
+ if (_text == null) return '';
473
+ let text = String(_text);
474
+ if (text.indexOf('<') === -1) return text;
475
+
476
+ let placeholders = _placeholders ?? {};
477
+ let ctx = _ctx ?? { lid: 'en-US', timeZone: 'UTC', formats: {} };
478
+ let localFormats = null;
479
+
480
+ let tokens = ('<i18x>' + text + '</i18x>').split('<');
481
+ let level = 0;
482
+ let txts = [''];
483
+ let visibles = [true];
484
+ let length = tokens.length;
485
+
486
+ for (let i = 0; i < length; i++) {
487
+ let token = tokens[i];
488
+ let closeIndex = token.indexOf('>');
489
+ if (closeIndex === -1) {
490
+ txts[level] += token;
491
+ continue;
492
+ }
493
+
494
+ let trailing = token.slice(closeIndex + 1);
495
+ let isClosingTag = token.charAt(0) === '/';
496
+ let isClosedTag = token.charAt(closeIndex - 1) === '/';
497
+ let sliceStart = 0;
498
+ let sliceEnd = closeIndex;
499
+ if (isClosedTag) sliceEnd -= 1;
500
+ if (isClosingTag) { sliceStart = 1; sliceEnd -= 1; }
501
+
502
+ let head = token.slice(sliceStart, sliceEnd);
503
+ let spaceIndex = head.indexOf(' ');
504
+ let tag = spaceIndex === -1 ? head : head.slice(0, spaceIndex);
505
+ let attrs = spaceIndex === -1 ? {} : _ParseAttributes(head.slice(spaceIndex + 1));
506
+
507
+ // inline <format> definitions: collect the raw block (honoring nesting),
508
+ // register it locally and skip over it without rendering its body.
509
+ if (tag === 'format' && !isClosingTag && !isClosedTag) {
510
+ let depth = 1;
511
+ let j = i;
512
+ while (depth > 0 && ++j < length) {
513
+ let inner = tokens[j];
514
+ let innerClose = inner.indexOf('>');
515
+ if (innerClose === -1) continue;
516
+ let innerClosing = inner.charAt(0) === '/';
517
+ let innerClosed = inner.charAt(innerClose - 1) === '/';
518
+ let innerHead = inner.slice(innerClosing ? 1 : 0, innerClose);
519
+ let innerSpace = innerHead.indexOf(' ');
520
+ let innerTag = innerSpace === -1 ? innerHead.replace(/\/$/, '') : innerHead.slice(0, innerSpace);
521
+ if (innerTag === 'format') {
522
+ if (innerClosing) depth--;
523
+ else if (!innerClosed) depth++;
524
+ }
525
+ }
526
+ let defSource = '<' + tokens.slice(i, j + 1).join('<');
527
+ if (!localFormats) localFormats = Object.create(null);
528
+ RegisterFormat(localFormats, defSource, true);
529
+ if (j < length) txts[level] += tokens[j].slice(tokens[j].indexOf('>') + 1);
530
+ i = j;
531
+ continue;
532
+ }
533
+
534
+ let value = '';
535
+ let doInsertValue = false;
536
+
537
+ if (isClosingTag) {
538
+ level--;
539
+ txts[level] += visibles[level + 1] ? txts[level + 1] : '';
540
+ txts[level] += trailing;
541
+ continue;
542
+ } else if (isClosedTag) {
543
+ doInsertValue = true;
544
+ } else {
545
+ level++;
546
+ txts[level] = '';
547
+ visibles[level] = true;
548
+ }
549
+
550
+ if (Object.prototype.hasOwnProperty.call(placeholders, tag)) {
551
+ let raw = placeholders[tag];
552
+ value = raw instanceof Date ? raw.getTime() : raw;
553
+ }
554
+ if (Object.prototype.hasOwnProperty.call(ENTITY_TAGS, tag)) value = ENTITY_TAGS[tag];
555
+
556
+ // process attributes in document order: value, expression, format, enumeration, if.
557
+ for (let attrKey of Object.keys(attrs)) {
558
+ let attrValue = attrs[attrKey];
559
+ if (attrKey === 'value') {
560
+ value = attrValue;
561
+ } else if (attrKey === 'expression') {
562
+ value = EvalExpression(attrValue, Number(value) || 0);
563
+ } else if (attrKey === 'format') {
564
+ let def = _ResolveFormat(attrValue, ctx, localFormats);
565
+ if (def) {
566
+ let subContext = localFormats ? { ...ctx, formats: { ...(ctx.formats ?? {}), ...localFormats } } : ctx;
567
+ value = FormatValue(Number(value) || 0, attrValue, subContext);
568
+ } else {
569
+ value = '';
570
+ }
571
+ } else if (attrKey === 'enumeration') {
572
+ let options = attrValue.split('|');
573
+ let index = parseInt(value, 10);
574
+ value = Number.isFinite(index) && index >= 0 && index < options.length ? options[index] : '';
575
+ } else if (attrKey === 'if' || attrKey === 'ifnot' || attrKey === 'ifin' || attrKey === 'ifnotin') {
576
+ visibles[level] = _EvaluateCondition(value, attrValue, attrKey);
577
+ }
578
+ }
579
+
580
+ txts[level] += (doInsertValue ? value : '') + trailing;
581
+ }
582
+
583
+ // flush any levels left open by unbalanced tags (e.g. the malformed roman format).
584
+ while (level > 0) {
585
+ txts[level - 1] += visibles[level] ? txts[level] : '';
586
+ level--;
587
+ }
588
+
589
+ return txts[0].replace(/\0/g, '');
590
+ }
591
+
592
+ /** Evaluates an `if`/`ifnot`/`ifin`/`ifnotin` condition against a value. */
593
+ function _EvaluateCondition(_value, _condition, _attrKey) {
594
+ let numeric = Number(_value);
595
+ let isNumber = _value !== '' && _value !== null && Number.isFinite(numeric);
596
+ let compareNumber = isNumber ? numeric : NaN;
597
+ let compareString = String(_value);
598
+ let isIn = _attrKey === 'ifin' || _attrKey === 'ifnotin';
599
+
600
+ let matched = false;
601
+ for (let clause of _condition.split('|')) {
602
+ let operator = clause.charAt(clause.length - 1);
603
+ let operand = clause;
604
+ if (operator === '+' || operator === '-' || operator === '=' || operator === '~' || operator === '*') {
605
+ operand = clause.slice(0, -1);
606
+ } else {
607
+ operator = '';
608
+ }
609
+ let operandNumber = Number(operand);
610
+ let operandIsNumber = operand !== '' && Number.isFinite(operandNumber);
611
+
612
+ if (isNumber && operandIsNumber) {
613
+ if (operator === '+') matched = matched || compareNumber > operandNumber;
614
+ else if (operator === '-') matched = matched || compareNumber < operandNumber;
615
+ else if (operator === '=') matched = matched || compareNumber === operandNumber;
616
+ else if (operator === '~') matched = matched || compareNumber !== operandNumber;
617
+ else if (operator === '*') matched = matched || compareString.indexOf(operand) >= 0;
618
+ else matched = matched || compareNumber === operandNumber;
619
+ } else {
620
+ if (operator === '+') matched = matched || compareString > operand;
621
+ else if (operator === '-') matched = matched || compareString < operand;
622
+ else if (operator === '=') matched = matched || compareString === operand;
623
+ else if (operator === '~') matched = matched || compareString !== operand;
624
+ else if (operator === '*') matched = matched || compareString.indexOf(operand) >= 0;
625
+ else if (isIn) matched = matched || compareString.indexOf(operand) >= 0;
626
+ else matched = matched || compareString === operand;
627
+ }
628
+ }
629
+ if (_attrKey === 'ifnot' || _attrKey === 'ifnotin') matched = !matched;
630
+ return matched;
631
+ }
632
+
633
+ /** Removes any remaining i18x/XML tags from a string (after placeholder substitution). */
634
+ export function StripTags(_text) {
635
+ return String(_text).replace(/<[^>]*>/g, '');
636
+ }
package/src/i18x.mjs CHANGED
@@ -31,6 +31,7 @@
31
31
 
32
32
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
33
33
  import { join } from 'node:path';
34
+ import { Trans as EngineTrans, FormatValue as EngineFormatValue, RegisterFormat } from './i18x-engine.mjs';
34
35
 
35
36
  const DEFAULT_LID = 'en-US';
36
37
 
@@ -51,6 +52,7 @@ const LIDS_MAIN_CULTURES = {
51
52
 
52
53
  let dictionaryCache = new Map();
53
54
  let formatDefinitionCache = new Map();
55
+ let formatRegistryCache = new Map();
54
56
  let availableLids = null;
55
57
  let activeLid = null;
56
58
  const timeZoneFormatters = {};
@@ -177,6 +179,7 @@ export function GetLid() {
177
179
  export function SetLid(_lid) {
178
180
  activeLid = _lid ? _BestAvailableLid(_lid, _AvailableLids()) : null;
179
181
  formatDefinitionCache.clear();
182
+ formatRegistryCache.clear();
180
183
  }
181
184
 
182
185
  /** Loads and caches `<dir>/<lid>.json`; a missing/broken file yields source text. */
@@ -215,90 +218,42 @@ function _LoadFormatDefinitions(_lid) {
215
218
  return formats;
216
219
  }
217
220
 
218
- /** Formats milliseconds as M:SS or H:MM:SS (progress bar durations). */
219
- function _FormatDuration(_ms) {
220
- let ms = Math.max(0, Number(_ms) || 0);
221
- let totalSeconds = Math.round(ms / 1000);
222
- let seconds = totalSeconds % 60;
223
- let minutes = Math.floor(totalSeconds / 60) % 60;
224
- let hours = Math.floor(totalSeconds / 3600);
225
- let pad = (_n) => String(_n).padStart(2, '0');
226
- return hours > 0 ? hours + ':' + pad(minutes) + ':' + pad(seconds) : minutes + ':' + pad(seconds);
221
+ /** Builds (and caches) the name→parsed-definition format registry for a language. */
222
+ function _FormatRegistry(_lid) {
223
+ if (formatRegistryCache.has(_lid)) return formatRegistryCache.get(_lid);
224
+ let registry = Object.create(null);
225
+ let definitions = _LoadFormatDefinitions(_lid);
226
+ for (let xml of Object.values(definitions)) RegisterFormat(registry, xml, false);
227
+ formatRegistryCache.set(_lid, registry);
228
+ return registry;
229
+ }
230
+
231
+ /** Assembles the engine render context (language, timezone, format registry). */
232
+ function _RenderContext(_lid) {
233
+ let lid = _lid ?? GetLid();
234
+ return { lid, timeZone: GetTimeZone(), formats: _FormatRegistry(lid) };
227
235
  }
228
236
 
229
237
  /**
230
- * Formats a numeric value with a named i18xe format definition from the
231
- * project's i18x/gulp dictionaries (synced from i18xe prod).
238
+ * Formats a numeric/date value with a named i18xe format definition from the
239
+ * project's i18x/gulp dictionaries (synced from i18xe prod). Delegates to the
240
+ * eval-free i18x engine, so the full i18x notation (fill/padding, `if`
241
+ * conditions, nested formats, expressions, enumerations, date parts) is honored.
232
242
  * @param {number|string} _value
233
- * @param {string} _formatName e.g. 'int', 'floatFix2', 'byteSize'
243
+ * @param {string} _formatName e.g. 'int', 'floatFix2', 'byteSize', 'stdDateTime'
234
244
  * @param {string} [_lid]
235
245
  * @returns {string}
236
246
  */
237
247
  export function FormatValue(_value, _formatName, _lid) {
238
- let lid = _lid ?? GetLid();
239
- let tz = GetTimeZone();
240
- if (_formatName === 'progressDuration') return _FormatDuration(Number(_value));
241
- if (_formatName === 'stdTime') {
242
- let date = new Date(Number(_value) || 0);
243
- try {
244
- return new Intl.DateTimeFormat(lid, { hour: '2-digit', minute: '2-digit', timeZone: tz }).format(date);
245
- } catch {
246
- return date.toISOString();
247
- }
248
- }
249
- if (_formatName === 'stdDateTime' || _formatName === 'fullDateTime') {
250
- let date = new Date(Number(_value) || 0);
251
- try {
252
- return new Intl.DateTimeFormat(lid, {
253
- year: 'numeric', month: '2-digit', day: '2-digit',
254
- hour: '2-digit', minute: '2-digit', timeZone: tz,
255
- }).format(date);
256
- } catch {
257
- return date.toISOString();
258
- }
259
- }
260
- if (_formatName === 'byteSize') {
261
- let bytes = Number(_value);
262
- if (!Number.isFinite(bytes)) return String(_value);
263
- if (bytes >= 1073741824) return FormatValue(bytes / 1073741824, 'floatFix2', lid) + ' GB';
264
- if (bytes >= 1048576) return FormatValue(bytes / 1048576, 'floatFix2', lid) + ' MB';
265
- if (bytes >= 1024) return FormatValue(bytes / 1024, 'floatFix2', lid) + ' KB';
266
- return FormatValue(bytes, 'int', lid) + ' Bytes';
267
- }
268
- let number = Number(_value);
269
- if (!Number.isFinite(number)) return String(_value);
270
- let definition = _LoadFormatDefinitions(lid)[_formatName];
271
- if (!definition) return String(_value);
272
- let groupSeparator = definition.match(/<xa if="1000\|1000\+">([^<]*)<\/xa>/)?.[1] ?? ',';
273
- let decimalSeparator = definition.match(/if="0~">([^<]*)<m0\/>/)?.[1]
274
- ?? definition.match(/<\/xa>([^<]*)<f0\/>/)?.[1] ?? '.';
275
- let fractionDigits = _formatName === 'floatFix1' ? 1
276
- : (_formatName.startsWith('floatFix') ? 2 : 0);
277
- if (_formatName === 'int') number = Math.round(number);
278
- let negative = number < 0;
279
- let absolute = Math.abs(number);
280
- let integerPart = String(Math.trunc(fractionDigits ? absolute : Math.round(absolute)));
281
- let grouped = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, groupSeparator);
282
- let fraction = fractionDigits ? decimalSeparator + absolute.toFixed(fractionDigits).split('.')[1] : '';
283
- return (negative ? '-' : '') + grouped + fraction;
284
- }
285
-
286
- /** Substitutes `<name/>` placeholders; honors `format="…"` via FormatValue. */
287
- function _ApplyPlaceholders(_text, _values) {
288
- let text = String(_text);
289
- let lid = GetLid();
290
- for (let key of Object.keys(_values ?? {})) {
291
- let pattern = new RegExp('<' + key + '(?:\\s+format="([^"]*)")?\\s*/>', 'g');
292
- text = text.replace(pattern, (_match, _formatName) =>
293
- _formatName ? FormatValue(_values[key], _formatName, lid) : String(_values[key]));
294
- }
295
- return text.replace(/<[^>]*>/g, '');
248
+ let context = _RenderContext(_lid);
249
+ if (!Object.prototype.hasOwnProperty.call(context.formats, _formatName)) return String(_value);
250
+ return EngineFormatValue(_value, _formatName, context);
296
251
  }
297
252
 
298
253
  /**
299
254
  * Translates a source phrase (including its context tag) into the active
300
- * language and resolves placeholders. Unknown phrases fall back to the en-US
301
- * source text with its tags stripped.
255
+ * language and renders it through the i18x engine (placeholders, `format="…"`,
256
+ * `if` conditions, …). Unknown phrases fall back to the en-US source text.
302
257
  * @param {string} _text source phrase, e.g. 'Build ready<context="task log"/>'
303
258
  * @param {object} [_values] placeholder values for `<name/>` tags
304
259
  * @returns {string}
@@ -309,7 +264,9 @@ export function Translate(_text, _values) {
309
264
  let lid = GetLid();
310
265
  let dictionary = lid === DEFAULT_LID ? null : _LoadDictionary(lid);
311
266
  let translated = (dictionary && Object.prototype.hasOwnProperty.call(dictionary, source)) ? dictionary[source] : source;
312
- return _ApplyPlaceholders(translated, _values);
267
+ // The context tag is part of the dictionary key only; strip it before rendering.
268
+ translated = translated.replace(/<context=[^>]*>/g, '');
269
+ return EngineTrans(translated, _values ?? {}, _RenderContext(lid));
313
270
  }
314
271
 
315
272
  /**