gulp-mu-gulp-api 0.3.5 → 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.
@@ -0,0 +1,697 @@
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(?:offset|short|place)?|summertimeoffset)[\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
+ /** 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
+
457
+ /** Decomposes a timestamp into date/time placeholders (local tz + utc variants). */
458
+ function _DatePlaceholders(_value, _timeZone, _lid) {
459
+ let ms = Number(_value) || 0;
460
+ let tz = _timeZone || 'UTC';
461
+ let local = _WallClockParts(ms, tz);
462
+ let utcDate = new Date(ms);
463
+ return {
464
+ day: local.day, month: local.month, year: local.year,
465
+ hour: local.hour, minute: local.minute, second: local.second,
466
+ weekday: local.weekday, millisecond: utcDate.getUTCMilliseconds(),
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,
473
+ utcday: utcDate.getUTCDate(), utcmonth: utcDate.getUTCMonth(), utcyear: utcDate.getUTCFullYear(),
474
+ utchour: utcDate.getUTCHours(), utcminute: utcDate.getUTCMinutes(), utcsecond: utcDate.getUTCSeconds(),
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()),
478
+ };
479
+ }
480
+
481
+ // -------------------------------------------------
482
+ // named-format rendering
483
+ // -------------------------------------------------
484
+
485
+ /**
486
+ * Formats a value with a named format definition.
487
+ * @param {number} _value
488
+ * @param {string} _formatName
489
+ * @param {object} _ctx render context `{ lid, timeZone, formats }`
490
+ * @returns {string}
491
+ */
492
+ export function FormatValue(_value, _formatName, _ctx) {
493
+ let def = _ResolveFormat(_formatName, _ctx);
494
+ if (!def) return String(_value);
495
+ let placeholders = _NumberPlaceholders(_value, def);
496
+ if (def.hasDateTags) Object.assign(placeholders, _DatePlaceholders(_value, _ctx.timeZone, _ctx.lid), { x: Number(_value) || 0 });
497
+ return Trans(def.xml, placeholders, _ctx);
498
+ }
499
+
500
+ /** Resolves a format by name from the local defs, then the context registry. */
501
+ function _ResolveFormat(_name, _ctx, _localFormats) {
502
+ if (_localFormats && Object.prototype.hasOwnProperty.call(_localFormats, _name)) return _localFormats[_name];
503
+ let formats = _ctx.formats;
504
+ if (formats && Object.prototype.hasOwnProperty.call(formats, _name)) return formats[_name];
505
+ return null;
506
+ }
507
+
508
+ // -------------------------------------------------
509
+ // entity tags
510
+ // -------------------------------------------------
511
+
512
+ const ENTITY_TAGS = {
513
+ lt: '<', gt: '>', obrc: '{', cbrc: '}', brl: '\u25C4', brr: '\u25BA',
514
+ br: '<br/>', newline: '\n', slashnewline: '\\n', quot: '"', quote: '"',
515
+ squot: "'", apos: "'", singlequote: "'", space: ' ', whitespace: ' ',
516
+ amp: '&', nbsp: '\u00A0', tab: '\t', hyphen: '\u00AD', '-': '\u00AD',
517
+ newpage: '\u21A1',
518
+ };
519
+
520
+ // -------------------------------------------------
521
+ // core interpreter (i18x.Trans port)
522
+ // -------------------------------------------------
523
+
524
+ /**
525
+ * Renders an i18x XML string against a placeholder set. This is the shared
526
+ * interpreter used both for translated phrases and for format bodies.
527
+ * @param {string} _text i18x XML source (already looked up / translated)
528
+ * @param {object} [_placeholders] values for `<name/>` tags
529
+ * @param {object} [_ctx] render context `{ lid, timeZone, formats }`
530
+ * @returns {string}
531
+ */
532
+ export function Trans(_text, _placeholders, _ctx) {
533
+ if (_text == null) return '';
534
+ let text = String(_text);
535
+ if (text.indexOf('<') === -1) return text;
536
+
537
+ let placeholders = _placeholders ?? {};
538
+ let ctx = _ctx ?? { lid: 'en-US', timeZone: 'UTC', formats: {} };
539
+ let localFormats = null;
540
+
541
+ let tokens = ('<i18x>' + text + '</i18x>').split('<');
542
+ let level = 0;
543
+ let txts = [''];
544
+ let visibles = [true];
545
+ let length = tokens.length;
546
+
547
+ for (let i = 0; i < length; i++) {
548
+ let token = tokens[i];
549
+ let closeIndex = token.indexOf('>');
550
+ if (closeIndex === -1) {
551
+ txts[level] += token;
552
+ continue;
553
+ }
554
+
555
+ let trailing = token.slice(closeIndex + 1);
556
+ let isClosingTag = token.charAt(0) === '/';
557
+ let isClosedTag = token.charAt(closeIndex - 1) === '/';
558
+ let sliceStart = 0;
559
+ let sliceEnd = closeIndex;
560
+ if (isClosedTag) sliceEnd -= 1;
561
+ if (isClosingTag) { sliceStart = 1; sliceEnd -= 1; }
562
+
563
+ let head = token.slice(sliceStart, sliceEnd);
564
+ let spaceIndex = head.indexOf(' ');
565
+ let tag = spaceIndex === -1 ? head : head.slice(0, spaceIndex);
566
+ let attrs = spaceIndex === -1 ? {} : _ParseAttributes(head.slice(spaceIndex + 1));
567
+
568
+ // inline <format> definitions: collect the raw block (honoring nesting),
569
+ // register it locally and skip over it without rendering its body.
570
+ if (tag === 'format' && !isClosingTag && !isClosedTag) {
571
+ let depth = 1;
572
+ let j = i;
573
+ while (depth > 0 && ++j < length) {
574
+ let inner = tokens[j];
575
+ let innerClose = inner.indexOf('>');
576
+ if (innerClose === -1) continue;
577
+ let innerClosing = inner.charAt(0) === '/';
578
+ let innerClosed = inner.charAt(innerClose - 1) === '/';
579
+ let innerHead = inner.slice(innerClosing ? 1 : 0, innerClose);
580
+ let innerSpace = innerHead.indexOf(' ');
581
+ let innerTag = innerSpace === -1 ? innerHead.replace(/\/$/, '') : innerHead.slice(0, innerSpace);
582
+ if (innerTag === 'format') {
583
+ if (innerClosing) depth--;
584
+ else if (!innerClosed) depth++;
585
+ }
586
+ }
587
+ let defSource = '<' + tokens.slice(i, j + 1).join('<');
588
+ if (!localFormats) localFormats = Object.create(null);
589
+ RegisterFormat(localFormats, defSource, true);
590
+ if (j < length) txts[level] += tokens[j].slice(tokens[j].indexOf('>') + 1);
591
+ i = j;
592
+ continue;
593
+ }
594
+
595
+ let value = '';
596
+ let doInsertValue = false;
597
+
598
+ if (isClosingTag) {
599
+ level--;
600
+ txts[level] += visibles[level + 1] ? txts[level + 1] : '';
601
+ txts[level] += trailing;
602
+ continue;
603
+ } else if (isClosedTag) {
604
+ doInsertValue = true;
605
+ } else {
606
+ level++;
607
+ txts[level] = '';
608
+ visibles[level] = true;
609
+ }
610
+
611
+ if (Object.prototype.hasOwnProperty.call(placeholders, tag)) {
612
+ let raw = placeholders[tag];
613
+ value = raw instanceof Date ? raw.getTime() : raw;
614
+ }
615
+ if (Object.prototype.hasOwnProperty.call(ENTITY_TAGS, tag)) value = ENTITY_TAGS[tag];
616
+
617
+ // process attributes in document order: value, expression, format, enumeration, if.
618
+ for (let attrKey of Object.keys(attrs)) {
619
+ let attrValue = attrs[attrKey];
620
+ if (attrKey === 'value') {
621
+ value = attrValue;
622
+ } else if (attrKey === 'expression') {
623
+ value = EvalExpression(attrValue, Number(value) || 0);
624
+ } else if (attrKey === 'format') {
625
+ let def = _ResolveFormat(attrValue, ctx, localFormats);
626
+ if (def) {
627
+ let subContext = localFormats ? { ...ctx, formats: { ...(ctx.formats ?? {}), ...localFormats } } : ctx;
628
+ value = FormatValue(Number(value) || 0, attrValue, subContext);
629
+ } else {
630
+ value = '';
631
+ }
632
+ } else if (attrKey === 'enumeration') {
633
+ let options = attrValue.split('|');
634
+ let index = parseInt(value, 10);
635
+ value = Number.isFinite(index) && index >= 0 && index < options.length ? options[index] : '';
636
+ } else if (attrKey === 'if' || attrKey === 'ifnot' || attrKey === 'ifin' || attrKey === 'ifnotin') {
637
+ visibles[level] = _EvaluateCondition(value, attrValue, attrKey);
638
+ }
639
+ }
640
+
641
+ txts[level] += (doInsertValue ? value : '') + trailing;
642
+ }
643
+
644
+ // flush any levels left open by unbalanced tags (e.g. the malformed roman format).
645
+ while (level > 0) {
646
+ txts[level - 1] += visibles[level] ? txts[level] : '';
647
+ level--;
648
+ }
649
+
650
+ return txts[0].replace(/\0/g, '');
651
+ }
652
+
653
+ /** Evaluates an `if`/`ifnot`/`ifin`/`ifnotin` condition against a value. */
654
+ function _EvaluateCondition(_value, _condition, _attrKey) {
655
+ let numeric = Number(_value);
656
+ let isNumber = _value !== '' && _value !== null && Number.isFinite(numeric);
657
+ let compareNumber = isNumber ? numeric : NaN;
658
+ let compareString = String(_value);
659
+ let isIn = _attrKey === 'ifin' || _attrKey === 'ifnotin';
660
+
661
+ let matched = false;
662
+ for (let clause of _condition.split('|')) {
663
+ let operator = clause.charAt(clause.length - 1);
664
+ let operand = clause;
665
+ if (operator === '+' || operator === '-' || operator === '=' || operator === '~' || operator === '*') {
666
+ operand = clause.slice(0, -1);
667
+ } else {
668
+ operator = '';
669
+ }
670
+ let operandNumber = Number(operand);
671
+ let operandIsNumber = operand !== '' && Number.isFinite(operandNumber);
672
+
673
+ if (isNumber && operandIsNumber) {
674
+ if (operator === '+') matched = matched || compareNumber > operandNumber;
675
+ else if (operator === '-') matched = matched || compareNumber < operandNumber;
676
+ else if (operator === '=') matched = matched || compareNumber === operandNumber;
677
+ else if (operator === '~') matched = matched || compareNumber !== operandNumber;
678
+ else if (operator === '*') matched = matched || compareString.indexOf(operand) >= 0;
679
+ else matched = matched || compareNumber === operandNumber;
680
+ } else {
681
+ if (operator === '+') matched = matched || compareString > operand;
682
+ else if (operator === '-') matched = matched || compareString < operand;
683
+ else if (operator === '=') matched = matched || compareString === operand;
684
+ else if (operator === '~') matched = matched || compareString !== operand;
685
+ else if (operator === '*') matched = matched || compareString.indexOf(operand) >= 0;
686
+ else if (isIn) matched = matched || compareString.indexOf(operand) >= 0;
687
+ else matched = matched || compareString === operand;
688
+ }
689
+ }
690
+ if (_attrKey === 'ifnot' || _attrKey === 'ifnotin') matched = !matched;
691
+ return matched;
692
+ }
693
+
694
+ /** Removes any remaining i18x/XML tags from a string (after placeholder substitution). */
695
+ export function StripTags(_text) {
696
+ return String(_text).replace(/<[^>]*>/g, '');
697
+ }