@tradik/xslt-processor 1.0.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.
@@ -0,0 +1,1012 @@
1
+ /**
2
+ * XPath 1.0 Evaluator
3
+ * Based on W3C XPath 1.0 Specification: http://www.w3.org/TR/1999/REC-xpath-19991116
4
+ *
5
+ * Evaluates XPath AST against DOM nodes.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ import { NodeType } from "./parser.js";
11
+
12
+ /**
13
+ * XPath result types matching W3C spec
14
+ */
15
+ export const XPathResultType = {
16
+ ANY_TYPE: 0,
17
+ NUMBER_TYPE: 1,
18
+ STRING_TYPE: 2,
19
+ BOOLEAN_TYPE: 3,
20
+ UNORDERED_NODE_ITERATOR_TYPE: 4,
21
+ ORDERED_NODE_ITERATOR_TYPE: 5,
22
+ UNORDERED_NODE_SNAPSHOT_TYPE: 6,
23
+ ORDERED_NODE_SNAPSHOT_TYPE: 7,
24
+ ANY_UNORDERED_NODE_TYPE: 8,
25
+ FIRST_ORDERED_NODE_TYPE: 9,
26
+ };
27
+
28
+ /**
29
+ * Security limits to prevent DoS attacks
30
+ */
31
+ export const XPathLimits = {
32
+ MAX_RECURSION_DEPTH: 100,
33
+ MAX_RESULT_SIZE: 10000,
34
+ MAX_STRING_LENGTH: 1000000,
35
+ };
36
+
37
+ /**
38
+ * Forbidden variable names to prevent prototype pollution
39
+ */
40
+ const FORBIDDEN_VARIABLE_NAMES = Object.freeze([
41
+ "__proto__",
42
+ "constructor",
43
+ "prototype",
44
+ "__defineGetter__",
45
+ "__defineSetter__",
46
+ "__lookupGetter__",
47
+ "__lookupSetter__",
48
+ ]);
49
+
50
+ /**
51
+ * XPath evaluation context
52
+ */
53
+ export class XPathContext {
54
+ constructor(node, position = 1, size = 1, variables = {}, namespaces = {}) {
55
+ this.node = node;
56
+ this.position = position;
57
+ this.size = size;
58
+ this.variables = variables;
59
+ this.namespaces = namespaces;
60
+ }
61
+
62
+ clone(overrides = {}) {
63
+ return new XPathContext(
64
+ overrides.node ?? this.node,
65
+ overrides.position ?? this.position,
66
+ overrides.size ?? this.size,
67
+ overrides.variables ?? this.variables,
68
+ overrides.namespaces ?? this.namespaces,
69
+ );
70
+ }
71
+ }
72
+
73
+ /**
74
+ * XPath Evaluator
75
+ */
76
+ export class XPathEvaluator {
77
+ constructor(options = {}) {
78
+ this.functions = this.initCoreFunctions();
79
+ this.maxRecursionDepth =
80
+ options.maxRecursionDepth ?? XPathLimits.MAX_RECURSION_DEPTH;
81
+ this.maxResultSize = options.maxResultSize ?? XPathLimits.MAX_RESULT_SIZE;
82
+ this.maxStringLength =
83
+ options.maxStringLength ?? XPathLimits.MAX_STRING_LENGTH;
84
+ this.recursionDepth = 0;
85
+ }
86
+
87
+ /**
88
+ * Evaluate XPath expression against a context
89
+ * @throws {Error} If recursion depth exceeds limit
90
+ * @throws {Error} If AST is invalid
91
+ */
92
+ evaluate(ast, context) {
93
+ if (!ast || typeof ast !== "object") {
94
+ throw new Error("Invalid AST: expected object");
95
+ }
96
+
97
+ if (!ast.type) {
98
+ throw new Error("Invalid AST: missing type property");
99
+ }
100
+
101
+ this.recursionDepth++;
102
+ if (this.recursionDepth > this.maxRecursionDepth) {
103
+ this.recursionDepth = 0;
104
+ throw new Error(
105
+ `Maximum recursion depth exceeded (${this.maxRecursionDepth})`,
106
+ );
107
+ }
108
+
109
+ try {
110
+ return this.evaluateInternal(ast, context);
111
+ } finally {
112
+ this.recursionDepth--;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Internal evaluation (after validation)
118
+ */
119
+ evaluateInternal(ast, context) {
120
+ switch (ast.type) {
121
+ case NodeType.OR_EXPR:
122
+ return this.evalOrExpr(ast, context);
123
+ case NodeType.AND_EXPR:
124
+ return this.evalAndExpr(ast, context);
125
+ case NodeType.EQUALITY_EXPR:
126
+ return this.evalEqualityExpr(ast, context);
127
+ case NodeType.RELATIONAL_EXPR:
128
+ return this.evalRelationalExpr(ast, context);
129
+ case NodeType.ADDITIVE_EXPR:
130
+ return this.evalAdditiveExpr(ast, context);
131
+ case NodeType.MULTIPLICATIVE_EXPR:
132
+ return this.evalMultiplicativeExpr(ast, context);
133
+ case NodeType.UNARY_EXPR:
134
+ return this.evalUnaryExpr(ast, context);
135
+ case NodeType.UNION_EXPR:
136
+ return this.evalUnionExpr(ast, context);
137
+ case NodeType.PATH_EXPR:
138
+ return this.evalPathExpr(ast, context);
139
+ case NodeType.LOCATION_PATH:
140
+ return this.evalLocationPath(ast, context);
141
+ case NodeType.VARIABLE_REF:
142
+ return this.evalVariableRef(ast, context);
143
+ case NodeType.LITERAL:
144
+ return this.validateString(ast.value);
145
+ case NodeType.NUMBER:
146
+ return ast.value;
147
+ case NodeType.FUNCTION_CALL:
148
+ return this.evalFunctionCall(ast, context);
149
+ default:
150
+ throw new Error(`Unknown AST node type: ${String(ast.type)}`);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Validate and limit string length
156
+ */
157
+ validateString(str) {
158
+ if (typeof str === "string" && str.length > this.maxStringLength) {
159
+ throw new Error(
160
+ `String exceeds maximum length (${this.maxStringLength})`,
161
+ );
162
+ }
163
+ return str;
164
+ }
165
+
166
+ /**
167
+ * Validate result size
168
+ */
169
+ validateResultSize(nodes) {
170
+ if (Array.isArray(nodes) && nodes.length > this.maxResultSize) {
171
+ throw new Error(
172
+ `Result set exceeds maximum size (${this.maxResultSize})`,
173
+ );
174
+ }
175
+ return nodes;
176
+ }
177
+
178
+ evalOrExpr(ast, context) {
179
+ return (
180
+ this.toBoolean(this.evaluate(ast.left, context)) ||
181
+ this.toBoolean(this.evaluate(ast.right, context))
182
+ );
183
+ }
184
+
185
+ evalAndExpr(ast, context) {
186
+ return (
187
+ this.toBoolean(this.evaluate(ast.left, context)) &&
188
+ this.toBoolean(this.evaluate(ast.right, context))
189
+ );
190
+ }
191
+
192
+ evalEqualityExpr(ast, context) {
193
+ const left = this.evaluate(ast.left, context);
194
+ const right = this.evaluate(ast.right, context);
195
+ const isEqual = this.compareValues(left, right, "=");
196
+ return ast.operator === "=" ? isEqual : !isEqual;
197
+ }
198
+
199
+ evalRelationalExpr(ast, context) {
200
+ const left = this.evaluate(ast.left, context);
201
+ const right = this.evaluate(ast.right, context);
202
+ return this.compareValues(left, right, ast.operator);
203
+ }
204
+
205
+ evalAdditiveExpr(ast, context) {
206
+ const left = this.toNumber(this.evaluate(ast.left, context));
207
+ const right = this.toNumber(this.evaluate(ast.right, context));
208
+ return ast.operator === "+" ? left + right : left - right;
209
+ }
210
+
211
+ evalMultiplicativeExpr(ast, context) {
212
+ const left = this.toNumber(this.evaluate(ast.left, context));
213
+ const right = this.toNumber(this.evaluate(ast.right, context));
214
+
215
+ switch (ast.operator) {
216
+ case "*":
217
+ return left * right;
218
+ case "div":
219
+ return left / right;
220
+ case "mod":
221
+ return left % right;
222
+ default:
223
+ throw new Error(`Unknown multiplicative operator: ${ast.operator}`);
224
+ }
225
+ }
226
+
227
+ evalUnaryExpr(ast, context) {
228
+ const value = this.toNumber(this.evaluate(ast.operand, context));
229
+ return -value;
230
+ }
231
+
232
+ evalUnionExpr(ast, context) {
233
+ const left = this.evaluate(ast.left, context);
234
+ const right = this.evaluate(ast.right, context);
235
+
236
+ const leftNodes = Array.isArray(left) ? left : [left];
237
+ const rightNodes = Array.isArray(right) ? right : [right];
238
+
239
+ // Union in document order, no duplicates
240
+ const seen = new Set();
241
+ const result = [];
242
+
243
+ for (const node of [...leftNodes, ...rightNodes]) {
244
+ if (!seen.has(node)) {
245
+ seen.add(node);
246
+ result.push(node);
247
+ }
248
+ }
249
+
250
+ this.validateResultSize(result);
251
+ return this.sortByDocumentOrder(result);
252
+ }
253
+
254
+ evalPathExpr(ast, context) {
255
+ if (ast.filter) {
256
+ let result = this.evaluate(ast.filter, context);
257
+
258
+ if (ast.predicates) {
259
+ for (const pred of ast.predicates) {
260
+ result = this.filterByPredicate(result, pred, context);
261
+ }
262
+ }
263
+
264
+ if (ast.steps) {
265
+ for (const step of ast.steps) {
266
+ result = this.evalStepOnNodes(step, result, context);
267
+ }
268
+ }
269
+
270
+ return result;
271
+ }
272
+
273
+ return [];
274
+ }
275
+
276
+ evalLocationPath(ast, context) {
277
+ let nodes;
278
+
279
+ if (ast.absolute) {
280
+ // Start from document root node (not document element)
281
+ // XPath absolute paths start from the document node
282
+ const doc = context.node.ownerDocument || context.node;
283
+ nodes = [doc];
284
+ } else {
285
+ nodes = [context.node];
286
+ }
287
+
288
+ for (const step of ast.steps) {
289
+ nodes = this.evalStepOnNodes(step, nodes, context);
290
+ this.validateResultSize(nodes);
291
+ }
292
+
293
+ return nodes;
294
+ }
295
+
296
+ evalStepOnNodes(step, nodes, context) {
297
+ const allNodes = Array.isArray(nodes) ? nodes : [nodes];
298
+ let result = [];
299
+
300
+ for (const node of allNodes) {
301
+ const stepNodes = this.evalStep(step, context.clone({ node }));
302
+ result = result.concat(stepNodes);
303
+
304
+ // Early validation to prevent excessive memory use
305
+ if (result.length > this.maxResultSize * 2) {
306
+ this.validateResultSize(result);
307
+ }
308
+ }
309
+
310
+ // Remove duplicates and sort by document order
311
+ const uniqueResult = [...new Set(result)];
312
+ this.validateResultSize(uniqueResult);
313
+ return this.sortByDocumentOrder(uniqueResult);
314
+ }
315
+
316
+ evalStep(step, context) {
317
+ // Get nodes along axis
318
+ let nodes = this.getAxisNodes(step.axis, context.node);
319
+
320
+ // Filter by node test
321
+ nodes = nodes.filter((n) => this.matchNodeTest(step.nodeTest, n, context));
322
+
323
+ // Apply predicates
324
+ for (const predicate of step.predicates) {
325
+ nodes = this.filterByPredicate(nodes, predicate, context);
326
+ }
327
+
328
+ return nodes;
329
+ }
330
+
331
+ getAxisNodes(axis, node) {
332
+ switch (axis) {
333
+ case "child":
334
+ return Array.from(node.childNodes || []);
335
+
336
+ case "parent":
337
+ return node.parentNode ? [node.parentNode] : [];
338
+
339
+ case "self":
340
+ return [node];
341
+
342
+ case "descendant":
343
+ return this.getDescendants(node, false);
344
+
345
+ case "descendant-or-self":
346
+ return this.getDescendants(node, true);
347
+
348
+ case "ancestor":
349
+ return this.getAncestors(node, false);
350
+
351
+ case "ancestor-or-self":
352
+ return this.getAncestors(node, true);
353
+
354
+ case "following-sibling":
355
+ return this.getFollowingSiblings(node);
356
+
357
+ case "preceding-sibling":
358
+ return this.getPrecedingSiblings(node);
359
+
360
+ case "following":
361
+ return this.getFollowing(node);
362
+
363
+ case "preceding":
364
+ return this.getPreceding(node);
365
+
366
+ case "attribute":
367
+ if (node.attributes) {
368
+ return Array.from(node.attributes);
369
+ }
370
+ return [];
371
+
372
+ case "namespace":
373
+ // Namespace axis - not commonly used
374
+ return [];
375
+
376
+ default:
377
+ throw new Error(`Unknown axis: ${axis}`);
378
+ }
379
+ }
380
+
381
+ getDescendants(node, includeSelf) {
382
+ const result = includeSelf ? [node] : [];
383
+ const stack = Array.from(node.childNodes || []).reverse();
384
+
385
+ while (stack.length > 0) {
386
+ const current = stack.pop();
387
+ result.push(current);
388
+ if (current.childNodes) {
389
+ for (let i = current.childNodes.length - 1; i >= 0; i--) {
390
+ stack.push(current.childNodes[i]);
391
+ }
392
+ }
393
+ }
394
+
395
+ return result;
396
+ }
397
+
398
+ getAncestors(node, includeSelf) {
399
+ const result = includeSelf ? [node] : [];
400
+ let current = node.parentNode;
401
+
402
+ while (current) {
403
+ result.push(current);
404
+ current = current.parentNode;
405
+ }
406
+
407
+ return result;
408
+ }
409
+
410
+ getFollowingSiblings(node) {
411
+ const result = [];
412
+ if (!node) return result;
413
+ let current = node.nextSibling;
414
+
415
+ while (current) {
416
+ result.push(current);
417
+ current = current.nextSibling;
418
+ }
419
+
420
+ return result;
421
+ }
422
+
423
+ getPrecedingSiblings(node) {
424
+ const result = [];
425
+ if (!node) return result;
426
+ let current = node.previousSibling;
427
+
428
+ while (current) {
429
+ result.push(current);
430
+ current = current.previousSibling;
431
+ }
432
+
433
+ return result.reverse();
434
+ }
435
+
436
+ getFollowing(node) {
437
+ const result = [];
438
+ let current = node;
439
+
440
+ // Go to next sibling, or ancestor's next sibling
441
+ while (current) {
442
+ if (current.nextSibling) {
443
+ current = current.nextSibling;
444
+ result.push(current);
445
+ // Add all descendants
446
+ result.push(...this.getDescendants(current, false));
447
+ } else {
448
+ current = current.parentNode;
449
+ }
450
+ }
451
+
452
+ return result;
453
+ }
454
+
455
+ getPreceding(node) {
456
+ const result = [];
457
+ let current = node;
458
+
459
+ while (current) {
460
+ if (current.previousSibling) {
461
+ current = current.previousSibling;
462
+ // Add descendants in reverse order, then the node
463
+ const descendants = this.getDescendants(current, false);
464
+ result.unshift(...descendants.reverse());
465
+ result.unshift(current);
466
+ } else {
467
+ current = current.parentNode;
468
+ if (current && current.nodeType !== 9) {
469
+ // Not document
470
+ // Don't add ancestors
471
+ }
472
+ }
473
+ }
474
+
475
+ return result;
476
+ }
477
+
478
+ matchNodeTest(nodeTest, node, context) {
479
+ switch (nodeTest.type) {
480
+ case NodeType.NAME_TEST:
481
+ return this.matchNameTest(nodeTest, node, context);
482
+
483
+ case NodeType.NODE_TYPE_TEST:
484
+ return this.matchNodeTypeTest(nodeTest.nodeType, node);
485
+
486
+ case NodeType.PI_TEST:
487
+ return node.nodeType === 7 && node.nodeName === nodeTest.name;
488
+
489
+ default:
490
+ return false;
491
+ }
492
+ }
493
+
494
+ matchNameTest(nodeTest, node, context) {
495
+ // Only element and attribute nodes have names
496
+ if (node.nodeType !== 1 && node.nodeType !== 2) {
497
+ return false;
498
+ }
499
+
500
+ const name = nodeTest.name;
501
+ const prefix = nodeTest.prefix;
502
+
503
+ // Wildcard
504
+ if (name === "*" && !prefix) {
505
+ return true;
506
+ }
507
+
508
+ // Get node's local name and namespace
509
+ const nodeName = node.localName || node.nodeName;
510
+ const nodeNs = node.namespaceURI || null;
511
+
512
+ // Prefix:* matches all nodes in namespace
513
+ if (name === "*" && prefix) {
514
+ const ns = context.namespaces[prefix];
515
+ return nodeNs === ns;
516
+ }
517
+
518
+ // Simple name match
519
+ if (!prefix) {
520
+ // For elements, match local name (case-insensitive for HTML)
521
+ const doc = node.ownerDocument;
522
+ if (doc && doc.contentType === "text/html" && node.nodeType === 1) {
523
+ return nodeName.toLowerCase() === name.toLowerCase();
524
+ }
525
+ return nodeName === name;
526
+ }
527
+
528
+ // Prefixed name match
529
+ const ns = context.namespaces[prefix];
530
+ return nodeName === name && nodeNs === ns;
531
+ }
532
+
533
+ matchNodeTypeTest(nodeType, node) {
534
+ switch (nodeType) {
535
+ case "node":
536
+ return true;
537
+ case "text":
538
+ return node.nodeType === 3;
539
+ case "comment":
540
+ return node.nodeType === 8;
541
+ case "processing-instruction":
542
+ return node.nodeType === 7;
543
+ default:
544
+ return false;
545
+ }
546
+ }
547
+
548
+ filterByPredicate(nodes, predicate, context) {
549
+ const result = [];
550
+ const nodeArray = Array.isArray(nodes) ? nodes : [nodes];
551
+ const size = nodeArray.length;
552
+
553
+ for (let i = 0; i < nodeArray.length; i++) {
554
+ const node = nodeArray[i];
555
+ const predicateContext = context.clone({
556
+ node,
557
+ position: i + 1,
558
+ size,
559
+ });
560
+
561
+ const value = this.evaluate(predicate.expr, predicateContext);
562
+
563
+ // If predicate evaluates to a number, it's a position test
564
+ if (typeof value === "number") {
565
+ if (value === i + 1) {
566
+ result.push(node);
567
+ }
568
+ } else if (this.toBoolean(value)) {
569
+ result.push(node);
570
+ }
571
+ }
572
+
573
+ return result;
574
+ }
575
+
576
+ evalVariableRef(ast, context) {
577
+ const name = ast.prefix ? `${ast.prefix}:${ast.name}` : ast.name;
578
+
579
+ // Security: Prevent prototype pollution
580
+ if (
581
+ FORBIDDEN_VARIABLE_NAMES.includes(name) ||
582
+ FORBIDDEN_VARIABLE_NAMES.includes(ast.name)
583
+ ) {
584
+ throw new Error(`Forbidden variable name: $${name}`);
585
+ }
586
+
587
+ if (!Object.prototype.hasOwnProperty.call(context.variables, name)) {
588
+ throw new Error(`Undefined variable: $${name}`);
589
+ }
590
+ return context.variables[name];
591
+ }
592
+
593
+ evalFunctionCall(ast, context) {
594
+ const name = ast.prefix ? `${ast.prefix}:${ast.name}` : ast.name;
595
+ const fn = this.functions[name];
596
+
597
+ if (!fn) {
598
+ throw new Error(`Unknown function: ${name}`);
599
+ }
600
+
601
+ return fn.call(this, ast.args, context);
602
+ }
603
+
604
+ // Type conversion functions
605
+ toBoolean(value) {
606
+ if (typeof value === "boolean") return value;
607
+ if (typeof value === "number") return value !== 0 && !isNaN(value);
608
+ if (typeof value === "string") return value.length > 0;
609
+ if (Array.isArray(value)) return value.length > 0;
610
+ if (value && value.nodeType) return true;
611
+ return Boolean(value);
612
+ }
613
+
614
+ toNumber(value) {
615
+ if (typeof value === "number") return value;
616
+ if (typeof value === "boolean") return value ? 1 : 0;
617
+ if (typeof value === "string") {
618
+ const trimmed = value.trim();
619
+ if (trimmed === "") return NaN;
620
+ const num = Number(trimmed);
621
+ return num;
622
+ }
623
+ if (Array.isArray(value)) {
624
+ return this.toNumber(this.toString(value));
625
+ }
626
+ if (value && value.nodeType) {
627
+ return this.toNumber(this.getStringValue(value));
628
+ }
629
+ return NaN;
630
+ }
631
+
632
+ toString(value) {
633
+ if (typeof value === "string") return value;
634
+ if (typeof value === "number") {
635
+ if (isNaN(value)) return "NaN";
636
+ if (value === Infinity) return "Infinity";
637
+ if (value === -Infinity) return "-Infinity";
638
+ if (value === 0) return "0";
639
+ return String(value);
640
+ }
641
+ if (typeof value === "boolean") return value ? "true" : "false";
642
+ if (Array.isArray(value)) {
643
+ if (value.length === 0) return "";
644
+ return this.getStringValue(value[0]);
645
+ }
646
+ if (value && value.nodeType) {
647
+ return this.getStringValue(value);
648
+ }
649
+ return String(value);
650
+ }
651
+
652
+ getStringValue(node) {
653
+ if (!node) return "";
654
+
655
+ switch (node.nodeType) {
656
+ case 1: // Element
657
+ case 9: // Document
658
+ case 11: {
659
+ // Document Fragment
660
+ let text = "";
661
+ const walker = (n) => {
662
+ if (n.nodeType === 3) {
663
+ text += n.nodeValue || "";
664
+ } else if (n.childNodes) {
665
+ for (const child of n.childNodes) {
666
+ walker(child);
667
+ }
668
+ }
669
+ };
670
+ walker(node);
671
+ return text;
672
+ }
673
+
674
+ case 2: // Attribute
675
+ case 3: // Text
676
+ case 4: // CDATA
677
+ case 7: // Processing Instruction
678
+ case 8: // Comment
679
+ return node.nodeValue || "";
680
+
681
+ default:
682
+ return "";
683
+ }
684
+ }
685
+
686
+ // Comparison helper
687
+ compareValues(left, right, operator) {
688
+ const leftIsNodeSet = Array.isArray(left);
689
+ const rightIsNodeSet = Array.isArray(right);
690
+
691
+ // Node-set comparisons
692
+ if (leftIsNodeSet && rightIsNodeSet) {
693
+ for (const l of left) {
694
+ for (const r of right) {
695
+ if (
696
+ this.comparePrimitive(
697
+ this.getStringValue(l),
698
+ this.getStringValue(r),
699
+ operator,
700
+ )
701
+ ) {
702
+ return true;
703
+ }
704
+ }
705
+ }
706
+ return false;
707
+ }
708
+
709
+ if (leftIsNodeSet) {
710
+ for (const l of left) {
711
+ if (this.comparePrimitive(this.getStringValue(l), right, operator)) {
712
+ return true;
713
+ }
714
+ }
715
+ return false;
716
+ }
717
+
718
+ if (rightIsNodeSet) {
719
+ for (const r of right) {
720
+ if (this.comparePrimitive(left, this.getStringValue(r), operator)) {
721
+ return true;
722
+ }
723
+ }
724
+ return false;
725
+ }
726
+
727
+ return this.comparePrimitive(left, right, operator);
728
+ }
729
+
730
+ comparePrimitive(left, right, operator) {
731
+ // If comparing for equality with different types
732
+ if (operator === "=" || operator === "!=") {
733
+ // If one is boolean, convert both to boolean
734
+ if (typeof left === "boolean" || typeof right === "boolean") {
735
+ const result = this.toBoolean(left) === this.toBoolean(right);
736
+ return operator === "=" ? result : !result;
737
+ }
738
+ // If one is number, convert both to number
739
+ if (typeof left === "number" || typeof right === "number") {
740
+ const result = this.toNumber(left) === this.toNumber(right);
741
+ return operator === "=" ? result : !result;
742
+ }
743
+ // Otherwise compare as strings
744
+ const result = this.toString(left) === this.toString(right);
745
+ return operator === "=" ? result : !result;
746
+ }
747
+
748
+ // Relational operators always compare as numbers
749
+ const leftNum = this.toNumber(left);
750
+ const rightNum = this.toNumber(right);
751
+
752
+ switch (operator) {
753
+ case "<":
754
+ return leftNum < rightNum;
755
+ case "<=":
756
+ return leftNum <= rightNum;
757
+ case ">":
758
+ return leftNum > rightNum;
759
+ case ">=":
760
+ return leftNum >= rightNum;
761
+ default:
762
+ throw new Error(`Unknown comparison operator: ${operator}`);
763
+ }
764
+ }
765
+
766
+ sortByDocumentOrder(nodes) {
767
+ if (nodes.length <= 1) return nodes;
768
+
769
+ return nodes.sort((a, b) => {
770
+ if (a === b) return 0;
771
+
772
+ const position = a.compareDocumentPosition
773
+ ? a.compareDocumentPosition(b)
774
+ : this.compareDocumentPositionFallback(a, b);
775
+
776
+ if (position & 4) return -1; // a before b
777
+ if (position & 2) return 1; // a after b
778
+ return 0;
779
+ });
780
+ }
781
+
782
+ compareDocumentPositionFallback(a, b) {
783
+ // Simple fallback for environments without compareDocumentPosition
784
+ const getPath = (node) => {
785
+ const path = [];
786
+ let current = node;
787
+ while (current) {
788
+ if (current.parentNode) {
789
+ const siblings = Array.from(current.parentNode.childNodes);
790
+ path.unshift(siblings.indexOf(current));
791
+ }
792
+ current = current.parentNode;
793
+ }
794
+ return path;
795
+ };
796
+
797
+ const pathA = getPath(a);
798
+ const pathB = getPath(b);
799
+
800
+ for (let i = 0; i < Math.min(pathA.length, pathB.length); i++) {
801
+ if (pathA[i] < pathB[i]) return 4; // a before b
802
+ if (pathA[i] > pathB[i]) return 2; // a after b
803
+ }
804
+
805
+ return pathA.length < pathB.length ? 4 : 2;
806
+ }
807
+
808
+ /**
809
+ * Initialize XPath 1.0 core functions
810
+ */
811
+ initCoreFunctions() {
812
+ return {
813
+ // Node set functions
814
+ last: (args, ctx) => ctx.size,
815
+ position: (args, ctx) => ctx.position,
816
+ count: (args, ctx) => {
817
+ const nodeSet = this.evaluate(args[0], ctx);
818
+ return Array.isArray(nodeSet) ? nodeSet.length : 1;
819
+ },
820
+ id: (args, ctx) => {
821
+ const value = this.toString(this.evaluate(args[0], ctx));
822
+ const doc = ctx.node.ownerDocument || ctx.node;
823
+ const ids = value.split(/\s+/).filter((id) => id);
824
+ const result = [];
825
+ for (const id of ids) {
826
+ const el = doc.getElementById(id);
827
+ if (el) result.push(el);
828
+ }
829
+ return result;
830
+ },
831
+ "local-name": (args, ctx) => {
832
+ let node;
833
+ if (args.length === 0) {
834
+ node = ctx.node;
835
+ } else {
836
+ const nodeSet = this.evaluate(args[0], ctx);
837
+ node = Array.isArray(nodeSet) ? nodeSet[0] : nodeSet;
838
+ }
839
+ if (!node) return "";
840
+ return node.localName || node.nodeName || "";
841
+ },
842
+ "namespace-uri": (args, ctx) => {
843
+ let node;
844
+ if (args.length === 0) {
845
+ node = ctx.node;
846
+ } else {
847
+ const nodeSet = this.evaluate(args[0], ctx);
848
+ node = Array.isArray(nodeSet) ? nodeSet[0] : nodeSet;
849
+ }
850
+ if (!node) return "";
851
+ return node.namespaceURI || "";
852
+ },
853
+ name: (args, ctx) => {
854
+ let node;
855
+ if (args.length === 0) {
856
+ node = ctx.node;
857
+ } else {
858
+ const nodeSet = this.evaluate(args[0], ctx);
859
+ node = Array.isArray(nodeSet) ? nodeSet[0] : nodeSet;
860
+ }
861
+ if (!node) return "";
862
+ return node.nodeName || "";
863
+ },
864
+
865
+ // String functions
866
+ string: (args, ctx) => {
867
+ if (args.length === 0) {
868
+ return this.toString([ctx.node]);
869
+ }
870
+ return this.toString(this.evaluate(args[0], ctx));
871
+ },
872
+ concat: (args, ctx) => {
873
+ return args
874
+ .map((arg) => this.toString(this.evaluate(arg, ctx)))
875
+ .join("");
876
+ },
877
+ "starts-with": (args, ctx) => {
878
+ const str = this.toString(this.evaluate(args[0], ctx));
879
+ const prefix = this.toString(this.evaluate(args[1], ctx));
880
+ return str.startsWith(prefix);
881
+ },
882
+ contains: (args, ctx) => {
883
+ const str = this.toString(this.evaluate(args[0], ctx));
884
+ const substr = this.toString(this.evaluate(args[1], ctx));
885
+ return str.includes(substr);
886
+ },
887
+ "substring-before": (args, ctx) => {
888
+ const str = this.toString(this.evaluate(args[0], ctx));
889
+ const substr = this.toString(this.evaluate(args[1], ctx));
890
+ const idx = str.indexOf(substr);
891
+ return idx === -1 ? "" : str.substring(0, idx);
892
+ },
893
+ "substring-after": (args, ctx) => {
894
+ const str = this.toString(this.evaluate(args[0], ctx));
895
+ const substr = this.toString(this.evaluate(args[1], ctx));
896
+ const idx = str.indexOf(substr);
897
+ return idx === -1 ? "" : str.substring(idx + substr.length);
898
+ },
899
+ substring: (args, ctx) => {
900
+ const str = this.toString(this.evaluate(args[0], ctx));
901
+ let start = Math.round(this.toNumber(this.evaluate(args[1], ctx)));
902
+ let length;
903
+
904
+ if (args.length > 2) {
905
+ length = Math.round(this.toNumber(this.evaluate(args[2], ctx)));
906
+ }
907
+
908
+ // XPath uses 1-based indexing
909
+ start = start - 1;
910
+
911
+ if (isNaN(start)) return "";
912
+ if (start < 0) {
913
+ if (length !== undefined) {
914
+ length = length + start;
915
+ }
916
+ start = 0;
917
+ }
918
+
919
+ if (length !== undefined) {
920
+ if (isNaN(length) || length <= 0) return "";
921
+ return str.substring(start, start + length);
922
+ }
923
+
924
+ return str.substring(start);
925
+ },
926
+ "string-length": (args, ctx) => {
927
+ const str =
928
+ args.length === 0
929
+ ? this.toString([ctx.node])
930
+ : this.toString(this.evaluate(args[0], ctx));
931
+ return str.length;
932
+ },
933
+ "normalize-space": (args, ctx) => {
934
+ const str =
935
+ args.length === 0
936
+ ? this.toString([ctx.node])
937
+ : this.toString(this.evaluate(args[0], ctx));
938
+ return str.trim().replace(/\s+/g, " ");
939
+ },
940
+ translate: (args, ctx) => {
941
+ const str = this.toString(this.evaluate(args[0], ctx));
942
+ const from = this.toString(this.evaluate(args[1], ctx));
943
+ const to = this.toString(this.evaluate(args[2], ctx));
944
+
945
+ let result = "";
946
+ for (const char of str) {
947
+ const idx = from.indexOf(char);
948
+ if (idx === -1) {
949
+ result += char;
950
+ } else if (idx < to.length) {
951
+ result += to[idx];
952
+ }
953
+ // If idx >= to.length, character is removed
954
+ }
955
+ return result;
956
+ },
957
+
958
+ // Boolean functions
959
+ boolean: (args, ctx) => {
960
+ return this.toBoolean(this.evaluate(args[0], ctx));
961
+ },
962
+ not: (args, ctx) => {
963
+ return !this.toBoolean(this.evaluate(args[0], ctx));
964
+ },
965
+ true: () => true,
966
+ false: () => false,
967
+ lang: (args, ctx) => {
968
+ const lang = this.toString(this.evaluate(args[0], ctx)).toLowerCase();
969
+ let node = ctx.node;
970
+
971
+ while (node && node.nodeType === 1) {
972
+ const xmlLang =
973
+ node.getAttribute("xml:lang") || node.getAttribute("lang");
974
+ if (xmlLang) {
975
+ const nodeLang = xmlLang.toLowerCase();
976
+ return nodeLang === lang || nodeLang.startsWith(lang + "-");
977
+ }
978
+ node = node.parentNode;
979
+ }
980
+ return false;
981
+ },
982
+
983
+ // Number functions
984
+ number: (args, ctx) => {
985
+ if (args.length === 0) {
986
+ return this.toNumber([ctx.node]);
987
+ }
988
+ return this.toNumber(this.evaluate(args[0], ctx));
989
+ },
990
+ sum: (args, ctx) => {
991
+ const nodeSet = this.evaluate(args[0], ctx);
992
+ if (!Array.isArray(nodeSet)) return NaN;
993
+ return nodeSet.reduce(
994
+ (sum, node) => sum + this.toNumber(this.getStringValue(node)),
995
+ 0,
996
+ );
997
+ },
998
+ floor: (args, ctx) => {
999
+ return Math.floor(this.toNumber(this.evaluate(args[0], ctx)));
1000
+ },
1001
+ ceiling: (args, ctx) => {
1002
+ return Math.ceil(this.toNumber(this.evaluate(args[0], ctx)));
1003
+ },
1004
+ round: (args, ctx) => {
1005
+ const num = this.toNumber(this.evaluate(args[0], ctx));
1006
+ if (isNaN(num)) return NaN;
1007
+ if (num === -0.5) return -0;
1008
+ return Math.round(num);
1009
+ },
1010
+ };
1011
+ }
1012
+ }