jtlt 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGES.md +18 -0
- package/README.md +46 -78
- package/demo/codemirror.esm.js +28242 -0
- package/demo/codemirror.js +94 -0
- package/demo/index.css +11 -0
- package/demo/index.html +11 -14
- package/demo/index.js +210 -26
- package/demo/vendor/fontoxpath/dist/fontoxpath.esm.js +600 -0
- package/demo/vendor/jamilih/dist/jml.mjs +2341 -0
- package/demo/vendor/jhtml/src/SAJJ/SAJJ.ObjectArrayDelegator.js +356 -0
- package/demo/vendor/jhtml/src/SAJJ/SAJJ.Stringifier.js +186 -0
- package/demo/vendor/jhtml/src/SAJJ/SAJJ.js +746 -0
- package/demo/vendor/jhtml/src/SAJJ/testing/SAJJ.html +33 -0
- package/demo/vendor/jhtml/src/SAJJ/testing/SAJJ.testing.js +25 -0
- package/demo/vendor/jhtml/src/jhtml-browser.js +5 -0
- package/demo/vendor/jhtml/src/jhtml-node.cts +3 -0
- package/demo/vendor/jhtml/src/jhtml-node.js +8 -0
- package/demo/vendor/jhtml/src/jhtml-node.mts +1 -0
- package/demo/vendor/jhtml/src/jhtml.cts +3 -0
- package/demo/vendor/jhtml/src/jhtml.js +602 -0
- package/demo/vendor/jhtml/src/jhtml.mts +1 -0
- package/demo/vendor/jsonpath-plus/dist/index-browser-esm.js +2158 -0
- package/demo/vendor/prsc/dist/prsc.esm.js +2 -0
- package/demo/vendor/simple-get-json/dist/index-es.js +151 -0
- package/demo/vendor/whynot/dist/whynot.esm.js +2 -0
- package/demo/vendor/xspattern/dist/xspattern.esm.js +2 -0
- package/dist/AbstractJoiningTransformer.d.ts +27 -0
- package/dist/AbstractJoiningTransformer.d.ts.map +1 -1
- package/dist/DOMJoiningTransformer.d.ts +60 -8
- package/dist/DOMJoiningTransformer.d.ts.map +1 -1
- package/dist/JSONJoiningTransformer.d.ts +8 -9
- package/dist/JSONJoiningTransformer.d.ts.map +1 -1
- package/dist/JSONPathTransformer.d.ts.map +1 -1
- package/dist/JSONPathTransformerContext.d.ts +223 -4
- package/dist/JSONPathTransformerContext.d.ts.map +1 -1
- package/dist/StringJoiningTransformer.d.ts +7 -7
- package/dist/StringJoiningTransformer.d.ts.map +1 -1
- package/dist/XPathTransformer.d.ts +2 -2
- package/dist/XPathTransformer.d.ts.map +1 -1
- package/dist/XPathTransformerContext.d.ts +161 -8
- package/dist/XPathTransformerContext.d.ts.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/docs/API.expanded.md +5 -3
- package/docs/API.md +1 -1
- package/docs/TO-DO.md +62 -36
- package/eslint.config.js +3 -1
- package/package.json +28 -4
- package/rollup.config.js +13 -0
- package/src/AbstractJoiningTransformer.js +42 -0
- package/src/DOMJoiningTransformer.js +115 -23
- package/src/JSONJoiningTransformer.js +50 -26
- package/src/JSONPathTransformer.js +2 -0
- package/src/JSONPathTransformerContext.js +936 -5
- package/src/StringJoiningTransformer.js +25 -21
- package/src/XPathTransformer.js +3 -1
- package/src/XPathTransformerContext.js +884 -15
- package/src/index.js +16 -7
- package/tsconfig.json +5 -2
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import xpath2 from 'xpath2.js'; // Runtime JS import; ambient types declared
|
|
2
|
-
//
|
|
2
|
+
// eslint-disable-next-line @stylistic/max-len -- Long
|
|
3
|
+
// xpathVersion: 1 => browser/native XPathEvaluator API; 2 => xpath2.js, 3 => fontoxpath
|
|
4
|
+
import fontoxpath from 'fontoxpath';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* @typedef {object} XPathTransformerContextConfig
|
|
6
8
|
* @property {unknown} [data] - XML/DOM root to transform
|
|
7
|
-
* @property {number} [xpathVersion] - 1
|
|
9
|
+
* @property {number} [xpathVersion] - 1, 2, 3.1 (default 1)
|
|
8
10
|
* @property {import('./index.js').
|
|
9
11
|
* JoiningTransformer} joiningTransformer Joiner
|
|
10
12
|
* @property {boolean} [errorOnEqualPriority]
|
|
@@ -15,13 +17,13 @@ import xpath2 from 'xpath2.js'; // Runtime JS import; ambient types declared
|
|
|
15
17
|
* Execution context for XPath-driven template application.
|
|
16
18
|
*
|
|
17
19
|
* Similar to JSONPathTransformerContext but uses XPath expressions on a
|
|
18
|
-
* DOM/XML-like tree. Supports XPath 1.0 (default)
|
|
19
|
-
* `xpathVersion: 2`.
|
|
20
|
+
* DOM/XML-like tree. Supports XPath 1.0 (default), 2.0 when
|
|
21
|
+
* `xpathVersion: 2`, or 3.1 when `xpathVersion: 3.1`.
|
|
20
22
|
*
|
|
21
23
|
* Expected config:
|
|
22
24
|
* - data: A Document, Element, or XML-like root node.
|
|
23
25
|
* - joiningTransformer: joiner with append(), string(), object(), array(), etc.
|
|
24
|
-
* - xpathVersion: 1|2 (default 1)
|
|
26
|
+
* - xpathVersion: 1|2|3.1 (default 1)
|
|
25
27
|
* - errorOnEqualPriority, specificityPriorityResolver (same semantics).
|
|
26
28
|
*/
|
|
27
29
|
class XPathTransformerContext {
|
|
@@ -46,6 +48,11 @@ class XPathTransformerContext {
|
|
|
46
48
|
this.propertySets = {};
|
|
47
49
|
/** @type {Record<string, {match: string, use: string}>} */
|
|
48
50
|
this.keys = {};
|
|
51
|
+
/**
|
|
52
|
+
* @type {Record<string,
|
|
53
|
+
* import('./JSONPathTransformerContext.js').DecimalFormatSymbols>}
|
|
54
|
+
*/
|
|
55
|
+
this.decimalFormats = {};
|
|
49
56
|
/** @type {boolean|undefined} */
|
|
50
57
|
this._initialized = undefined;
|
|
51
58
|
/** @type {string|undefined} */
|
|
@@ -69,7 +76,7 @@ class XPathTransformerContext {
|
|
|
69
76
|
if (!expr) {
|
|
70
77
|
return this._contextNode;
|
|
71
78
|
}
|
|
72
|
-
const version = this._config.xpathVersion
|
|
79
|
+
const version = this._config.xpathVersion ?? 1;
|
|
73
80
|
if (version === 1) {
|
|
74
81
|
// Use native XPath (browser-like); rely on DOM doc if available.
|
|
75
82
|
const doc = this._contextNode && this._contextNode.ownerDocument
|
|
@@ -141,8 +148,25 @@ class XPathTransformerContext {
|
|
|
141
148
|
/* c8 ignore stop */
|
|
142
149
|
}
|
|
143
150
|
}
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
if (version === 2) {
|
|
152
|
+
// Version 2: xpath2.js
|
|
153
|
+
const result = xpath2.evaluate(expr, this._contextNode);
|
|
154
|
+
if (asNodes) {
|
|
155
|
+
// eslint-disable-next-line @stylistic/max-len -- Long
|
|
156
|
+
/* c8 ignore next -- array wrap/identity branch counted in other tests */
|
|
157
|
+
return Array.isArray(result) ? result : [result];
|
|
158
|
+
}
|
|
159
|
+
/* c8 ignore next -- scalar return trivial; wrap behavior tested */
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// eslint-disable-next-line @stylistic/max-len -- Long
|
|
164
|
+
// eslint-disable-next-line import/no-named-as-default-member -- Only as default
|
|
165
|
+
const result = fontoxpath.evaluateXPath(
|
|
166
|
+
expr, this._contextNode, undefined, undefined,
|
|
167
|
+
// Non-deprecated, predictable all results
|
|
168
|
+
14 // ReturnType.ALL_RESULTS
|
|
169
|
+
);
|
|
146
170
|
if (asNodes) {
|
|
147
171
|
/* c8 ignore next -- array wrap/identity branch counted in other tests */
|
|
148
172
|
return Array.isArray(result) ? result : [result];
|
|
@@ -274,7 +298,16 @@ class XPathTransformerContext {
|
|
|
274
298
|
);
|
|
275
299
|
}
|
|
276
300
|
this._contextNode = node;
|
|
301
|
+
|
|
302
|
+
// Set up parameter context for valueOf() access in templates
|
|
303
|
+
const prevTemplateParams = this._params;
|
|
304
|
+
this._params = {0: node};
|
|
305
|
+
|
|
277
306
|
const ret = templateObj.template.call(this, node, {mode});
|
|
307
|
+
|
|
308
|
+
// Restore previous parameter context
|
|
309
|
+
this._params = prevTemplateParams;
|
|
310
|
+
|
|
278
311
|
if (typeof ret !== 'undefined') {
|
|
279
312
|
this._getJoiningTransformer().append(ret);
|
|
280
313
|
}
|
|
@@ -352,11 +385,269 @@ class XPathTransformerContext {
|
|
|
352
385
|
const nodesResult = this._evalXPath(select, true);
|
|
353
386
|
const nodes = /** @type {Node[]} */ (nodesResult);
|
|
354
387
|
for (const n of nodes) {
|
|
355
|
-
|
|
388
|
+
// Set up parameter context for valueOf() access
|
|
389
|
+
const prevParams = this._params;
|
|
390
|
+
const prevContext = this._contextNode;
|
|
391
|
+
this._params = {0: n};
|
|
392
|
+
this._contextNode = n;
|
|
393
|
+
try {
|
|
394
|
+
cb.call(this, n);
|
|
395
|
+
} finally {
|
|
396
|
+
// Restore previous parameter context
|
|
397
|
+
this._params = prevParams;
|
|
398
|
+
this._contextNode = prevContext;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return this;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Groups items and executes callback for each group.
|
|
406
|
+
* Equivalent to XSLT's xsl:for-each-group.
|
|
407
|
+
* @param {string} select - XPath selector for items to group
|
|
408
|
+
* @param {object} options - Grouping options
|
|
409
|
+
* @param {string} [options.groupBy] - XPath expression to group by value
|
|
410
|
+
* @param {string} [options.groupAdjacent] - Groups adjacent items with
|
|
411
|
+
* same value
|
|
412
|
+
* @param {string} [options.groupStartingWith] - Starts new group when
|
|
413
|
+
* expression matches
|
|
414
|
+
* @param {string} [options.groupEndingWith] - Ends group when expression
|
|
415
|
+
* matches
|
|
416
|
+
* @param {(
|
|
417
|
+
* this: XPathTransformerContext, key: any, items: Node[], ctx: any
|
|
418
|
+
* ) => void} cb - Callback receives (groupingKey, groupItems, context)
|
|
419
|
+
* @returns {this}
|
|
420
|
+
*/
|
|
421
|
+
forEachGroup (select, options, cb) {
|
|
422
|
+
// eslint-disable-next-line unicorn/no-this-assignment -- Temporary
|
|
423
|
+
const that = this;
|
|
424
|
+
const {groupBy, groupAdjacent, groupStartingWith, groupEndingWith} =
|
|
425
|
+
options;
|
|
426
|
+
|
|
427
|
+
const nodesResult = this._evalXPath(select, true);
|
|
428
|
+
const nodes = /** @type {Node[]} */ (nodesResult);
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* @param {string} expr
|
|
432
|
+
* @param {Node} node
|
|
433
|
+
* @returns {any}
|
|
434
|
+
*/
|
|
435
|
+
function evalInContext (expr, node) {
|
|
436
|
+
if (expr === '.' || expr === '@') {
|
|
437
|
+
return node.textContent;
|
|
438
|
+
}
|
|
439
|
+
// Temporarily set context to evaluate expression
|
|
440
|
+
const prevNode = that._contextNode;
|
|
441
|
+
that._contextNode = node;
|
|
442
|
+
try {
|
|
443
|
+
const result = that._evalXPath(expr, false);
|
|
444
|
+
// XPath v2/v3 always return arrays; handle NodeList/Array results
|
|
445
|
+
// by taking first item or its text content
|
|
446
|
+
const resultWithLength = /** @type {{length: number}} */ (result);
|
|
447
|
+
if (resultWithLength.length > 0) {
|
|
448
|
+
const firstItem = /** @type {any} */ (result)[0];
|
|
449
|
+
return firstItem?.textContent ?? firstItem;
|
|
450
|
+
}
|
|
451
|
+
return undefined;
|
|
452
|
+
} finally {
|
|
453
|
+
that._contextNode = prevNode;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** @type {Map<string, Node[]>} */
|
|
458
|
+
const groups = new Map();
|
|
459
|
+
|
|
460
|
+
if (groupBy) {
|
|
461
|
+
// Group by computed value
|
|
462
|
+
for (const node of nodes) {
|
|
463
|
+
const key = evalInContext(groupBy, node);
|
|
464
|
+
// Handle undefined by converting to null for JSON serialization
|
|
465
|
+
const keyStr = JSON.stringify(key === undefined ? null : key);
|
|
466
|
+
if (!groups.has(keyStr)) {
|
|
467
|
+
groups.set(keyStr, []);
|
|
468
|
+
}
|
|
469
|
+
/** @type {Node[]} */ (groups.get(keyStr)).push(node);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const [keyStr, items] of groups) {
|
|
473
|
+
const key = JSON.parse(keyStr);
|
|
474
|
+
// Convert null back to undefined if that was the original value
|
|
475
|
+
const actualKey = key === null && keyStr === 'null' ? undefined : key;
|
|
476
|
+
const prevParams = this._params;
|
|
477
|
+
const prevContext = this._contextNode;
|
|
478
|
+
try {
|
|
479
|
+
this._contextNode = items[0];
|
|
480
|
+
/** @type {any} */ (this)._currentGroup = items;
|
|
481
|
+
/** @type {any} */ (this)._currentGroupingKey = actualKey;
|
|
482
|
+
cb.call(this, actualKey, items, this);
|
|
483
|
+
} finally {
|
|
484
|
+
this._params = prevParams;
|
|
485
|
+
this._contextNode = prevContext;
|
|
486
|
+
delete /** @type {any} */ (this)._currentGroup;
|
|
487
|
+
delete /** @type {any} */ (this)._currentGroupingKey;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
} else if (groupAdjacent) {
|
|
491
|
+
// Group adjacent items with same value
|
|
492
|
+
/** @type {string|null} */
|
|
493
|
+
let currentKey = null;
|
|
494
|
+
/** @type {Node[]} */
|
|
495
|
+
let currentGroup = [];
|
|
496
|
+
|
|
497
|
+
for (const node of nodes) {
|
|
498
|
+
const key = evalInContext(groupAdjacent, node);
|
|
499
|
+
const keyStr = JSON.stringify(key);
|
|
500
|
+
|
|
501
|
+
if (currentKey === null || currentKey !== keyStr) {
|
|
502
|
+
if (currentGroup.length > 0) {
|
|
503
|
+
const prevParams = this._params;
|
|
504
|
+
const prevContext = this._contextNode;
|
|
505
|
+
try {
|
|
506
|
+
this._contextNode = currentGroup[0];
|
|
507
|
+
/** @type {any} */ (this)._currentGroup = currentGroup;
|
|
508
|
+
/** @type {any} */ (this)._currentGroupingKey =
|
|
509
|
+
JSON.parse(/** @type {string} */ (currentKey));
|
|
510
|
+
cb.call(
|
|
511
|
+
this,
|
|
512
|
+
JSON.parse(/** @type {string} */ (currentKey)),
|
|
513
|
+
currentGroup,
|
|
514
|
+
this
|
|
515
|
+
);
|
|
516
|
+
} finally {
|
|
517
|
+
this._params = prevParams;
|
|
518
|
+
this._contextNode = prevContext;
|
|
519
|
+
delete /** @type {any} */ (this)._currentGroup;
|
|
520
|
+
delete /** @type {any} */ (this)._currentGroupingKey;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
currentKey = keyStr;
|
|
524
|
+
currentGroup = [node];
|
|
525
|
+
} else {
|
|
526
|
+
currentGroup.push(node);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Process last group
|
|
531
|
+
if (currentGroup.length > 0) {
|
|
532
|
+
const prevParams = this._params;
|
|
533
|
+
const prevContext = this._contextNode;
|
|
534
|
+
try {
|
|
535
|
+
this._contextNode = currentGroup[0];
|
|
536
|
+
/** @type {any} */ (this)._currentGroup = currentGroup;
|
|
537
|
+
/** @type {any} */ (this)._currentGroupingKey =
|
|
538
|
+
JSON.parse(/** @type {string} */ (currentKey));
|
|
539
|
+
cb.call(
|
|
540
|
+
this,
|
|
541
|
+
JSON.parse(/** @type {string} */ (currentKey)),
|
|
542
|
+
currentGroup,
|
|
543
|
+
this
|
|
544
|
+
);
|
|
545
|
+
} finally {
|
|
546
|
+
this._params = prevParams;
|
|
547
|
+
this._contextNode = prevContext;
|
|
548
|
+
delete /** @type {any} */ (this)._currentGroup;
|
|
549
|
+
delete /** @type {any} */ (this)._currentGroupingKey;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
} else if (groupStartingWith) {
|
|
553
|
+
// Start new group when expression matches
|
|
554
|
+
/** @type {Node[]} */
|
|
555
|
+
let currentGroup = [];
|
|
556
|
+
|
|
557
|
+
for (const node of nodes) {
|
|
558
|
+
const startMatch = evalInContext(groupStartingWith, node);
|
|
559
|
+
|
|
560
|
+
if (startMatch && currentGroup.length > 0) {
|
|
561
|
+
const prevParams = this._params;
|
|
562
|
+
const prevContext = this._contextNode;
|
|
563
|
+
try {
|
|
564
|
+
this._contextNode = currentGroup[0];
|
|
565
|
+
/** @type {any} */ (this)._currentGroup = currentGroup;
|
|
566
|
+
cb.call(this, null, currentGroup, this);
|
|
567
|
+
} finally {
|
|
568
|
+
this._params = prevParams;
|
|
569
|
+
this._contextNode = prevContext;
|
|
570
|
+
delete /** @type {any} */ (this)._currentGroup;
|
|
571
|
+
}
|
|
572
|
+
currentGroup = [];
|
|
573
|
+
}
|
|
574
|
+
currentGroup.push(node);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Process last group
|
|
578
|
+
if (currentGroup.length > 0) {
|
|
579
|
+
const prevParams = this._params;
|
|
580
|
+
const prevContext = this._contextNode;
|
|
581
|
+
try {
|
|
582
|
+
this._contextNode = currentGroup[0];
|
|
583
|
+
/** @type {any} */ (this)._currentGroup = currentGroup;
|
|
584
|
+
cb.call(this, null, currentGroup, this);
|
|
585
|
+
} finally {
|
|
586
|
+
this._params = prevParams;
|
|
587
|
+
this._contextNode = prevContext;
|
|
588
|
+
delete /** @type {any} */ (this)._currentGroup;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
} else if (groupEndingWith) {
|
|
592
|
+
// End group when expression matches
|
|
593
|
+
/** @type {Node[]} */
|
|
594
|
+
let currentGroup = [];
|
|
595
|
+
|
|
596
|
+
for (const node of nodes) {
|
|
597
|
+
currentGroup.push(node);
|
|
598
|
+
const endMatch = evalInContext(groupEndingWith, node);
|
|
599
|
+
|
|
600
|
+
if (endMatch) {
|
|
601
|
+
const prevParams = this._params;
|
|
602
|
+
const prevContext = this._contextNode;
|
|
603
|
+
try {
|
|
604
|
+
this._contextNode = currentGroup[0];
|
|
605
|
+
/** @type {any} */ (this)._currentGroup = currentGroup;
|
|
606
|
+
cb.call(this, null, currentGroup, this);
|
|
607
|
+
} finally {
|
|
608
|
+
this._params = prevParams;
|
|
609
|
+
this._contextNode = prevContext;
|
|
610
|
+
delete /** @type {any} */ (this)._currentGroup;
|
|
611
|
+
}
|
|
612
|
+
currentGroup = [];
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Process last group if not ended
|
|
617
|
+
if (currentGroup.length > 0) {
|
|
618
|
+
const prevParams = this._params;
|
|
619
|
+
const prevContext = this._contextNode;
|
|
620
|
+
try {
|
|
621
|
+
this._contextNode = currentGroup[0];
|
|
622
|
+
/** @type {any} */ (this)._currentGroup = currentGroup;
|
|
623
|
+
cb.call(this, null, currentGroup, this);
|
|
624
|
+
} finally {
|
|
625
|
+
this._params = prevParams;
|
|
626
|
+
this._contextNode = prevContext;
|
|
627
|
+
delete /** @type {any} */ (this)._currentGroup;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
356
630
|
}
|
|
631
|
+
|
|
357
632
|
return this;
|
|
358
633
|
}
|
|
359
634
|
|
|
635
|
+
/**
|
|
636
|
+
* Returns the current group (for use within forEachGroup callback).
|
|
637
|
+
* @returns {Node[]|undefined}
|
|
638
|
+
*/
|
|
639
|
+
currentGroup () {
|
|
640
|
+
return /** @type {any} */ (this)._currentGroup;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Returns the current grouping key (for use within forEachGroup callback).
|
|
645
|
+
* @returns {any}
|
|
646
|
+
*/
|
|
647
|
+
currentGroupingKey () {
|
|
648
|
+
return /** @type {any} */ (this)._currentGroupingKey;
|
|
649
|
+
}
|
|
650
|
+
|
|
360
651
|
/**
|
|
361
652
|
* Append the value from an XPath expression or the context node text.
|
|
362
653
|
* @param {string|object} [select]
|
|
@@ -370,11 +661,66 @@ class XPathTransformerContext {
|
|
|
370
661
|
? /** @type {{select?: string}} */ (select).select
|
|
371
662
|
: select;
|
|
372
663
|
|
|
664
|
+
// Check for format-number() function call
|
|
665
|
+
if (selectStr && selectStr.includes('format-number(')) {
|
|
666
|
+
const match = (/format-number\((?<value>[^,\)]+)(?:,\s*["'](?<format>[^"']+)["'])?(?:,\s*["'](?<decimalFormat>[^"']*)["'])?\)/v).exec(selectStr);
|
|
667
|
+
if (match && match.groups) {
|
|
668
|
+
const {
|
|
669
|
+
value: valueExpr,
|
|
670
|
+
format: formatStr,
|
|
671
|
+
decimalFormat: decimalFormatName
|
|
672
|
+
} = match.groups;
|
|
673
|
+
// Evaluate the value expression
|
|
674
|
+
let numValue;
|
|
675
|
+
if (valueExpr.trim().startsWith('$')) {
|
|
676
|
+
// Parameter reference
|
|
677
|
+
const paramName = valueExpr.trim().slice(1);
|
|
678
|
+
numValue = this._params && paramName in this._params
|
|
679
|
+
? this._params[paramName]
|
|
680
|
+
: 0;
|
|
681
|
+
} else {
|
|
682
|
+
// Try to parse as number or evaluate as XPath
|
|
683
|
+
const trimmed = valueExpr.trim();
|
|
684
|
+
numValue = Number.isNaN(Number(trimmed))
|
|
685
|
+
? this._evalXPath(trimmed, false)
|
|
686
|
+
: Number(trimmed);
|
|
687
|
+
}
|
|
688
|
+
const num = typeof numValue === 'string' ? Number(numValue) : numValue;
|
|
689
|
+
const format = formatStr || '1';
|
|
690
|
+
const formatted = this._formatNumber(
|
|
691
|
+
num,
|
|
692
|
+
format,
|
|
693
|
+
undefined,
|
|
694
|
+
undefined,
|
|
695
|
+
decimalFormatName || '',
|
|
696
|
+
'en'
|
|
697
|
+
);
|
|
698
|
+
jt.append(formatted);
|
|
699
|
+
return this;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
373
703
|
// Check if this is a parameter reference (starts with $)
|
|
374
704
|
if (selectStr && selectStr.startsWith('$')) {
|
|
375
705
|
const paramName = selectStr.slice(1);
|
|
376
706
|
if (this._params && paramName in this._params) {
|
|
377
707
|
val = this._params[paramName];
|
|
708
|
+
// If val is a Node, extract its text content
|
|
709
|
+
if (val && typeof val === 'object' && 'nodeType' in val) {
|
|
710
|
+
if (val.nodeType === 3) {
|
|
711
|
+
// Text node: use nodeValue
|
|
712
|
+
val = val.nodeValue;
|
|
713
|
+
} else if (val.nodeType === 9) {
|
|
714
|
+
// Document node: use documentElement.textContent
|
|
715
|
+
const doc = /** @type {Document} */ (val);
|
|
716
|
+
val = doc.documentElement
|
|
717
|
+
? doc.documentElement.textContent
|
|
718
|
+
: null;
|
|
719
|
+
} else {
|
|
720
|
+
// Other nodes: use textContent
|
|
721
|
+
val = val.textContent;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
378
724
|
} else {
|
|
379
725
|
// Fall back to normal XPath evaluation
|
|
380
726
|
const resResult = this._evalXPath(selectStr, true);
|
|
@@ -387,9 +733,19 @@ class XPathTransformerContext {
|
|
|
387
733
|
/* c8 ignore stop */
|
|
388
734
|
}
|
|
389
735
|
} else if (!selectStr || selectStr === '.') {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
736
|
+
if (this._contextNode.nodeType === 3) {
|
|
737
|
+
// Text node: use nodeValue
|
|
738
|
+
val = this._contextNode.nodeValue;
|
|
739
|
+
} else if (this._contextNode.nodeType === 9) {
|
|
740
|
+
// Document node: use documentElement.textContent
|
|
741
|
+
const doc = /** @type {Document} */ (this._contextNode);
|
|
742
|
+
val = doc.documentElement
|
|
743
|
+
? doc.documentElement.textContent
|
|
744
|
+
: null;
|
|
745
|
+
} else {
|
|
746
|
+
// Other nodes: use textContent
|
|
747
|
+
val = this._contextNode.textContent;
|
|
748
|
+
}
|
|
393
749
|
} else {
|
|
394
750
|
const resResult = this._evalXPath(selectStr, true);
|
|
395
751
|
const res = /** @type {Node[]} */ (resResult);
|
|
@@ -523,14 +879,341 @@ class XPathTransformerContext {
|
|
|
523
879
|
return this;
|
|
524
880
|
}
|
|
525
881
|
/**
|
|
526
|
-
* Append number.
|
|
527
|
-
* @param {number
|
|
882
|
+
* Append number with xsl:number-like formatting.
|
|
883
|
+
* @param {number|string|{
|
|
884
|
+
* value?: number|string,
|
|
885
|
+
* count?: string,
|
|
886
|
+
* level?: 'single'|'multiple'|'any',
|
|
887
|
+
* from?: string,
|
|
888
|
+
* format?: string,
|
|
889
|
+
* decimalFormat?: string,
|
|
890
|
+
* groupingSeparator?: string,
|
|
891
|
+
* groupingSize?: number
|
|
892
|
+
* }} num - Number value, "position()" string, or options object
|
|
528
893
|
* @returns {XPathTransformerContext}
|
|
529
894
|
*/
|
|
530
895
|
number (num) {
|
|
531
|
-
|
|
896
|
+
// Handle xsl:number-like functionality
|
|
897
|
+
if (typeof num === 'object' && num !== null) {
|
|
898
|
+
const opts = num;
|
|
899
|
+
let {value} = opts;
|
|
900
|
+
|
|
901
|
+
// Handle position() calculation
|
|
902
|
+
if (value === 'position()' || value === undefined) {
|
|
903
|
+
const {count} = opts;
|
|
904
|
+
const level = opts.level || 'single';
|
|
905
|
+
const {from} = opts;
|
|
906
|
+
|
|
907
|
+
switch (level) {
|
|
908
|
+
case 'single': {
|
|
909
|
+
value = this._calculatePosition(count, from);
|
|
910
|
+
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
case 'multiple': {
|
|
914
|
+
// Hierarchical numbering: get position for each ancestor up to root
|
|
915
|
+
const positions = [];
|
|
916
|
+
let node = /** @type {any} */ (this._config).currentNode;
|
|
917
|
+
while (node) {
|
|
918
|
+
positions.unshift(this._calculatePosition(count, undefined));
|
|
919
|
+
node = node.parentNode;
|
|
920
|
+
if (from) {
|
|
921
|
+
const fromResult = /** @type {any} */ (
|
|
922
|
+
this._evalXPath(from, node)
|
|
923
|
+
);
|
|
924
|
+
if (fromResult && fromResult.length > 0) {
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
value = positions.join('.');
|
|
930
|
+
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
case 'any': {
|
|
934
|
+
value = this._calculatePositionAny(count, from);
|
|
935
|
+
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
// No default
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Determine format string and locale
|
|
943
|
+
let format = opts.format || '1';
|
|
944
|
+
// @ts-expect-error: dynamic property access
|
|
945
|
+
const locale = opts.lang || 'en';
|
|
946
|
+
// @ts-expect-error: dynamic property access
|
|
947
|
+
const {letterValue} = opts;
|
|
948
|
+
|
|
949
|
+
// If letterValue is 'alphabetic', force alphabetic format
|
|
950
|
+
if (letterValue === 'alphabetic') {
|
|
951
|
+
format = (opts.format && (/^[aA]$/v).test(opts.format)) ? opts.format : 'a';
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const numValue = typeof value === 'string' ? Number(value) : (value || 1);
|
|
955
|
+
const formatted = this._formatNumber(
|
|
956
|
+
numValue,
|
|
957
|
+
format,
|
|
958
|
+
opts.groupingSeparator,
|
|
959
|
+
opts.groupingSize,
|
|
960
|
+
locale
|
|
961
|
+
);
|
|
962
|
+
this._getJoiningTransformer().plainText(formatted);
|
|
963
|
+
} else if (num === 'position()') {
|
|
964
|
+
// Simple position() call
|
|
965
|
+
const pos = this._calculatePosition();
|
|
966
|
+
this._getJoiningTransformer().number(pos);
|
|
967
|
+
} else {
|
|
968
|
+
// Simple number
|
|
969
|
+
this._getJoiningTransformer().number(
|
|
970
|
+
typeof num === 'string' ? Number(num) : num
|
|
971
|
+
);
|
|
972
|
+
}
|
|
532
973
|
return this;
|
|
533
974
|
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Calculate position of current node.
|
|
978
|
+
* @param {string} [count] - XPath pattern to match
|
|
979
|
+
* @param {string} [from] - XPath pattern for ancestor
|
|
980
|
+
* @returns {number}
|
|
981
|
+
* @private
|
|
982
|
+
*/
|
|
983
|
+
_calculatePosition (count, from) {
|
|
984
|
+
// eslint-disable-next-line prefer-destructuring -- TS
|
|
985
|
+
const currentNode = /** @type {any} */ (this._config).currentNode;
|
|
986
|
+
if (!currentNode) {
|
|
987
|
+
return 1;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Get parent node
|
|
991
|
+
const parent = currentNode.parentNode;
|
|
992
|
+
if (!parent) {
|
|
993
|
+
return 1;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// If from pattern specified, find that ancestor
|
|
997
|
+
let startNode = parent;
|
|
998
|
+
if (from) {
|
|
999
|
+
const fromResult = /** @type {any} */ (
|
|
1000
|
+
this._evalXPath(from, currentNode)
|
|
1001
|
+
);
|
|
1002
|
+
if (fromResult && fromResult.length > 0) {
|
|
1003
|
+
startNode = fromResult[0];
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Count preceding siblings
|
|
1008
|
+
let position = 1;
|
|
1009
|
+
let sibling = currentNode.previousSibling;
|
|
1010
|
+
|
|
1011
|
+
while (sibling) {
|
|
1012
|
+
if (count) {
|
|
1013
|
+
// Check if sibling matches count pattern
|
|
1014
|
+
const matches = /** @type {any} */ (this._evalXPath(count, sibling));
|
|
1015
|
+
if (matches && matches.length > 0) {
|
|
1016
|
+
position++;
|
|
1017
|
+
}
|
|
1018
|
+
} else if (sibling.nodeType === currentNode.nodeType &&
|
|
1019
|
+
(!currentNode.nodeName || sibling.nodeName === currentNode.nodeName)) {
|
|
1020
|
+
position++;
|
|
1021
|
+
}
|
|
1022
|
+
sibling = sibling.previousSibling;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return position;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Calculate position counting all ancestors (level=any).
|
|
1030
|
+
* @param {string} [count] - XPath pattern to match
|
|
1031
|
+
* @param {string} [from] - XPath pattern for ancestor
|
|
1032
|
+
* @returns {number}
|
|
1033
|
+
* @private
|
|
1034
|
+
*/
|
|
1035
|
+
_calculatePositionAny (count, from) {
|
|
1036
|
+
// eslint-disable-next-line prefer-destructuring -- TS
|
|
1037
|
+
const currentNode = /** @type {any} */ (this._config).currentNode;
|
|
1038
|
+
if (!currentNode) {
|
|
1039
|
+
return 1;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Find root or 'from' node
|
|
1043
|
+
let root = currentNode.ownerDocument || currentNode;
|
|
1044
|
+
if (from) {
|
|
1045
|
+
const fromResult = /** @type {any} */ (
|
|
1046
|
+
this._evalXPath(from, currentNode)
|
|
1047
|
+
);
|
|
1048
|
+
if (fromResult && fromResult.length > 0) {
|
|
1049
|
+
root = fromResult[0];
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Count all matching nodes in document order up to current
|
|
1054
|
+
const pattern = count || 'node()';
|
|
1055
|
+
const allNodes = /** @type {any[]} */ (
|
|
1056
|
+
this._evalXPath('//' + pattern, root)
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
for (const [i, allNode] of allNodes.entries()) {
|
|
1060
|
+
if (allNode === currentNode) {
|
|
1061
|
+
return i + 1;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return 1;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Format a number according to format string.
|
|
1070
|
+
* @param {number} num - Number to format
|
|
1071
|
+
* @param {string} format - Format string (1, a, A, i, I, 01, etc.)
|
|
1072
|
+
* @param {string} [groupingSeparator] - Separator for grouping (e.g., ',')
|
|
1073
|
+
* @param {number} [groupingSize] - Size of groups (e.g., 3 for 1,000)
|
|
1074
|
+
* @param {string} [decimalFormatName] - Named decimal format to use
|
|
1075
|
+
* @param {string} [locale]
|
|
1076
|
+
* @returns {string}
|
|
1077
|
+
* @private
|
|
1078
|
+
*/
|
|
1079
|
+
_formatNumber (
|
|
1080
|
+
num, format, groupingSeparator, groupingSize, decimalFormatName, locale
|
|
1081
|
+
) {
|
|
1082
|
+
if (Number.isNaN(num)) {
|
|
1083
|
+
return String(num);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Get decimal format symbols if specified
|
|
1087
|
+
const symbols = decimalFormatName !== undefined &&
|
|
1088
|
+
decimalFormatName in this.decimalFormats
|
|
1089
|
+
? this.decimalFormats[decimalFormatName]
|
|
1090
|
+
: undefined;
|
|
1091
|
+
|
|
1092
|
+
// Default locale
|
|
1093
|
+
const loc = locale || 'en';
|
|
1094
|
+
|
|
1095
|
+
let result;
|
|
1096
|
+
const formatChar = format.charAt(0);
|
|
1097
|
+
|
|
1098
|
+
switch (formatChar) {
|
|
1099
|
+
case 'i': {
|
|
1100
|
+
result = this._toRoman(num).toLowerCase();
|
|
1101
|
+
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
case 'I': {
|
|
1105
|
+
result = this._toRoman(num);
|
|
1106
|
+
|
|
1107
|
+
break;
|
|
1108
|
+
}
|
|
1109
|
+
case 'a': {
|
|
1110
|
+
result = this._toAlphabetic(num, false);
|
|
1111
|
+
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
case 'A': {
|
|
1115
|
+
result = this._toAlphabetic(num, true);
|
|
1116
|
+
|
|
1117
|
+
break;
|
|
1118
|
+
}
|
|
1119
|
+
case '0': {
|
|
1120
|
+
const width = format.length;
|
|
1121
|
+
const zeroDigit = symbols?.zeroDigit || '0';
|
|
1122
|
+
result = String(num).padStart(width, zeroDigit);
|
|
1123
|
+
|
|
1124
|
+
break;
|
|
1125
|
+
}
|
|
1126
|
+
default: {
|
|
1127
|
+
// Use Intl.NumberFormat for decimal formatting if grouping/locale
|
|
1128
|
+
// options are provided
|
|
1129
|
+
let options = {};
|
|
1130
|
+
if (groupingSeparator || groupingSize) {
|
|
1131
|
+
options = {
|
|
1132
|
+
useGrouping: true
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
try {
|
|
1136
|
+
result = new Intl.NumberFormat(loc, options).format(num);
|
|
1137
|
+
|
|
1138
|
+
// Apply decimal format symbols if specified
|
|
1139
|
+
if (symbols) {
|
|
1140
|
+
// Use placeholders to avoid conflicts during replacement
|
|
1141
|
+
const TEMP_GROUP = '\u0000GROUPSEP\u0000';
|
|
1142
|
+
const TEMP_DECIMAL = '\u0000DECIMALSEP\u0000';
|
|
1143
|
+
|
|
1144
|
+
// Replace with temporary placeholders first
|
|
1145
|
+
result = result.replaceAll(',', TEMP_GROUP);
|
|
1146
|
+
result = result.replaceAll('.', TEMP_DECIMAL);
|
|
1147
|
+
|
|
1148
|
+
// Now replace with actual symbols
|
|
1149
|
+
const effectiveGroupingSep = groupingSeparator ||
|
|
1150
|
+
symbols.groupingSeparator || ',';
|
|
1151
|
+
const effectiveDecimalSep = symbols.decimalSeparator || '.';
|
|
1152
|
+
|
|
1153
|
+
result = result.replaceAll(TEMP_GROUP, effectiveGroupingSep);
|
|
1154
|
+
result = result.replaceAll(TEMP_DECIMAL, effectiveDecimalSep);
|
|
1155
|
+
} else if (groupingSeparator) {
|
|
1156
|
+
result = result.replaceAll(',', groupingSeparator);
|
|
1157
|
+
}
|
|
1158
|
+
} catch (e) {
|
|
1159
|
+
result = String(num);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return result;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Convert number to Roman numerals.
|
|
1168
|
+
* @param {number} num - Number to convert (1-3999)
|
|
1169
|
+
* @returns {string}
|
|
1170
|
+
* @private
|
|
1171
|
+
*/
|
|
1172
|
+
// eslint-disable-next-line class-methods-use-this -- Avoid for now
|
|
1173
|
+
_toRoman (num) {
|
|
1174
|
+
if (num < 1 || num > 3999) {
|
|
1175
|
+
return String(num);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
|
|
1179
|
+
const syms = [
|
|
1180
|
+
'M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'
|
|
1181
|
+
];
|
|
1182
|
+
|
|
1183
|
+
let result = '';
|
|
1184
|
+
for (const [i, val] of vals.entries()) {
|
|
1185
|
+
while (num >= val) {
|
|
1186
|
+
result += syms[i];
|
|
1187
|
+
num -= val;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return result;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Convert number to alphabetic sequence.
|
|
1195
|
+
* @param {number} num - Number to convert
|
|
1196
|
+
* @param {boolean} uppercase - Use uppercase letters
|
|
1197
|
+
* @returns {string}
|
|
1198
|
+
* @private
|
|
1199
|
+
*/
|
|
1200
|
+
// eslint-disable-next-line class-methods-use-this -- Avoid for now
|
|
1201
|
+
_toAlphabetic (num, uppercase) {
|
|
1202
|
+
if (num < 1) {
|
|
1203
|
+
return String(num);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
let result = '';
|
|
1207
|
+
const base = uppercase ? 65 : 97; // 'A' or 'a'
|
|
1208
|
+
|
|
1209
|
+
while (num > 0) {
|
|
1210
|
+
num--; // Make 0-indexed
|
|
1211
|
+
result = String.fromCodePoint(base + (num % 26)) + result;
|
|
1212
|
+
num = Math.floor(num / 26);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
return result;
|
|
1216
|
+
}
|
|
534
1217
|
/**
|
|
535
1218
|
* Append plain text (no escaping changes).
|
|
536
1219
|
* @param {string} str Text
|
|
@@ -596,6 +1279,17 @@ class XPathTransformerContext {
|
|
|
596
1279
|
return this;
|
|
597
1280
|
}
|
|
598
1281
|
|
|
1282
|
+
/**
|
|
1283
|
+
* @param {string} name
|
|
1284
|
+
* @param {import('./AbstractJoiningTransformer.js').
|
|
1285
|
+
* OutputCharacters} outputCharacters
|
|
1286
|
+
* @returns {this}
|
|
1287
|
+
*/
|
|
1288
|
+
characterMap (name, outputCharacters) {
|
|
1289
|
+
this._getJoiningTransformer().characterMap(name, outputCharacters);
|
|
1290
|
+
return this;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
599
1293
|
/**
|
|
600
1294
|
* Append element.
|
|
601
1295
|
* @param {string} name Tag name
|
|
@@ -611,6 +1305,44 @@ class XPathTransformerContext {
|
|
|
611
1305
|
this._getJoiningTransformer().element(name, atts, children, cb);
|
|
612
1306
|
return this;
|
|
613
1307
|
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Adds a prefixed namespace declaration to the most recently opened
|
|
1311
|
+
* element. Mirrors the joining
|
|
1312
|
+
* transformer API so templates can call `this.attribute()`.
|
|
1313
|
+
* @param {string} prefix - Prefix
|
|
1314
|
+
* @param {string} namespaceURI - Namespace
|
|
1315
|
+
* @returns {this}
|
|
1316
|
+
*/
|
|
1317
|
+
namespace (prefix, namespaceURI) {
|
|
1318
|
+
/** @type {any} */ (this._getJoiningTransformer()).namespace(
|
|
1319
|
+
prefix, namespaceURI
|
|
1320
|
+
);
|
|
1321
|
+
return this;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Define a decimal format with custom symbols for number formatting.
|
|
1326
|
+
* Equivalent to xsl:decimal-format. If no name is provided, defines
|
|
1327
|
+
* the default format.
|
|
1328
|
+
* @param {string|import('./JSONPathTransformerContext.js').
|
|
1329
|
+
* DecimalFormatSymbols} nameOrSymbols - Format name or symbols object
|
|
1330
|
+
* if defining default
|
|
1331
|
+
* @param {import('./JSONPathTransformerContext.js').
|
|
1332
|
+
* DecimalFormatSymbols} [symbols] - Format symbols
|
|
1333
|
+
* @returns {this}
|
|
1334
|
+
*/
|
|
1335
|
+
decimalFormat (nameOrSymbols, symbols) {
|
|
1336
|
+
if (typeof nameOrSymbols === 'string') {
|
|
1337
|
+
// Named format
|
|
1338
|
+
this.decimalFormats[nameOrSymbols] = symbols || {};
|
|
1339
|
+
} else {
|
|
1340
|
+
// Default format (unnamed)
|
|
1341
|
+
this.decimalFormats[''] = nameOrSymbols;
|
|
1342
|
+
}
|
|
1343
|
+
return this;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
614
1346
|
/**
|
|
615
1347
|
* Append attribute.
|
|
616
1348
|
* @param {string} name Attribute name
|
|
@@ -824,6 +1556,143 @@ class XPathTransformerContext {
|
|
|
824
1556
|
return this;
|
|
825
1557
|
}
|
|
826
1558
|
|
|
1559
|
+
/**
|
|
1560
|
+
* Analyze a string with a regular expression, equivalent to
|
|
1561
|
+
* xsl:analyze-string. Processes matching and non-matching substrings
|
|
1562
|
+
* with separate callbacks.
|
|
1563
|
+
* @param {string} str - The string to analyze
|
|
1564
|
+
* @param {string|RegExp} regex - Regular expression to match against
|
|
1565
|
+
* @param {{
|
|
1566
|
+
* matchingSubstring?: (
|
|
1567
|
+
* this: XPathTransformerContext,
|
|
1568
|
+
* substring: string,
|
|
1569
|
+
* groups: string[],
|
|
1570
|
+
* regexGroup: (n: number) => string
|
|
1571
|
+
* ) => void,
|
|
1572
|
+
* nonMatchingSubstring?: (
|
|
1573
|
+
* this: XPathTransformerContext,
|
|
1574
|
+
* substring: string
|
|
1575
|
+
* ) => void,
|
|
1576
|
+
* flags?: string
|
|
1577
|
+
* }} options - Options object
|
|
1578
|
+
* @returns {XPathTransformerContext}
|
|
1579
|
+
*/
|
|
1580
|
+
analyzeString (str, regex, options = {}) {
|
|
1581
|
+
// Ensure we have a string
|
|
1582
|
+
const inputString = String(str || '');
|
|
1583
|
+
|
|
1584
|
+
// If empty string, do nothing
|
|
1585
|
+
if (inputString.length === 0) {
|
|
1586
|
+
return this;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
const {
|
|
1590
|
+
matchingSubstring,
|
|
1591
|
+
nonMatchingSubstring,
|
|
1592
|
+
flags = ''
|
|
1593
|
+
} = options;
|
|
1594
|
+
|
|
1595
|
+
// Convert regex to RegExp if it's a string
|
|
1596
|
+
let regexObj;
|
|
1597
|
+
if (typeof regex === 'string') {
|
|
1598
|
+
// Ensure 'g' flag is present for global matching
|
|
1599
|
+
const actualFlags = flags.includes('g') ? flags : flags + 'g';
|
|
1600
|
+
regexObj = new RegExp(regex, actualFlags);
|
|
1601
|
+
} else {
|
|
1602
|
+
regexObj = regex;
|
|
1603
|
+
// Ensure global flag is set
|
|
1604
|
+
if (!regexObj.global) {
|
|
1605
|
+
regexObj = new RegExp(
|
|
1606
|
+
regexObj.source,
|
|
1607
|
+
regexObj.flags + 'g'
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Check for zero-length matches (error condition in XSLT)
|
|
1613
|
+
if (regexObj.test('')) {
|
|
1614
|
+
throw new Error(
|
|
1615
|
+
'Regular expression matches zero-length string'
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// Store captured groups for access during callback
|
|
1620
|
+
/** @type {string[] | undefined} */
|
|
1621
|
+
let currentCapturedGroups;
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Get captured group by index.
|
|
1625
|
+
* @param {number} groupNumber - Group index
|
|
1626
|
+
* @returns {string} - Captured group or empty string
|
|
1627
|
+
*/
|
|
1628
|
+
const getRegexGroup = (groupNumber) => {
|
|
1629
|
+
if (!currentCapturedGroups ||
|
|
1630
|
+
groupNumber < 0 ||
|
|
1631
|
+
groupNumber >= currentCapturedGroups.length) {
|
|
1632
|
+
return '';
|
|
1633
|
+
}
|
|
1634
|
+
return currentCapturedGroups[groupNumber] || '';
|
|
1635
|
+
};
|
|
1636
|
+
|
|
1637
|
+
// Save previous context to restore later
|
|
1638
|
+
const prevContext = this._contextNode;
|
|
1639
|
+
|
|
1640
|
+
let lastIndex = 0;
|
|
1641
|
+
let match;
|
|
1642
|
+
|
|
1643
|
+
// Bind callbacks to this context
|
|
1644
|
+
const boundMatchingSubstring = matchingSubstring
|
|
1645
|
+
? matchingSubstring.bind(this)
|
|
1646
|
+
: undefined;
|
|
1647
|
+
const boundNonMatchingSubstring = nonMatchingSubstring
|
|
1648
|
+
? nonMatchingSubstring.bind(this)
|
|
1649
|
+
: undefined;
|
|
1650
|
+
|
|
1651
|
+
// Find all matches
|
|
1652
|
+
while ((match = regexObj.exec(inputString)) !== null) {
|
|
1653
|
+
// Process non-matching substring before this match
|
|
1654
|
+
if (match.index > lastIndex) {
|
|
1655
|
+
const nonMatchingStr = inputString.slice(lastIndex, match.index);
|
|
1656
|
+
if (boundNonMatchingSubstring) {
|
|
1657
|
+
boundNonMatchingSubstring(nonMatchingStr);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Process matching substring
|
|
1662
|
+
if (boundMatchingSubstring) {
|
|
1663
|
+
const matchingStr = match[0];
|
|
1664
|
+
// Store captured groups: [full match, group1, group2, ...]
|
|
1665
|
+
currentCapturedGroups = [...match];
|
|
1666
|
+
boundMatchingSubstring(
|
|
1667
|
+
matchingStr, currentCapturedGroups, getRegexGroup
|
|
1668
|
+
);
|
|
1669
|
+
currentCapturedGroups = undefined;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
const {lastIndex: newLastIndex} = regexObj;
|
|
1673
|
+
lastIndex = newLastIndex;
|
|
1674
|
+
|
|
1675
|
+
// Prevent infinite loop on zero-length matches (shouldn't happen
|
|
1676
|
+
// due to earlier check, but defensive)
|
|
1677
|
+
if (match.index === regexObj.lastIndex) {
|
|
1678
|
+
regexObj.lastIndex++;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// Process final non-matching substring
|
|
1683
|
+
if (lastIndex < inputString.length) {
|
|
1684
|
+
const nonMatchingStr = inputString.slice(lastIndex);
|
|
1685
|
+
if (boundNonMatchingSubstring) {
|
|
1686
|
+
boundNonMatchingSubstring(nonMatchingStr);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Restore previous context
|
|
1691
|
+
this._contextNode = prevContext;
|
|
1692
|
+
|
|
1693
|
+
return this;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
827
1696
|
/* c8 ignore start -- static default rules object has spotty function
|
|
828
1697
|
* attribution under coverage; behavior is exercised via applyTemplates */
|
|
829
1698
|
static DefaultTemplateRules = {
|