@tradik/xslt-processor 1.1.1 → 1.3.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.
Files changed (122) hide show
  1. package/LICENSE.md +1 -1
  2. package/README.md +102 -757
  3. package/bin/lib/decode.js +15 -0
  4. package/bin/lib/dom.js +177 -0
  5. package/bin/lib/loaders.js +127 -0
  6. package/bin/lib/options.js +17 -0
  7. package/bin/lib/output.js +114 -0
  8. package/bin/lib/paths.js +3 -3
  9. package/bin/lib/transform.js +124 -33
  10. package/bin/xslt.js +26 -27
  11. package/dist/xslt-processor.browser.js +8784 -2720
  12. package/dist/xslt-processor.browser.js.map +4 -4
  13. package/dist/xslt-processor.browser.min.js +13 -6
  14. package/dist/xslt-processor.browser.min.js.map +4 -4
  15. package/dist/xslt-processor.cjs +8789 -2723
  16. package/dist/xslt-processor.cjs.map +4 -4
  17. package/dist/xslt-processor.d.cts +380 -21
  18. package/dist/xslt-processor.d.ts +380 -21
  19. package/dist/xslt-processor.js +8770 -2722
  20. package/dist/xslt-processor.js.map +4 -4
  21. package/package.json +51 -11
  22. package/src/XSLTProcessor.js +343 -66
  23. package/src/async/abort.js +63 -0
  24. package/src/async/documentUris.js +128 -0
  25. package/src/async/loaders.js +134 -0
  26. package/src/async/preload.js +159 -0
  27. package/src/async/processor.js +206 -0
  28. package/src/async/stream.js +125 -0
  29. package/src/bridge/engine.js +221 -0
  30. package/src/bridge/loader.js +78 -0
  31. package/src/bridge/results.js +75 -0
  32. package/src/bridge/version.js +63 -0
  33. package/src/index.js +16 -4
  34. package/src/io/decode.js +140 -0
  35. package/src/io/readSource.js +167 -0
  36. package/src/xpath/axes.js +562 -0
  37. package/src/xpath/documentOrder.js +270 -0
  38. package/src/xpath/evaluator.js +475 -355
  39. package/src/xpath/index.js +8 -2
  40. package/src/xpath/namespaceNodes.js +172 -0
  41. package/src/xpath/nodeSetFunctions.js +169 -0
  42. package/src/xpath/parser.js +30 -5
  43. package/src/xpath/strings.js +183 -0
  44. package/src/xpath/tokenizer.js +37 -23
  45. package/src/xslt/attributeSets.js +95 -0
  46. package/src/xslt/avt.js +103 -0
  47. package/src/xslt/computedNames.js +91 -0
  48. package/src/xslt/copying.js +212 -0
  49. package/src/xslt/declarationNames.js +80 -0
  50. package/src/xslt/domParsing.js +95 -0
  51. package/src/xslt/elements.js +1 -1
  52. package/src/xslt/engine/bindings.js +195 -0
  53. package/src/xslt/engine/context.js +105 -0
  54. package/src/xslt/engine/controlFlow.js +145 -0
  55. package/src/xslt/engine/copyInstructions.js +133 -0
  56. package/src/xslt/engine/declarations.js +233 -0
  57. package/src/xslt/engine/functionSupport.js +103 -0
  58. package/src/xslt/engine/methods.js +33 -0
  59. package/src/xslt/engine/nodeConstruction.js +187 -0
  60. package/src/xslt/engine/numbering.js +104 -0
  61. package/src/xslt/engine/outputDeclaration.js +77 -0
  62. package/src/xslt/engine/sequenceConstructor.js +228 -0
  63. package/src/xslt/engine/stylesheetLoading.js +208 -0
  64. package/src/xslt/engine/templateInvocation.js +253 -0
  65. package/src/xslt/engine/templateRules.js +243 -0
  66. package/src/xslt/engine/textInstructions.js +171 -0
  67. package/src/xslt/engine/topLevel.js +130 -0
  68. package/src/xslt/engine/transformation.js +263 -0
  69. package/src/xslt/engine/workStack.js +245 -0
  70. package/src/xslt/engine.js +176 -2020
  71. package/src/xslt/exslt/arguments.js +99 -0
  72. package/src/xslt/exslt/calendar.js +120 -0
  73. package/src/xslt/exslt/common.js +44 -0
  74. package/src/xslt/exslt/dateCalc.js +261 -0
  75. package/src/xslt/exslt/dateFormat.js +150 -0
  76. package/src/xslt/exslt/dateParse.js +265 -0
  77. package/src/xslt/exslt/dates.js +259 -0
  78. package/src/xslt/exslt/duration.js +207 -0
  79. package/src/xslt/exslt/dynamic.js +59 -0
  80. package/src/xslt/exslt/index.js +59 -0
  81. package/src/xslt/exslt/math.js +177 -0
  82. package/src/xslt/exslt/sets.js +96 -0
  83. package/src/xslt/exslt/stringOps.js +163 -0
  84. package/src/xslt/exslt/strings.js +147 -0
  85. package/src/xslt/exslt/uri.js +92 -0
  86. package/src/xslt/formatNumber.js +22 -9
  87. package/src/xslt/forwardsCompatible.js +75 -0
  88. package/src/xslt/functions.js +94 -15
  89. package/src/xslt/index.js +7 -1
  90. package/src/xslt/keys.js +51 -28
  91. package/src/xslt/literalResult.js +63 -7
  92. package/src/xslt/matchScope.js +116 -0
  93. package/src/xslt/number.js +171 -78
  94. package/src/xslt/numberFormat.js +124 -26
  95. package/src/xslt/outputNames.js +58 -0
  96. package/src/xslt/patternCompiler.js +175 -0
  97. package/src/xslt/patterns.js +324 -0
  98. package/src/xslt/qname.js +90 -0
  99. package/src/xslt/resultDocument.js +98 -0
  100. package/src/xslt/resultNamespaces.js +219 -0
  101. package/src/xslt/resultTree.js +143 -6
  102. package/src/xslt/serializer/baseWriter.js +173 -66
  103. package/src/xslt/serializer/chunks.js +120 -0
  104. package/src/xslt/serializer/constants.js +14 -0
  105. package/src/xslt/serializer/encoding.js +327 -0
  106. package/src/xslt/serializer/escape.js +49 -12
  107. package/src/xslt/serializer/frames.js +168 -0
  108. package/src/xslt/serializer/htmlDoctype.js +102 -0
  109. package/src/xslt/serializer/htmlEntities.js +77 -0
  110. package/src/xslt/serializer/htmlSerializer.js +123 -25
  111. package/src/xslt/serializer/settings.js +89 -13
  112. package/src/xslt/serializer/textSerializer.js +58 -10
  113. package/src/xslt/serializer/xhtmlDocument.js +103 -0
  114. package/src/xslt/serializer/xmlSerializer.js +113 -13
  115. package/src/xslt/serializer.js +50 -17
  116. package/src/xslt/sort.js +151 -0
  117. package/src/xslt/spaceNameTests.js +115 -0
  118. package/src/xslt/stylesheetChecks.js +206 -0
  119. package/src/xslt/stylesheetNamespaces.js +266 -0
  120. package/src/xslt/variables.js +152 -0
  121. package/src/xslt/whitespace.js +43 -27
  122. package/LICENSE +0 -29
@@ -8,6 +8,118 @@
8
8
  "use strict";
9
9
 
10
10
  import { NodeType } from "./parser.js";
11
+ import {
12
+ AXIS_WALKERS,
13
+ REVERSE_AXES,
14
+ ancestorAxis,
15
+ attributeAxis,
16
+ childAxis,
17
+ descendantAxis,
18
+ followingAxis,
19
+ followingSiblingAxis,
20
+ precedingAxis,
21
+ precedingSiblingAxis,
22
+ isTextNode,
23
+ parentOf,
24
+ rootNodeOf,
25
+ } from "./axes.js";
26
+ import {
27
+ NAMESPACE_NODE,
28
+ matchNamespaceNameTest,
29
+ namespaceAxis,
30
+ } from "./namespaceNodes.js";
31
+ import {
32
+ DocumentOrderIndex,
33
+ compareDomPositions,
34
+ compareNodeOrder,
35
+ hasNativePositionComparison,
36
+ } from "./documentOrder.js";
37
+ import { createNodeSetFunctions } from "./nodeSetFunctions.js";
38
+ import {
39
+ codePointLength,
40
+ formatXPathNumber,
41
+ normalizeXmlSpace,
42
+ parseXPathNumber,
43
+ xpathSubstring,
44
+ xpathTranslate,
45
+ } from "./strings.js";
46
+
47
+ /**
48
+ * Namespace the `xml` prefix is bound to in every context; it cannot be
49
+ * undeclared or rebound (Namespaces in XML 1.0, section 3).
50
+ */
51
+ export const XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace";
52
+
53
+ /**
54
+ * Resolve a namespace prefix: `xml` is predeclared, every other prefix comes
55
+ * from the context bindings.
56
+ *
57
+ * @param {string} prefix - The prefix
58
+ * @param {Object<string, string>} namespaces - Bindings by prefix
59
+ * @returns {string|null} The namespace URI, or null when undeclared
60
+ */
61
+ export function resolveNamespacePrefix(prefix, namespaces) {
62
+ if (prefix === "xml") return XML_NAMESPACE;
63
+ return Object.hasOwn(namespaces, prefix) ? namespaces[prefix] : null;
64
+ }
65
+
66
+ /**
67
+ * Key of a function registered by expanded name, as used by
68
+ * {@link XPathEvaluator#registerFunctions}: `{namespace-uri}local-name`.
69
+ *
70
+ * @param {string} namespaceUri - The function's namespace
71
+ * @param {string} localName - The function's local name
72
+ * @returns {string} The registry key
73
+ *
74
+ * @example
75
+ * expandedFunctionName("http://exslt.org/common", "node-set");
76
+ * // "{http://exslt.org/common}node-set"
77
+ */
78
+ export function expandedFunctionName(namespaceUri, localName) {
79
+ return `{${namespaceUri}}${localName}`;
80
+ }
81
+
82
+ /**
83
+ * Whether an expression is a call of the core `position()` function.
84
+ *
85
+ * @param {object} expr - Expression AST node
86
+ * @returns {boolean} True for `position()`
87
+ */
88
+ function isPositionCall(expr) {
89
+ return (
90
+ expr.type === NodeType.FUNCTION_CALL &&
91
+ expr.name === "position" &&
92
+ !expr.prefix &&
93
+ expr.args.length === 0
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Whether a node has the principal node type of an axis, the only type a
99
+ * name test can match there: attributes on the attribute axis, namespace
100
+ * nodes on the namespace axis, elements elsewhere (XPath 1.0 section 2.3).
101
+ *
102
+ * @param {number} type - The candidate node's `nodeType`
103
+ * @param {string|null} axis - The axis, or null when the caller checks types
104
+ * @returns {boolean} True when a name test may match the node
105
+ */
106
+ function isPrincipalNodeType(type, axis) {
107
+ if (axis === null) return true;
108
+ if (axis === "attribute") return type === 2;
109
+ if (axis === "namespace") return type === NAMESPACE_NODE;
110
+ return type === 1;
111
+ }
112
+
113
+ /**
114
+ * Whether a node belongs to an HTML document (`contentType` "text/html").
115
+ * Two jsdom getters, so name tests read it last.
116
+ *
117
+ * @param {Node} node - An element
118
+ * @returns {boolean} True for a node of an HTML document
119
+ */
120
+ function isInHtmlDocument(node) {
121
+ return node.ownerDocument?.contentType === "text/html";
122
+ }
11
123
 
12
124
  /**
13
125
  * XPath result types matching W3C spec
@@ -92,7 +204,18 @@ export class XPathContext {
92
204
  * XPath Evaluator
93
205
  */
94
206
  export class XPathEvaluator {
207
+ /**
208
+ * @param {object} [options] - Evaluator options
209
+ * @param {number} [options.maxRecursionDepth] - Deepest expression nesting
210
+ * @param {number} [options.maxResultSize] - Largest node-set of one step
211
+ * @param {number} [options.maxStringLength] - Longest string literal
212
+ * @param {boolean} [options.legacyNameTests] - Deprecated: let unprefixed
213
+ * name tests also match nodes in a namespace, as before 1.2.0
214
+ */
95
215
  constructor(options = {}) {
216
+ this.legacyNameTests = options.legacyNameTests === true;
217
+ this.resetNamespaceNodes();
218
+ this.resetDocumentOrder();
96
219
  this.functions = Object.assign(
97
220
  Object.create(null),
98
221
  this.initCoreFunctions(),
@@ -105,6 +228,18 @@ export class XPathEvaluator {
105
228
  this.recursionDepth = 0;
106
229
  }
107
230
 
231
+ /**
232
+ * Forget the namespace nodes synthesized so far (see namespaceNodes.js),
233
+ * e.g. before a new transformation: the declarations of the source tree
234
+ * may have changed. Until then each element keeps the same namespace node
235
+ * objects, so they have a stable identity.
236
+ *
237
+ * @returns {void}
238
+ */
239
+ resetNamespaceNodes() {
240
+ this.namespaceNodes = new WeakMap();
241
+ }
242
+
108
243
  /**
109
244
  * Evaluate XPath expression against a context
110
245
  * @throws {Error} If recursion depth exceeds limit
@@ -119,6 +254,11 @@ export class XPathEvaluator {
119
254
  throw new Error("Invalid AST: missing type property");
120
255
  }
121
256
 
257
+ // An index created during an earlier evaluation may describe trees
258
+ // that have changed since; one set with resetDocumentOrder() stays
259
+ if (this.recursionDepth === 0 && this.ownsDocumentOrder) {
260
+ this.resetDocumentOrder();
261
+ }
122
262
  this.recursionDepth++;
123
263
  if (this.recursionDepth > this.maxRecursionDepth) {
124
264
  this.recursionDepth = 0;
@@ -144,9 +284,8 @@ export class XPathEvaluator {
144
284
  case NodeType.AND_EXPR:
145
285
  return this.evalAndExpr(ast, context);
146
286
  case NodeType.EQUALITY_EXPR:
147
- return this.evalEqualityExpr(ast, context);
148
287
  case NodeType.RELATIONAL_EXPR:
149
- return this.evalRelationalExpr(ast, context);
288
+ return this.evalComparisonExpr(ast, context);
150
289
  case NodeType.ADDITIVE_EXPR:
151
290
  return this.evalAdditiveExpr(ast, context);
152
291
  case NodeType.MULTIPLICATIVE_EXPR:
@@ -210,14 +349,15 @@ export class XPathEvaluator {
210
349
  );
211
350
  }
212
351
 
213
- evalEqualityExpr(ast, context) {
214
- const left = this.evaluate(ast.left, context);
215
- const right = this.evaluate(ast.right, context);
216
- const isEqual = this.compareValues(left, right, "=");
217
- return ast.operator === "=" ? isEqual : !isEqual;
218
- }
219
-
220
- evalRelationalExpr(ast, context) {
352
+ /**
353
+ * Evaluate `=`, `!=`, `<`, `<=`, `>` or `>=`. Comparisons involving
354
+ * node-sets are existential, so `!=` is not `not(=)` (XPath 3.4).
355
+ *
356
+ * @param {object} ast - Equality or relational expression node
357
+ * @param {XPathContext} context - Evaluation context
358
+ * @returns {boolean} The comparison result
359
+ */
360
+ evalComparisonExpr(ast, context) {
221
361
  const left = this.evaluate(ast.left, context);
222
362
  const right = this.evaluate(ast.right, context);
223
363
  return this.compareValues(left, right, ast.operator);
@@ -298,10 +438,9 @@ export class XPathEvaluator {
298
438
  let nodes;
299
439
 
300
440
  if (ast.absolute) {
301
- // Start from document root node (not document element)
302
- // XPath absolute paths start from the document node
303
- const doc = context.node.ownerDocument || context.node;
304
- nodes = [doc];
441
+ // The root node of the tree containing the context node: a document,
442
+ // or the fragment of a result tree fragment turned into a node-set
443
+ nodes = [rootNodeOf(context.node)];
305
444
  } else {
306
445
  nodes = [context.node];
307
446
  }
@@ -314,192 +453,190 @@ export class XPathEvaluator {
314
453
  return nodes;
315
454
  }
316
455
 
456
+ /**
457
+ * Apply one location step to every node of a node-set.
458
+ *
459
+ * The result is in document order without duplicates. With a single context
460
+ * node the axis output is already duplicate free, so it only needs to be
461
+ * reversed for reverse axes; merging several context nodes needs a sort.
462
+ *
463
+ * @param {object} step - Step AST node
464
+ * @param {Node|Node[]} nodes - Context nodes
465
+ * @param {XPathContext} context - Evaluation context
466
+ * @returns {Node[]} The selected nodes in document order
467
+ */
317
468
  evalStepOnNodes(step, nodes, context) {
318
469
  const allNodes = Array.isArray(nodes) ? nodes : [nodes];
319
- let result = [];
470
+ const reverse = REVERSE_AXES.has(step.axis);
320
471
 
321
- for (const node of allNodes) {
322
- const stepNodes = this.evalStep(step, context.clone({ node }));
323
- result = result.concat(stepNodes);
472
+ if (allNodes.length === 1) {
473
+ const single = this.evalStep(step, context.clone({ node: allNodes[0] }));
474
+ this.validateResultSize(single);
475
+ return reverse ? single.reverse() : single;
476
+ }
324
477
 
325
- // Early validation to prevent excessive memory use
326
- if (result.length > this.maxResultSize * 2) {
327
- this.validateResultSize(result);
478
+ const seen = new Set();
479
+ const result = [];
480
+ for (const node of allNodes) {
481
+ for (const found of this.evalStep(step, context.clone({ node }))) {
482
+ if (!seen.has(found)) {
483
+ seen.add(found);
484
+ result.push(found);
485
+ }
328
486
  }
487
+ this.validateResultSize(result);
329
488
  }
330
489
 
331
- // Remove duplicates and sort by document order
332
- const uniqueResult = [...new Set(result)];
333
- this.validateResultSize(uniqueResult);
334
- return this.sortByDocumentOrder(uniqueResult);
490
+ return this.sortByDocumentOrder(result);
335
491
  }
336
492
 
493
+ /**
494
+ * Apply one location step to a single context node.
495
+ *
496
+ * When the first predicate selects one position (`[n]` or
497
+ * `[position() = n]`) and the axis can be walked lazily, the walk stops at
498
+ * the n-th node that passes the node test instead of materializing the
499
+ * whole axis: `following-sibling::x[1]` in a loop is then linear.
500
+ *
501
+ * @param {object} step - Step AST node
502
+ * @param {XPathContext} context - Evaluation context
503
+ * @returns {Node[]} The selected nodes in axis order
504
+ */
337
505
  evalStep(step, context) {
338
- // Get nodes along axis
339
- let nodes = this.getAxisNodes(step.axis, context.node);
506
+ const { predicates } = step;
507
+ const position = this.positionalPredicate(step);
508
+ let nodes;
509
+ let first = 0;
340
510
 
341
- // Filter by node test
342
- nodes = nodes.filter((n) => this.matchNodeTest(step.nodeTest, n, context));
511
+ if (position !== null && Object.hasOwn(AXIS_WALKERS, step.axis)) {
512
+ nodes = this.nthOnAxis(step, context, position);
513
+ first = 1;
514
+ } else {
515
+ nodes = this.getAxisNodes(step.axis, context.node).filter((n) =>
516
+ this.matchNodeTest(step.nodeTest, n, context, step.axis),
517
+ );
518
+ }
343
519
 
344
- // Apply predicates
345
- for (const predicate of step.predicates) {
346
- nodes = this.filterByPredicate(nodes, predicate, context);
520
+ for (let i = first; i < predicates.length; i++) {
521
+ nodes = this.filterByPredicate(nodes, predicates[i], context);
347
522
  }
348
523
 
349
524
  return nodes;
350
525
  }
351
526
 
527
+ /**
528
+ * The position selected by the first predicate of a step, when that
529
+ * predicate is a number literal `[n]` or `[position() = n]`.
530
+ *
531
+ * @param {object} step - Step AST node
532
+ * @returns {number|null} The position, or null for any other predicate
533
+ */
534
+ positionalPredicate(step) {
535
+ const expr = step.predicates[0]?.expr;
536
+ if (!expr) return null;
537
+ if (expr.type === NodeType.NUMBER) return expr.value;
538
+ if (expr.type !== NodeType.EQUALITY_EXPR || expr.operator !== "=") {
539
+ return null;
540
+ }
541
+ const { left, right } = expr;
542
+ if (isPositionCall(left) && right.type === NodeType.NUMBER) {
543
+ return right.value;
544
+ }
545
+ if (isPositionCall(right) && left.type === NodeType.NUMBER) {
546
+ return left.value;
547
+ }
548
+ return null;
549
+ }
550
+
551
+ /**
552
+ * The n-th node of an axis passing the node test of a step, found by
553
+ * walking the axis only as far as needed.
554
+ *
555
+ * @param {object} step - Step AST node
556
+ * @param {XPathContext} context - Evaluation context
557
+ * @param {number} position - The wanted proximity position
558
+ * @returns {Node[]} The node, or an empty node-set
559
+ */
560
+ nthOnAxis(step, context, position) {
561
+ // Positions are whole numbers from 1: [0], [1.5] or [NaN] select nothing
562
+ if (!Number.isInteger(position) || position < 1) return [];
563
+ let remaining = position;
564
+ let found = null;
565
+ AXIS_WALKERS[step.axis](context.node, (node) => {
566
+ if (!this.matchNodeTest(step.nodeTest, node, context, step.axis)) {
567
+ return false;
568
+ }
569
+ remaining--;
570
+ if (remaining > 0) return false;
571
+ found = node;
572
+ return true;
573
+ });
574
+ return found ? [found] : [];
575
+ }
576
+
577
+ /**
578
+ * Nodes on an axis (see axes.js). Reverse axes are returned nearest first,
579
+ * so predicates see proximity positions.
580
+ *
581
+ * @param {string} axis - Axis name
582
+ * @param {Node} node - Context node
583
+ * @returns {Node[]} The nodes on the axis
584
+ */
352
585
  getAxisNodes(axis, node) {
353
586
  switch (axis) {
354
587
  case "child":
355
- return Array.from(node.childNodes || []);
356
-
357
- case "parent":
358
- return node.parentNode ? [node.parentNode] : [];
359
-
588
+ return childAxis(node);
589
+ case "parent": {
590
+ const parent = parentOf(node);
591
+ return parent ? [parent] : [];
592
+ }
360
593
  case "self":
361
594
  return [node];
362
-
363
595
  case "descendant":
364
- return this.getDescendants(node, false);
365
-
596
+ return descendantAxis(node, false);
366
597
  case "descendant-or-self":
367
- return this.getDescendants(node, true);
368
-
598
+ return descendantAxis(node, true);
369
599
  case "ancestor":
370
- return this.getAncestors(node, false);
371
-
600
+ return ancestorAxis(node, false);
372
601
  case "ancestor-or-self":
373
- return this.getAncestors(node, true);
374
-
602
+ return ancestorAxis(node, true);
375
603
  case "following-sibling":
376
- return this.getFollowingSiblings(node);
377
-
604
+ return followingSiblingAxis(node);
378
605
  case "preceding-sibling":
379
- return this.getPrecedingSiblings(node);
380
-
606
+ return precedingSiblingAxis(node);
381
607
  case "following":
382
- return this.getFollowing(node);
383
-
608
+ return followingAxis(node);
384
609
  case "preceding":
385
- return this.getPreceding(node);
386
-
610
+ return precedingAxis(node);
387
611
  case "attribute":
388
- if (node.attributes) {
389
- return Array.from(node.attributes);
390
- }
391
- return [];
392
-
612
+ return attributeAxis(node);
393
613
  case "namespace":
394
- // Namespace axis - not commonly used
395
- return [];
396
-
614
+ return namespaceAxis(node, this.namespaceNodes);
397
615
  default:
398
616
  throw new Error(`Unknown axis: ${axis}`);
399
617
  }
400
618
  }
401
619
 
402
- getDescendants(node, includeSelf) {
403
- const result = includeSelf ? [node] : [];
404
- const stack = Array.from(node.childNodes || []).reverse();
405
-
406
- while (stack.length > 0) {
407
- const current = stack.pop();
408
- result.push(current);
409
- if (current.childNodes) {
410
- for (let i = current.childNodes.length - 1; i >= 0; i--) {
411
- stack.push(current.childNodes[i]);
412
- }
413
- }
414
- }
415
-
416
- return result;
417
- }
418
-
419
- getAncestors(node, includeSelf) {
420
- const result = includeSelf ? [node] : [];
421
- let current = node.parentNode;
422
-
423
- while (current) {
424
- result.push(current);
425
- current = current.parentNode;
426
- }
427
-
428
- return result;
429
- }
430
-
431
- getFollowingSiblings(node) {
432
- const result = [];
433
- if (!node) return result;
434
- let current = node.nextSibling;
435
-
436
- while (current) {
437
- result.push(current);
438
- current = current.nextSibling;
439
- }
440
-
441
- return result;
442
- }
443
-
444
- getPrecedingSiblings(node) {
445
- const result = [];
446
- if (!node) return result;
447
- let current = node.previousSibling;
448
-
449
- while (current) {
450
- result.push(current);
451
- current = current.previousSibling;
452
- }
453
-
454
- return result.reverse();
455
- }
456
-
457
- getFollowing(node) {
458
- const result = [];
459
- let current = node;
460
-
461
- // Go to next sibling, or ancestor's next sibling
462
- while (current) {
463
- if (current.nextSibling) {
464
- current = current.nextSibling;
465
- result.push(current);
466
- // Add all descendants
467
- result.push(...this.getDescendants(current, false));
468
- } else {
469
- current = current.parentNode;
470
- }
471
- }
472
-
473
- return result;
474
- }
475
-
476
- getPreceding(node) {
477
- const result = [];
478
- let current = node;
479
-
480
- while (current) {
481
- if (current.previousSibling) {
482
- current = current.previousSibling;
483
- // Add descendants in reverse order, then the node
484
- const descendants = this.getDescendants(current, false);
485
- result.unshift(...descendants.reverse());
486
- result.unshift(current);
487
- } else {
488
- current = current.parentNode;
489
- if (current && current.nodeType !== 9) {
490
- // Not document
491
- // Don't add ancestors
492
- }
493
- }
494
- }
495
-
496
- return result;
497
- }
498
-
499
- matchNodeTest(nodeTest, node, context) {
620
+ /**
621
+ * Match a node test against a node.
622
+ *
623
+ * @param {object} nodeTest - Node test AST node
624
+ * @param {Node} node - Candidate node
625
+ * @param {XPathContext} context - Evaluation context (for prefixes)
626
+ * @param {string|null} [axis] - Axis of the step, which decides the
627
+ * principal node type of name tests; null for XSLT patterns, whose
628
+ * matcher checks node types itself
629
+ * @returns {boolean} Whether the node matches
630
+ */
631
+ matchNodeTest(nodeTest, node, context, axis = null) {
500
632
  switch (nodeTest.type) {
501
- case NodeType.NAME_TEST:
502
- return this.matchNameTest(nodeTest, node, context);
633
+ case NodeType.NAME_TEST: {
634
+ const type = node.nodeType;
635
+ return (
636
+ isPrincipalNodeType(type, axis) &&
637
+ this.matchNameTest(nodeTest, node, context, type)
638
+ );
639
+ }
503
640
 
504
641
  case NodeType.NODE_TYPE_TEST:
505
642
  return this.matchNodeTypeTest(nodeTest.nodeType, node);
@@ -512,43 +649,64 @@ export class XPathEvaluator {
512
649
  }
513
650
  }
514
651
 
515
- matchNameTest(nodeTest, node, context) {
652
+ /**
653
+ * Match a name test against a node.
654
+ *
655
+ * Cheap checks run first: in jsdom every DOM getter crosses a wrapper, so
656
+ * `namespaceURI` and the owner document's content type are only read when
657
+ * the outcome depends on them.
658
+ *
659
+ * @param {object} nodeTest - Name test AST node
660
+ * @param {Node} node - Candidate node
661
+ * @param {XPathContext} context - Evaluation context (for prefixes)
662
+ * @param {number} [type] - The node's `nodeType`, when already read
663
+ * @returns {boolean} Whether the node matches
664
+ */
665
+ matchNameTest(nodeTest, node, context, type = node.nodeType) {
666
+ if (type === NAMESPACE_NODE) return matchNamespaceNameTest(nodeTest, node);
516
667
  // Only element and attribute nodes have names
517
- if (node.nodeType !== 1 && node.nodeType !== 2) {
518
- return false;
519
- }
668
+ if (type !== 1 && type !== 2) return false;
520
669
 
521
- const name = nodeTest.name;
522
- const prefix = nodeTest.prefix;
670
+ const { name, prefix } = nodeTest;
523
671
 
524
- // Wildcard
525
- if (name === "*" && !prefix) {
526
- return true;
527
- }
528
-
529
- // Get node's local name and namespace
530
- const nodeName = node.localName || node.nodeName;
531
- const nodeNs = node.namespaceURI || null;
672
+ if (!prefix) return this.matchUnprefixedName(name, node, type);
532
673
 
533
- // Prefix:* matches all nodes in namespace
534
- if (name === "*" && prefix) {
535
- const ns = context.namespaces[prefix];
536
- return nodeNs === ns;
674
+ const namespaceUri = resolveNamespacePrefix(prefix, context.namespaces);
675
+ if (!namespaceUri) {
676
+ // An undeclared prefix is an error (XPath 1.0 section 2.3), as in
677
+ // libxslt; it must not silently match names in no namespace
678
+ throw new Error(`Undefined namespace prefix: ${prefix}`);
537
679
  }
680
+ if ((node.namespaceURI || null) !== namespaceUri) return false;
681
+ return name === "*" || (node.localName || node.nodeName) === name;
682
+ }
538
683
 
539
- // Simple name match
540
- if (!prefix) {
541
- // For elements, match local name (case-insensitive for HTML)
542
- const doc = node.ownerDocument;
543
- if (doc && doc.contentType === "text/html" && node.nodeType === 1) {
544
- return nodeName.toLowerCase() === name.toLowerCase();
545
- }
546
- return nodeName === name;
684
+ /**
685
+ * Match a name test without prefix (`name` or `*`) against an element or
686
+ * attribute. The owner document's content type (two jsdom getters) is
687
+ * read last, only for HTML-specific outcomes.
688
+ *
689
+ * @param {string} name - The tested name, or "*"
690
+ * @param {Node} node - An element or attribute
691
+ * @param {number} type - The node's `nodeType`
692
+ * @returns {boolean} Whether the node matches
693
+ */
694
+ matchUnprefixedName(name, node, type) {
695
+ if (name === "*") return true;
696
+ const nodeName = node.localName || node.nodeName;
697
+ if (nodeName !== name) {
698
+ // HTML documents match element names case-insensitively
699
+ return (
700
+ type === 1 &&
701
+ nodeName.toLowerCase() === name.toLowerCase() &&
702
+ isInHtmlDocument(node)
703
+ );
547
704
  }
548
-
549
- // Prefixed name match
550
- const ns = context.namespaces[prefix];
551
- return nodeName === name && nodeNs === ns;
705
+ // A QName without prefix only matches nodes in no namespace (2.3).
706
+ // HTML elements are in the XHTML namespace in the DOM but in none for
707
+ // libxslt, which receives HTML documents re-parsed from their markup.
708
+ if (this.legacyNameTests || node.namespaceURI === null) return true;
709
+ return type === 1 && isInHtmlDocument(node);
552
710
  }
553
711
 
554
712
  matchNodeTypeTest(nodeType, node) {
@@ -619,11 +777,17 @@ export class XPathEvaluator {
619
777
  * core function. Each function is called as `fn(args, context)` with the
620
778
  * evaluator as `this`.
621
779
  *
780
+ * Extension functions are keyed by expanded name, `{namespace-uri}local`
781
+ * (see {@link expandedFunctionName}), and are found through whatever prefix
782
+ * the expression binds to that namespace. A literal `prefix:local` key is
783
+ * still honoured when the prefix does not resolve to a registered function.
784
+ *
622
785
  * @param {Object<string, Function>} functions - Functions by name
623
786
  * @returns {XPathEvaluator} This evaluator, to allow chaining
624
787
  *
625
788
  * @example
626
- * evaluator.registerFunctions({ 'my:double': (args, ctx) => 2 });
789
+ * evaluator.registerFunctions({ '{urn:my}double': (args, ctx) => 2 });
790
+ * // callable as my:double() when my is bound to urn:my
627
791
  */
628
792
  registerFunctions(functions) {
629
793
  for (const [name, fn] of Object.entries(functions)) {
@@ -632,14 +796,58 @@ export class XPathEvaluator {
632
796
  return this;
633
797
  }
634
798
 
799
+ /**
800
+ * Find the implementation of a possibly prefixed function name.
801
+ *
802
+ * @param {string} localName - The local part of the function name
803
+ * @param {string|null} prefix - The prefix, or null for core functions
804
+ * @param {Object<string, string>} namespaces - Prefix bindings
805
+ * @returns {Function|null} The function, or null when none is registered
806
+ */
807
+ resolveFunction(localName, prefix, namespaces) {
808
+ if (!prefix) return this.getFunction(localName);
809
+
810
+ const namespaceUri = resolveNamespacePrefix(prefix, namespaces);
811
+ const byUri =
812
+ namespaceUri === null
813
+ ? null
814
+ : this.getFunction(expandedFunctionName(namespaceUri, localName));
815
+ return byUri ?? this.getFunction(`${prefix}:${localName}`);
816
+ }
817
+
818
+ /**
819
+ * Whether a function is registered under an expanded name.
820
+ *
821
+ * @param {string} localName - The local part of the function name
822
+ * @param {string|null} [namespaceUri] - The namespace, null for core functions
823
+ * @returns {boolean} True when the function exists
824
+ */
825
+ hasFunction(localName, namespaceUri = null) {
826
+ const key = namespaceUri
827
+ ? expandedFunctionName(namespaceUri, localName)
828
+ : localName;
829
+ return this.getFunction(key) !== null;
830
+ }
831
+
832
+ /**
833
+ * Own entry of the function table, never an inherited property.
834
+ *
835
+ * @param {string} key - Registry key
836
+ * @returns {Function|null} The function, or null
837
+ */
838
+ getFunction(key) {
839
+ return Object.hasOwn(this.functions, key) ? this.functions[key] : null;
840
+ }
841
+
635
842
  evalFunctionCall(ast, context) {
636
- const name = ast.prefix ? `${ast.prefix}:${ast.name}` : ast.name;
843
+ const fn = this.resolveFunction(ast.name, ast.prefix, context.namespaces);
637
844
 
638
- if (!Object.hasOwn(this.functions, name)) {
845
+ if (!fn) {
846
+ const name = ast.prefix ? `${ast.prefix}:${ast.name}` : ast.name;
639
847
  throw new Error(`Unknown function: ${name}`);
640
848
  }
641
849
 
642
- return this.functions[name].call(this, ast.args, context);
850
+ return fn.call(this, ast.args, context);
643
851
  }
644
852
 
645
853
  // Type conversion functions
@@ -655,12 +863,7 @@ export class XPathEvaluator {
655
863
  toNumber(value) {
656
864
  if (typeof value === "number") return value;
657
865
  if (typeof value === "boolean") return value ? 1 : 0;
658
- if (typeof value === "string") {
659
- const trimmed = value.trim();
660
- if (trimmed === "") return NaN;
661
- const num = Number(trimmed);
662
- return num;
663
- }
866
+ if (typeof value === "string") return parseXPathNumber(value);
664
867
  if (Array.isArray(value)) {
665
868
  return this.toNumber(this.toString(value));
666
869
  }
@@ -672,13 +875,7 @@ export class XPathEvaluator {
672
875
 
673
876
  toString(value) {
674
877
  if (typeof value === "string") return value;
675
- if (typeof value === "number") {
676
- if (isNaN(value)) return "NaN";
677
- if (value === Infinity) return "Infinity";
678
- if (value === -Infinity) return "-Infinity";
679
- if (value === 0) return "0";
680
- return String(value);
681
- }
878
+ if (typeof value === "number") return formatXPathNumber(value);
682
879
  if (typeof value === "boolean") return value ? "true" : "false";
683
880
  if (Array.isArray(value)) {
684
881
  if (value.length === 0) return "";
@@ -712,11 +909,21 @@ export class XPathEvaluator {
712
909
  return text;
713
910
  }
714
911
 
715
- case 2: // Attribute
716
912
  case 3: // Text
717
- case 4: // CDATA
913
+ case 4: {
914
+ // CDATA. A run of adjacent text nodes is one XPath text node (5.7)
915
+ let text = node.nodeValue;
916
+ for (let next = node.nextSibling; isTextNode(next);) {
917
+ text += next.nodeValue;
918
+ next = next.nextSibling;
919
+ }
920
+ return text;
921
+ }
922
+
923
+ case 2: // Attribute
718
924
  case 7: // Processing Instruction
719
925
  case 8: // Comment
926
+ case NAMESPACE_NODE: // The namespace URI
720
927
  return node.nodeValue || "";
721
928
 
722
929
  default:
@@ -729,6 +936,18 @@ export class XPathEvaluator {
729
936
  const leftIsNodeSet = Array.isArray(left);
730
937
  const rightIsNodeSet = Array.isArray(right);
731
938
 
939
+ // A node-set compared with a boolean is converted with boolean() (3.4)
940
+ if (
941
+ (leftIsNodeSet && typeof right === "boolean") ||
942
+ (rightIsNodeSet && typeof left === "boolean")
943
+ ) {
944
+ return this.comparePrimitive(
945
+ this.toBoolean(left),
946
+ this.toBoolean(right),
947
+ operator,
948
+ );
949
+ }
950
+
732
951
  // Node-set comparisons
733
952
  if (leftIsNodeSet && rightIsNodeSet) {
734
953
  for (const l of left) {
@@ -804,46 +1023,46 @@ export class XPathEvaluator {
804
1023
  }
805
1024
  }
806
1025
 
1026
+ /**
1027
+ * Sort nodes in document order; attributes and namespace nodes sort after
1028
+ * their element and before its children (see documentOrder.js).
1029
+ *
1030
+ * With a {@link DocumentOrderIndex} (set by the XSLT engine for each
1031
+ * transformation, or created on first use when the DOM has no
1032
+ * `compareDocumentPosition`, as xmldom 0.8), nodes are sorted by their
1033
+ * precomputed positions; otherwise through `compareDocumentPosition`.
1034
+ *
1035
+ * @param {Node[]} nodes - The nodes, sorted in place
1036
+ * @returns {Node[]} The same array
1037
+ */
807
1038
  sortByDocumentOrder(nodes) {
808
1039
  if (nodes.length <= 1) return nodes;
1040
+ // Number the trees once per evaluation unless the DOM compares positions
1041
+ // natively: compareDocumentPosition written in JavaScript (xmldom,
1042
+ // jsdom) costs a tree walk per comparison
1043
+ if (!this.documentOrder && !hasNativePositionComparison(nodes[0])) {
1044
+ this.documentOrder = new DocumentOrderIndex();
1045
+ this.ownsDocumentOrder = true;
1046
+ }
1047
+ if (this.documentOrder) return this.documentOrder.sort(nodes);
809
1048
 
810
- return nodes.sort((a, b) => {
811
- if (a === b) return 0;
812
-
813
- const position = a.compareDocumentPosition
814
- ? a.compareDocumentPosition(b)
815
- : this.compareDocumentPositionFallback(a, b);
816
-
817
- if (position & 4) return -1; // a before b
818
- if (position & 2) return 1; // a after b
819
- return 0;
820
- });
1049
+ return nodes.sort((a, b) =>
1050
+ a === b ? 0 : compareNodeOrder(a, b, compareDomPositions),
1051
+ );
821
1052
  }
822
1053
 
823
- compareDocumentPositionFallback(a, b) {
824
- // Simple fallback for environments without compareDocumentPosition
825
- const getPath = (node) => {
826
- const path = [];
827
- let current = node;
828
- while (current) {
829
- if (current.parentNode) {
830
- const siblings = Array.from(current.parentNode.childNodes);
831
- path.unshift(siblings.indexOf(current));
832
- }
833
- current = current.parentNode;
834
- }
835
- return path;
836
- };
837
-
838
- const pathA = getPath(a);
839
- const pathB = getPath(b);
840
-
841
- for (let i = 0; i < Math.min(pathA.length, pathB.length); i++) {
842
- if (pathA[i] < pathB[i]) return 4; // a before b
843
- if (pathA[i] > pathB[i]) return 2; // a after b
844
- }
845
-
846
- return pathA.length < pathB.length ? 4 : 2;
1054
+ /**
1055
+ * Use a new {@link DocumentOrderIndex} from now on, e.g. for a new
1056
+ * transformation (the trees may have changed since the previous one), or
1057
+ * none (null): `compareDocumentPosition` then orders the nodes whenever
1058
+ * the DOM has it.
1059
+ *
1060
+ * @param {DocumentOrderIndex|null} [index] - The index to use
1061
+ * @returns {void}
1062
+ */
1063
+ resetDocumentOrder(index = null) {
1064
+ this.documentOrder = index;
1065
+ this.ownsDocumentOrder = false;
847
1066
  }
848
1067
 
849
1068
  /**
@@ -854,54 +1073,7 @@ export class XPathEvaluator {
854
1073
  // Node set functions
855
1074
  last: (args, ctx) => ctx.size,
856
1075
  position: (args, ctx) => ctx.position,
857
- count: (args, ctx) => {
858
- const nodeSet = this.evaluate(args[0], ctx);
859
- return Array.isArray(nodeSet) ? nodeSet.length : 1;
860
- },
861
- id: (args, ctx) => {
862
- const value = this.toString(this.evaluate(args[0], ctx));
863
- const doc = ctx.node.ownerDocument || ctx.node;
864
- const ids = value.split(/\s+/).filter((id) => id);
865
- const result = [];
866
- for (const id of ids) {
867
- const el = doc.getElementById(id);
868
- if (el) result.push(el);
869
- }
870
- return result;
871
- },
872
- "local-name": (args, ctx) => {
873
- let node;
874
- if (args.length === 0) {
875
- node = ctx.node;
876
- } else {
877
- const nodeSet = this.evaluate(args[0], ctx);
878
- node = Array.isArray(nodeSet) ? nodeSet[0] : nodeSet;
879
- }
880
- if (!node) return "";
881
- return node.localName || node.nodeName || "";
882
- },
883
- "namespace-uri": (args, ctx) => {
884
- let node;
885
- if (args.length === 0) {
886
- node = ctx.node;
887
- } else {
888
- const nodeSet = this.evaluate(args[0], ctx);
889
- node = Array.isArray(nodeSet) ? nodeSet[0] : nodeSet;
890
- }
891
- if (!node) return "";
892
- return node.namespaceURI || "";
893
- },
894
- name: (args, ctx) => {
895
- let node;
896
- if (args.length === 0) {
897
- node = ctx.node;
898
- } else {
899
- const nodeSet = this.evaluate(args[0], ctx);
900
- node = Array.isArray(nodeSet) ? nodeSet[0] : nodeSet;
901
- }
902
- if (!node) return "";
903
- return node.nodeName || "";
904
- },
1076
+ ...createNodeSetFunctions(this),
905
1077
 
906
1078
  // String functions
907
1079
  string: (args, ctx) => {
@@ -939,61 +1111,32 @@ export class XPathEvaluator {
939
1111
  },
940
1112
  substring: (args, ctx) => {
941
1113
  const str = this.toString(this.evaluate(args[0], ctx));
942
- let start = Math.round(this.toNumber(this.evaluate(args[1], ctx)));
943
- let length;
944
-
945
- if (args.length > 2) {
946
- length = Math.round(this.toNumber(this.evaluate(args[2], ctx)));
947
- }
948
-
949
- // XPath uses 1-based indexing
950
- start = start - 1;
951
-
952
- if (isNaN(start)) return "";
953
- if (start < 0) {
954
- if (length !== undefined) {
955
- length = length + start;
956
- }
957
- start = 0;
958
- }
959
-
960
- if (length !== undefined) {
961
- if (isNaN(length) || length <= 0) return "";
962
- return str.substring(start, start + length);
963
- }
964
-
965
- return str.substring(start);
1114
+ const start = this.toNumber(this.evaluate(args[1], ctx));
1115
+ const length =
1116
+ args.length > 2
1117
+ ? this.toNumber(this.evaluate(args[2], ctx))
1118
+ : undefined;
1119
+ return xpathSubstring(str, start, length);
966
1120
  },
967
1121
  "string-length": (args, ctx) => {
968
1122
  const str =
969
1123
  args.length === 0
970
1124
  ? this.toString([ctx.node])
971
1125
  : this.toString(this.evaluate(args[0], ctx));
972
- return str.length;
1126
+ return codePointLength(str);
973
1127
  },
974
1128
  "normalize-space": (args, ctx) => {
975
1129
  const str =
976
1130
  args.length === 0
977
1131
  ? this.toString([ctx.node])
978
1132
  : this.toString(this.evaluate(args[0], ctx));
979
- return str.trim().replace(/\s+/g, " ");
1133
+ return normalizeXmlSpace(str);
980
1134
  },
981
1135
  translate: (args, ctx) => {
982
1136
  const str = this.toString(this.evaluate(args[0], ctx));
983
1137
  const from = this.toString(this.evaluate(args[1], ctx));
984
1138
  const to = this.toString(this.evaluate(args[2], ctx));
985
-
986
- let result = "";
987
- for (const char of str) {
988
- const idx = from.indexOf(char);
989
- if (idx === -1) {
990
- result += char;
991
- } else if (idx < to.length) {
992
- result += to[idx];
993
- }
994
- // If idx >= to.length, character is removed
995
- }
996
- return result;
1139
+ return xpathTranslate(str, from, to);
997
1140
  },
998
1141
 
999
1142
  // Boolean functions
@@ -1005,21 +1148,6 @@ export class XPathEvaluator {
1005
1148
  },
1006
1149
  true: () => true,
1007
1150
  false: () => false,
1008
- lang: (args, ctx) => {
1009
- const lang = this.toString(this.evaluate(args[0], ctx)).toLowerCase();
1010
- let node = ctx.node;
1011
-
1012
- while (node && node.nodeType === 1) {
1013
- const xmlLang =
1014
- node.getAttribute("xml:lang") || node.getAttribute("lang");
1015
- if (xmlLang) {
1016
- const nodeLang = xmlLang.toLowerCase();
1017
- return nodeLang === lang || nodeLang.startsWith(lang + "-");
1018
- }
1019
- node = node.parentNode;
1020
- }
1021
- return false;
1022
- },
1023
1151
 
1024
1152
  // Number functions
1025
1153
  number: (args, ctx) => {
@@ -1028,14 +1156,6 @@ export class XPathEvaluator {
1028
1156
  }
1029
1157
  return this.toNumber(this.evaluate(args[0], ctx));
1030
1158
  },
1031
- sum: (args, ctx) => {
1032
- const nodeSet = this.evaluate(args[0], ctx);
1033
- if (!Array.isArray(nodeSet)) return NaN;
1034
- return nodeSet.reduce(
1035
- (sum, node) => sum + this.toNumber(this.getStringValue(node)),
1036
- 0,
1037
- );
1038
- },
1039
1159
  floor: (args, ctx) => {
1040
1160
  return Math.floor(this.toNumber(this.evaluate(args[0], ctx)));
1041
1161
  },