@tradik/xslt-processor 1.0.3 → 1.1.1

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 (47) hide show
  1. package/README.md +290 -45
  2. package/bin/lib/options.js +114 -0
  3. package/bin/lib/paths.js +186 -0
  4. package/bin/lib/transform.js +115 -0
  5. package/bin/xslt.js +68 -162
  6. package/dist/xslt-processor.browser.js +2074 -159
  7. package/dist/xslt-processor.browser.js.map +4 -4
  8. package/dist/xslt-processor.browser.min.js +6 -2
  9. package/dist/xslt-processor.browser.min.js.map +4 -4
  10. package/dist/xslt-processor.cjs +2078 -158
  11. package/dist/xslt-processor.cjs.map +4 -4
  12. package/dist/xslt-processor.d.cts +299 -0
  13. package/dist/xslt-processor.d.ts +92 -4
  14. package/dist/xslt-processor.js +2073 -157
  15. package/dist/xslt-processor.js.map +4 -4
  16. package/package.json +26 -15
  17. package/src/XSLTProcessor.js +177 -8
  18. package/src/index.js +11 -5
  19. package/src/xpath/evaluator.js +48 -7
  20. package/src/xslt/elements.js +57 -0
  21. package/src/xslt/engine.js +471 -179
  22. package/src/xslt/formatNumber.js +220 -0
  23. package/src/xslt/functions.js +191 -0
  24. package/src/xslt/index.js +31 -0
  25. package/src/xslt/keys.js +141 -0
  26. package/src/xslt/literalResult.js +167 -0
  27. package/src/xslt/number.js +178 -0
  28. package/src/xslt/numberFormat.js +155 -0
  29. package/src/xslt/resultTree.js +74 -0
  30. package/src/xslt/serializer/baseWriter.js +283 -0
  31. package/src/xslt/serializer/constants.js +78 -0
  32. package/src/xslt/serializer/escape.js +98 -0
  33. package/src/xslt/serializer/htmlSerializer.js +141 -0
  34. package/src/xslt/serializer/indent.js +51 -0
  35. package/src/xslt/serializer/namespaces.js +68 -0
  36. package/src/xslt/serializer/rawText.js +41 -0
  37. package/src/xslt/serializer/settings.js +103 -0
  38. package/src/xslt/serializer/textSerializer.js +29 -0
  39. package/src/xslt/serializer/xmlSerializer.js +127 -0
  40. package/src/xslt/serializer.js +57 -0
  41. package/src/xslt/templatePriority.js +45 -0
  42. package/src/xslt/uri.js +68 -0
  43. package/src/xslt/whitespace.js +184 -0
  44. package/src/XSLTProcessor.test.js +0 -930
  45. package/src/xpath/evaluator.test.js +0 -1852
  46. package/src/xpath/tokenizer.test.js +0 -224
  47. package/src/xslt/engine.test.js +0 -3130
@@ -7,8 +7,23 @@
7
7
 
8
8
  import { parse as parseXPath } from "../xpath/parser.js";
9
9
  import { XPathEvaluator, XPathContext } from "../xpath/evaluator.js";
10
-
11
- const XSLT_NS = "http://www.w3.org/1999/XSL/Transform";
10
+ import { XSLT_NAMESPACE } from "./elements.js";
11
+ import { createXsltFunctions } from "./functions.js";
12
+ import { KeyIndexRegistry } from "./keys.js";
13
+ import { countXsltNumber } from "./number.js";
14
+ import { formatXsltNumber, toRoman } from "./numberFormat.js";
15
+ import { resolveUri, stripFragment } from "./uri.js";
16
+ import { WhitespaceFilter, stripWhitespaceNodes } from "./whitespace.js";
17
+ import {
18
+ NamespaceAliasMap,
19
+ getXsltAttribute,
20
+ shouldCopyAttribute,
21
+ } from "./literalResult.js";
22
+ import { createResultDocument, importResultFragment } from "./resultTree.js";
23
+ import { calculatePriority } from "./templatePriority.js";
24
+ import { serializeResult } from "./serializer.js";
25
+
26
+ const XSLT_NS = XSLT_NAMESPACE;
12
27
 
13
28
  /**
14
29
  * XSLT Processing Context
@@ -28,6 +43,8 @@ export class XsltContext {
28
43
  this.decimalFormats = options.decimalFormats || {};
29
44
  this.outputMethod = options.outputMethod || "xml";
30
45
  this.xpathEvaluator = options.xpathEvaluator || new XPathEvaluator();
46
+ this.currentTemplate = options.currentTemplate || null;
47
+ this.currentMode = options.currentMode ?? null;
31
48
  }
32
49
 
33
50
  clone(overrides = {}) {
@@ -51,6 +68,8 @@ export class XsltContext {
51
68
  decimalFormats: this.decimalFormats,
52
69
  outputMethod: this.outputMethod,
53
70
  xpathEvaluator: this.xpathEvaluator,
71
+ currentTemplate: overrides.currentTemplate ?? this.currentTemplate,
72
+ currentMode: overrides.currentMode ?? this.currentMode,
54
73
  });
55
74
  }
56
75
 
@@ -81,7 +100,9 @@ export class XsltEngine {
81
100
  this.globalParameters = {};
82
101
  this.outputSettings = {
83
102
  method: "xml",
103
+ version: "1.0",
84
104
  encoding: "UTF-8",
105
+ standalone: null,
85
106
  indent: "no",
86
107
  omitXmlDeclaration: "no",
87
108
  doctypePublic: null,
@@ -93,7 +114,7 @@ export class XsltEngine {
93
114
  this.decimalFormats = {};
94
115
  this.stylesheetDoc = null;
95
116
  this.attributeSets = {};
96
- this.namespaceAliases = {};
117
+ this.namespaceAliases = new NamespaceAliasMap();
97
118
  this.stripSpace = [];
98
119
  this.preserveSpace = [];
99
120
 
@@ -102,6 +123,117 @@ export class XsltEngine {
102
123
  this.currentImportPrecedence = 0;
103
124
  this.processedStylesheets = new Set();
104
125
  this.baseUri = options.baseUri || "";
126
+
127
+ // document() support
128
+ this.documentLoader = options.documentLoader || null;
129
+ this.loadedDocuments = new Map();
130
+
131
+ // generate-id() support
132
+ this.generatedIds = new WeakMap();
133
+ this.generatedIdCount = 0;
134
+
135
+ // key() support
136
+ this.rootContext = null;
137
+ this.keyRegistry = new KeyIndexRegistry({
138
+ keys: this.keys,
139
+ matchesPattern: (node, pattern) =>
140
+ this.matchesPattern(node, pattern, this.rootContext),
141
+ evaluateUse: (node, expression) =>
142
+ this.evaluateKeyValues(node, expression),
143
+ });
144
+
145
+ this.xpathEvaluator.registerFunctions(createXsltFunctions(this));
146
+ }
147
+
148
+ /**
149
+ * Set the loader used by the XSLT `document()` function.
150
+ *
151
+ * The loader is synchronous and must return a `Document`, an XML string or
152
+ * null. Returning null (or configuring no loader at all) makes `document()`
153
+ * evaluate to an empty node-set instead of failing the transformation.
154
+ *
155
+ * @param {((uri: string, baseUri?: string) => (Document|string|null))|null} loader - The loader, or null to remove it
156
+ * @returns {XsltEngine} This engine, to allow chaining
157
+ *
158
+ * @example
159
+ * engine.setDocumentLoader((uri) => readFileSync(uri, 'utf8'));
160
+ */
161
+ setDocumentLoader(loader) {
162
+ this.documentLoader = loader ?? null;
163
+ this.loadedDocuments.clear();
164
+ return this;
165
+ }
166
+
167
+ /**
168
+ * Load an external document for the `document()` function.
169
+ *
170
+ * Results are cached per resolved URI for the life of the engine, so the same
171
+ * URI always yields the identical node-set.
172
+ *
173
+ * @param {string} uri - The requested URI, fragment identifiers are ignored
174
+ * @param {string} [baseUri] - Base URI used to resolve relative references
175
+ * @returns {Document|null} The loaded document, or null when unavailable
176
+ *
177
+ * @example
178
+ * engine.loadDocument('data.xml', '/styles/main.xsl');
179
+ */
180
+ loadDocument(uri, baseUri) {
181
+ const target = stripFragment(uri);
182
+
183
+ if (target === "") return this.stylesheetDoc;
184
+ if (!this.documentLoader) return null;
185
+
186
+ const resolved = resolveUri(target, baseUri);
187
+ if (this.loadedDocuments.has(resolved)) {
188
+ return this.loadedDocuments.get(resolved);
189
+ }
190
+
191
+ const loaded = this.documentLoader(resolved, baseUri);
192
+ const doc =
193
+ typeof loaded === "string" ? this.parseXmlString(loaded) : loaded || null;
194
+
195
+ this.loadedDocuments.set(resolved, doc);
196
+ return doc;
197
+ }
198
+
199
+ /**
200
+ * Return the stable identifier of a node for `generate-id()`.
201
+ *
202
+ * @param {Node} node - The node to identify
203
+ * @returns {string} An identifier starting with a letter
204
+ *
205
+ * @example
206
+ * engine.generateId(element); // 'N1'
207
+ */
208
+ generateId(node) {
209
+ let id = this.generatedIds.get(node);
210
+ if (!id) {
211
+ this.generatedIdCount++;
212
+ id = `N${this.generatedIdCount}`;
213
+ this.generatedIds.set(node, id);
214
+ }
215
+ return id;
216
+ }
217
+
218
+ /**
219
+ * Evaluate the `use` expression of an `xsl:key` for one node.
220
+ *
221
+ * @param {Node} node - The node being indexed
222
+ * @param {string} expression - The `use` expression
223
+ * @returns {string[]} The key values contributed by the node
224
+ */
225
+ evaluateKeyValues(node, expression) {
226
+ const context = this.rootContext.clone({
227
+ currentNode: node,
228
+ currentNodeList: [node],
229
+ position: 1,
230
+ });
231
+ const value = this.evaluateXPath(expression, context);
232
+
233
+ if (Array.isArray(value)) {
234
+ return value.map((item) => this.xpathEvaluator.getStringValue(item));
235
+ }
236
+ return [this.xpathEvaluator.toString(value)];
105
237
  }
106
238
 
107
239
  /**
@@ -114,22 +246,13 @@ export class XsltEngine {
114
246
 
115
247
  /**
116
248
  * Resolve a relative URI against a base URI
249
+ *
250
+ * @param {string} href - The URI to resolve
251
+ * @param {string} [baseUri] - The base URI
252
+ * @returns {string} The resolved URI
117
253
  */
118
254
  resolveUri(href, baseUri) {
119
- if (
120
- !baseUri ||
121
- href.startsWith("http://") ||
122
- href.startsWith("https://") ||
123
- href.startsWith("/")
124
- ) {
125
- return href;
126
- }
127
-
128
- // Remove filename from baseUri to get directory
129
- const lastSlash = baseUri.lastIndexOf("/");
130
- const baseDir = lastSlash >= 0 ? baseUri.substring(0, lastSlash + 1) : "";
131
-
132
- return baseDir + href;
255
+ return resolveUri(href, baseUri);
133
256
  }
134
257
 
135
258
  /**
@@ -290,6 +413,7 @@ export class XsltEngine {
290
413
  } catch (error) {
291
414
  throw new Error(
292
415
  `Failed to include stylesheet "${href}": ${error.message}`,
416
+ { cause: error },
293
417
  );
294
418
  }
295
419
  }
@@ -331,6 +455,7 @@ export class XsltEngine {
331
455
  } catch (error) {
332
456
  throw new Error(
333
457
  `Failed to import stylesheet "${href}": ${error.message}`,
458
+ { cause: error },
334
459
  );
335
460
  }
336
461
  }
@@ -427,62 +552,61 @@ export class XsltEngine {
427
552
  }
428
553
  }
429
554
 
555
+ /**
556
+ * Register a template rule.
557
+ *
558
+ * A union match pattern is equivalent to a set of template rules, one per
559
+ * alternative (XSLT 1.0 section 5.5), so each alternative is registered
560
+ * separately with its own default priority.
561
+ *
562
+ * @param {Element} node - The xsl:template element
563
+ */
430
564
  registerTemplate(node) {
431
565
  const match = node.getAttribute("match");
432
566
  const name = node.getAttribute("name");
433
567
  const mode = node.getAttribute("mode") || null;
434
568
  const priorityAttr = node.getAttribute("priority");
435
- const priority = priorityAttr
436
- ? parseFloat(priorityAttr)
437
- : this.calculatePriority(match);
438
-
439
- this.templates.push({
440
- match,
441
- name,
442
- mode,
443
- priority,
444
- importPrecedence: this.currentImportPrecedence,
445
- node,
446
- });
569
+ const alternatives = match
570
+ ? this.splitUnionPattern(match).map((p) => p.trim())
571
+ : [null];
572
+
573
+ for (const alternative of alternatives) {
574
+ this.templates.push({
575
+ match: alternative,
576
+ name,
577
+ mode,
578
+ priority: priorityAttr
579
+ ? parseFloat(priorityAttr)
580
+ : this.calculatePriority(alternative),
581
+ importPrecedence: this.currentImportPrecedence,
582
+ node,
583
+ });
584
+ }
447
585
  }
448
586
 
587
+ /**
588
+ * Default priority of a single match pattern (see templatePriority.js).
589
+ *
590
+ * @param {string|null} matchPattern - The match pattern
591
+ * @returns {number} The default priority
592
+ */
449
593
  calculatePriority(matchPattern) {
450
- if (!matchPattern) return 0.5;
451
-
452
- // Simplified priority calculation based on XPath 1.0 spec
453
- // - NodeType or * have priority -0.5
454
- // - NCName:* has priority -0.25
455
- // - QName has priority 0
456
- // - Other patterns have priority 0.5
457
-
458
- if (
459
- matchPattern === "*" ||
460
- matchPattern === "node()" ||
461
- matchPattern === "text()" ||
462
- matchPattern === "comment()" ||
463
- matchPattern === "processing-instruction()"
464
- ) {
465
- return -0.5;
466
- }
467
-
468
- if (matchPattern.includes(":*")) {
469
- return -0.25;
470
- }
471
-
472
- if (/^[a-zA-Z_][\w.-]*$/.test(matchPattern)) {
473
- return 0;
474
- }
475
-
476
- return 0.5;
594
+ return calculatePriority(matchPattern ? matchPattern.trim() : matchPattern);
477
595
  }
478
596
 
479
597
  processOutput(node) {
480
598
  const method = node.getAttribute("method");
481
599
  if (method) this.outputSettings.method = method;
482
600
 
601
+ const version = node.getAttribute("version");
602
+ if (version) this.outputSettings.version = version;
603
+
483
604
  const encoding = node.getAttribute("encoding");
484
605
  if (encoding) this.outputSettings.encoding = encoding;
485
606
 
607
+ const standalone = node.getAttribute("standalone");
608
+ if (standalone) this.outputSettings.standalone = standalone;
609
+
486
610
  const indent = node.getAttribute("indent");
487
611
  if (indent) this.outputSettings.indent = indent;
488
612
 
@@ -513,11 +637,80 @@ export class XsltEngine {
513
637
  this.globalVariables[name] = { node, select };
514
638
  }
515
639
 
640
+ /**
641
+ * Register an `xsl:param` top level declaration.
642
+ *
643
+ * A value supplied from outside (through `setParameter`) has precedence over
644
+ * the declared default, so it survives compilation of the stylesheet.
645
+ *
646
+ * @param {Element} node - The `xsl:param` element
647
+ * @returns {void}
648
+ */
516
649
  processGlobalParam(node) {
517
650
  const name = node.getAttribute("name");
518
651
  const select = node.getAttribute("select");
652
+ const existing = this.globalParameters[name];
653
+ const definition = { node, select };
519
654
 
520
- this.globalParameters[name] = { node, select };
655
+ if (existing && "value" in existing) {
656
+ definition.value = existing.value;
657
+ }
658
+
659
+ this.globalParameters[name] = definition;
660
+ }
661
+
662
+ /**
663
+ * Supply the value of a global parameter from outside the stylesheet.
664
+ *
665
+ * The value is merged into the `xsl:param` declaration when there is one, so
666
+ * removing the value later restores the declared default.
667
+ *
668
+ * @param {string} name - The parameter name, `{uri}local` when namespaced
669
+ * @param {*} value - The value to use
670
+ * @returns {void}
671
+ *
672
+ * @example
673
+ * engine.setParameterValue('sortOrder', 'ascending');
674
+ */
675
+ setParameterValue(name, value) {
676
+ const definition = this.globalParameters[name];
677
+
678
+ if (definition) definition.value = value;
679
+ else this.globalParameters[name] = { value };
680
+ }
681
+
682
+ /**
683
+ * Remove an externally supplied parameter value.
684
+ *
685
+ * The `xsl:param` declaration of the stylesheet is kept, so the parameter
686
+ * falls back to its declared default instead of becoming undefined.
687
+ *
688
+ * @param {string} name - The parameter name, `{uri}local` when namespaced
689
+ * @returns {void}
690
+ *
691
+ * @example
692
+ * engine.clearParameterValue('sortOrder');
693
+ */
694
+ clearParameterValue(name) {
695
+ const definition = this.globalParameters[name];
696
+ if (!definition) return;
697
+
698
+ if (definition.node) delete definition.value;
699
+ else delete this.globalParameters[name];
700
+ }
701
+
702
+ /**
703
+ * Remove every externally supplied parameter value.
704
+ *
705
+ * @returns {void}
706
+ *
707
+ * @example
708
+ * engine.clearParameterValues();
709
+ */
710
+ clearParameterValues() {
711
+ for (const name of Object.keys(this.globalParameters)) {
712
+ this.clearParameterValue(name);
713
+ }
521
714
  }
522
715
 
523
716
  processKey(node) {
@@ -526,6 +719,7 @@ export class XsltEngine {
526
719
  const use = node.getAttribute("use");
527
720
 
528
721
  this.keys[name] = { match, use };
722
+ this.keyRegistry.clear();
529
723
  }
530
724
 
531
725
  processDecimalFormat(node) {
@@ -546,9 +740,7 @@ export class XsltEngine {
546
740
  }
547
741
 
548
742
  processNamespaceAlias(node) {
549
- const stylesheet = node.getAttribute("stylesheet-prefix");
550
- const result = node.getAttribute("result-prefix");
551
- this.namespaceAliases[stylesheet] = result;
743
+ this.namespaceAliases.add(node);
552
744
  }
553
745
 
554
746
  processAttributeSet(node) {
@@ -588,13 +780,19 @@ export class XsltEngine {
588
780
  throw new Error("No output document available");
589
781
  }
590
782
 
783
+ // Build the result tree in a neutral XML document: creating nodes directly
784
+ // in an HTML owner document would lower case names and force the XHTML
785
+ // namespace on every element.
786
+ const resultDocument = createResultDocument(doc);
787
+ const source = this.prepareSource(sourceNode, doc);
788
+
591
789
  // Create context - use document node as initial context for "/" template matching
592
790
  // XPath paths like "RootElement/child" expect to start from document node
593
791
  const context = new XsltContext({
594
- currentNode: sourceNode,
595
- currentNodeList: [sourceNode],
792
+ currentNode: source,
793
+ currentNodeList: [source],
596
794
  position: 1,
597
- outputDocument: doc,
795
+ outputDocument: resultDocument,
598
796
  stylesheet: this.stylesheetDoc,
599
797
  namespaces: { ...this.namespaces },
600
798
  templates: this.templates,
@@ -604,6 +802,8 @@ export class XsltEngine {
604
802
  xpathEvaluator: this.xpathEvaluator,
605
803
  });
606
804
 
805
+ this.rootContext = context;
806
+
607
807
  // Evaluate global variables
608
808
  for (const [name, def] of Object.entries(this.globalParameters)) {
609
809
  if (!(name in context.parameters)) {
@@ -616,14 +816,35 @@ export class XsltEngine {
616
816
  }
617
817
 
618
818
  // Create result document fragment
619
- const fragment = doc.createDocumentFragment();
819
+ const fragment = resultDocument.createDocumentFragment();
620
820
 
621
821
  // Apply templates to document node (not documentElement)
622
822
  // This ensures "/" template has document as context, so paths like
623
823
  // "RootElement/child" work correctly
624
- this.applyTemplates([sourceNode], null, context, fragment);
824
+ this.applyTemplates([source], null, context, fragment);
625
825
 
626
- return fragment;
826
+ return importResultFragment(fragment, doc);
827
+ }
828
+
829
+ /**
830
+ * Apply `xsl:strip-space` to the source tree.
831
+ *
832
+ * Stripping produces a copy so the caller's document is never modified; when
833
+ * no `xsl:strip-space` is declared the original node is used unchanged.
834
+ *
835
+ * @param {Node} sourceNode - The source document or element
836
+ * @param {Document} ownerDocument - Document providing the DOM implementation
837
+ * @returns {Node} The source to transform
838
+ */
839
+ prepareSource(sourceNode, ownerDocument) {
840
+ const filter = new WhitespaceFilter(this.stripSpace, this.preserveSpace);
841
+ if (!filter.isActive()) return sourceNode;
842
+
843
+ return stripWhitespaceNodes(
844
+ sourceNode,
845
+ filter,
846
+ createResultDocument(ownerDocument),
847
+ );
627
848
  }
628
849
 
629
850
  /**
@@ -631,7 +852,7 @@ export class XsltEngine {
631
852
  */
632
853
  transformToDocument(sourceNode) {
633
854
  // For Node.js environments, we need a document implementation
634
- const doc = this.createDocument();
855
+ const doc = this.createDocument(sourceNode);
635
856
  const fragment = this.transform(sourceNode, doc);
636
857
 
637
858
  // Move fragment contents to document
@@ -642,16 +863,66 @@ export class XsltEngine {
642
863
  return doc;
643
864
  }
644
865
 
645
- createDocument() {
866
+ /**
867
+ * Transform a source document and serialize the result to a string.
868
+ *
869
+ * Non-W3C convenience method: the result tree is serialized honoring the
870
+ * `xsl:output` settings of the stylesheet (XSLT 1.0 section 16).
871
+ *
872
+ * @param {Node} sourceNode - Source document or element to transform
873
+ * @returns {string} The serialized transformation result
874
+ */
875
+ transformToString(sourceNode) {
876
+ const fragment = this.transform(
877
+ sourceNode,
878
+ this.createDocument(sourceNode),
879
+ );
880
+ return serializeResult(fragment, this.outputSettings);
881
+ }
882
+
883
+ /**
884
+ * Create an empty XML document to hold a transformation result.
885
+ *
886
+ * Uses the global `document` when running in a browser and otherwise falls
887
+ * back to the DOM implementation owning `referenceNode` (e.g. a jsdom or
888
+ * xmldom document in Node.js).
889
+ *
890
+ * @param {Node} [referenceNode] - Any node whose DOM implementation can be reused
891
+ * @returns {Document} A new empty document
892
+ * @throws {Error} When no DOM implementation is available
893
+ */
894
+ createDocument(referenceNode) {
646
895
  if (typeof document !== "undefined") {
647
896
  return document.implementation.createDocument(null, null, null);
648
897
  }
649
898
 
650
- // For Node.js - would need JSDOM or similar
899
+ const ownerDocument =
900
+ referenceNode &&
901
+ (referenceNode.nodeType === 9
902
+ ? referenceNode
903
+ : referenceNode.ownerDocument);
904
+ if (ownerDocument?.implementation) {
905
+ return ownerDocument.implementation.createDocument(null, null, null);
906
+ }
907
+
651
908
  throw new Error("Document creation not available in this environment");
652
909
  }
653
910
 
911
+ /**
912
+ * Compute the value of a variable or parameter definition.
913
+ *
914
+ * A value supplied from outside (`setParameter`) wins over the `select`
915
+ * expression and over the instantiated content of the declaration.
916
+ *
917
+ * @param {{value?: *, select?: string, node?: Element}} def - The definition
918
+ * @param {XsltContext} context - The context used for evaluation
919
+ * @returns {*} The variable value
920
+ */
654
921
  evaluateVariable(def, context) {
922
+ if ("value" in def) {
923
+ return def.value;
924
+ }
925
+
655
926
  if (def.select) {
656
927
  return this.evaluateXPath(def.select, context);
657
928
  }
@@ -677,6 +948,8 @@ export class XsltEngine {
677
948
  currentNode: node,
678
949
  currentNodeList: nodeList,
679
950
  position: i + 1,
951
+ currentTemplate: template,
952
+ currentMode: mode,
680
953
  });
681
954
 
682
955
  this.processTemplate(template.node, newContext, output);
@@ -690,7 +963,7 @@ export class XsltEngine {
690
963
  /**
691
964
  * Find the best matching template for a node
692
965
  */
693
- findMatchingTemplate(node, mode, context) {
966
+ findMatchingTemplate(node, mode, context, maxImportPrecedence = Infinity) {
694
967
  let bestMatch = null;
695
968
  let bestPriority = -Infinity;
696
969
  let bestImportPrecedence = -Infinity;
@@ -698,6 +971,7 @@ export class XsltEngine {
698
971
  for (const template of this.templates) {
699
972
  if (template.mode !== mode) continue;
700
973
  if (!template.match) continue;
974
+ if ((template.importPrecedence || 0) >= maxImportPrecedence) continue;
701
975
 
702
976
  if (this.matchesPattern(node, template.match, context)) {
703
977
  const priority = template.priority;
@@ -796,20 +1070,24 @@ export class XsltEngine {
796
1070
  1,
797
1071
  { ...context.variables, ...context.parameters },
798
1072
  context.namespaces,
1073
+ context,
799
1074
  );
800
1075
  const result = this.xpathEvaluator.evaluate(ast, xpathContext);
801
1076
  const nodes = Array.isArray(result) ? result : [result];
802
1077
  return nodes.includes(node);
803
1078
  }
804
1079
 
805
- // For relative patterns, check if this node matches when evaluated from parent
806
- if (node.parentNode) {
1080
+ // For relative patterns, check if this node matches when evaluated from
1081
+ // its parent; attribute nodes are reached through their owner element
1082
+ const parent = node.nodeType === 2 ? node.ownerElement : node.parentNode;
1083
+ if (parent) {
807
1084
  const xpathContext = new XPathContext(
808
- node.parentNode,
1085
+ parent,
809
1086
  1,
810
1087
  1,
811
1088
  { ...context.variables, ...context.parameters },
812
1089
  context.namespaces,
1090
+ context,
813
1091
  );
814
1092
  const result = this.xpathEvaluator.evaluate(ast, xpathContext);
815
1093
  const nodes = Array.isArray(result) ? result : [result];
@@ -823,6 +1101,7 @@ export class XsltEngine {
823
1101
  1,
824
1102
  { ...context.variables, ...context.parameters },
825
1103
  context.namespaces,
1104
+ context,
826
1105
  );
827
1106
  const result = this.xpathEvaluator.evaluate(ast, xpathContext);
828
1107
  const nodes = Array.isArray(result) ? result : [result];
@@ -959,6 +1238,10 @@ export class XsltEngine {
959
1238
  this.xslApplyTemplates(node, context, output);
960
1239
  break;
961
1240
 
1241
+ case "apply-imports":
1242
+ this.xslApplyImports(node, context, output);
1243
+ break;
1244
+
962
1245
  case "call-template":
963
1246
  this.xslCallTemplate(node, context, output);
964
1247
  break;
@@ -1042,42 +1325,56 @@ export class XsltEngine {
1042
1325
 
1043
1326
  /**
1044
1327
  * Process a literal result element (non-XSLT)
1328
+ *
1329
+ * Applies `xsl:namespace-alias` to the element and its attributes, honours
1330
+ * `xsl:use-attribute-sets` and keeps XSLT-only attributes and namespace
1331
+ * declarations out of the result tree.
1332
+ *
1333
+ * @param {Element} node - The literal result element in the stylesheet
1334
+ * @param {XsltContext} context - The current XSLT context
1335
+ * @param {Node} output - The result tree node receiving the element
1336
+ * @returns {void}
1045
1337
  */
1046
1338
  processLiteralResultElement(node, context, output) {
1047
- // Create element in output
1048
- let outputElement;
1049
- const namespaceURI = node.namespaceURI;
1050
- const nodeName = node.nodeName;
1051
-
1052
- // Apply namespace aliases
1053
- let resolvedNS = namespaceURI;
1054
- if (namespaceURI) {
1055
- for (const [from, to] of Object.entries(this.namespaceAliases)) {
1056
- if (this.namespaces[from] === namespaceURI) {
1057
- resolvedNS = this.namespaces[to] || to;
1058
- break;
1059
- }
1060
- }
1061
- }
1062
-
1063
- if (resolvedNS && context.outputDocument.createElementNS) {
1064
- outputElement = context.outputDocument.createElementNS(
1065
- resolvedNS,
1066
- nodeName,
1067
- );
1068
- } else {
1069
- outputElement = context.outputDocument.createElement(nodeName);
1339
+ const localName = node.localName || node.nodeName;
1340
+ const alias = this.namespaceAliases.resolve(node.namespaceURI, localName);
1341
+ const namespaceUri = alias ? alias.namespaceUri : node.namespaceURI;
1342
+ const qname = alias ? alias.qname : node.nodeName;
1343
+
1344
+ const outputElement =
1345
+ namespaceUri && context.outputDocument.createElementNS
1346
+ ? context.outputDocument.createElementNS(namespaceUri, qname)
1347
+ : context.outputDocument.createElement(qname);
1348
+
1349
+ // Attribute sets come first so literal attributes take precedence
1350
+ const useAttributeSets = getXsltAttribute(
1351
+ node,
1352
+ "use-attribute-sets",
1353
+ XSLT_NS,
1354
+ );
1355
+ if (useAttributeSets) {
1356
+ this.applyAttributeSets(useAttributeSets, context, outputElement);
1070
1357
  }
1071
1358
 
1072
- // Copy attributes (except XSLT namespace)
1073
1359
  if (node.attributes) {
1074
1360
  for (const attr of node.attributes) {
1075
- if (attr.namespaceURI === XSLT_NS) continue;
1076
- if (attr.name.startsWith("xmlns")) continue;
1361
+ if (!shouldCopyAttribute(attr, XSLT_NS)) continue;
1077
1362
 
1078
- // Process attribute value templates
1079
1363
  const value = this.processAttributeValueTemplate(attr.value, context);
1080
- outputElement.setAttribute(attr.name, value);
1364
+ const attrAlias = this.namespaceAliases.resolve(
1365
+ attr.namespaceURI,
1366
+ attr.localName || attr.name,
1367
+ );
1368
+
1369
+ if (attrAlias) {
1370
+ outputElement.setAttributeNS(
1371
+ attrAlias.namespaceUri,
1372
+ attrAlias.qname,
1373
+ value,
1374
+ );
1375
+ } else {
1376
+ outputElement.setAttribute(attr.name, value);
1377
+ }
1081
1378
  }
1082
1379
  }
1083
1380
 
@@ -1187,6 +1484,44 @@ export class XsltEngine {
1187
1484
  this.applyTemplates(nodes, mode, newContext, output);
1188
1485
  }
1189
1486
 
1487
+ /**
1488
+ * Instantiate `xsl:apply-imports`.
1489
+ *
1490
+ * Only templates with a lower import precedence than the template being
1491
+ * instantiated are considered; when none matches, the built-in template rules
1492
+ * apply, exactly as for `xsl:apply-templates`.
1493
+ *
1494
+ * @param {Element} node - The `xsl:apply-imports` element
1495
+ * @param {XsltContext} context - The current XSLT context
1496
+ * @param {Node} output - The result tree node receiving the output
1497
+ * @returns {void}
1498
+ */
1499
+ xslApplyImports(node, context, output) {
1500
+ const currentNode = context.currentNode;
1501
+ const mode = context.currentMode ?? null;
1502
+ const precedence = context.currentTemplate
1503
+ ? context.currentTemplate.importPrecedence || 0
1504
+ : 0;
1505
+
1506
+ const template = this.findMatchingTemplate(
1507
+ currentNode,
1508
+ mode,
1509
+ context,
1510
+ precedence,
1511
+ );
1512
+
1513
+ if (!template) {
1514
+ this.applyBuiltinTemplate(currentNode, mode, context, output);
1515
+ return;
1516
+ }
1517
+
1518
+ this.processTemplate(
1519
+ template.node,
1520
+ context.clone({ currentTemplate: template }),
1521
+ output,
1522
+ );
1523
+ }
1524
+
1190
1525
  xslCallTemplate(node, context, output) {
1191
1526
  const name = node.getAttribute("name");
1192
1527
 
@@ -1592,96 +1927,52 @@ export class XsltEngine {
1592
1927
  xslNumber(node, context, output) {
1593
1928
  const value = node.getAttribute("value");
1594
1929
  const format = node.getAttribute("format") || "1";
1595
- const level = node.getAttribute("level") || "single";
1596
1930
 
1597
- let number;
1931
+ let numbers;
1598
1932
  if (value) {
1599
- number = Math.round(
1600
- this.xpathEvaluator.toNumber(this.evaluateXPath(value, context)),
1601
- );
1933
+ numbers = [
1934
+ Math.round(
1935
+ this.xpathEvaluator.toNumber(this.evaluateXPath(value, context)),
1936
+ ),
1937
+ ];
1602
1938
  } else {
1603
- // Count based on level
1604
- number = this.countNumber(context.currentNode, level, node, context);
1939
+ numbers = countXsltNumber(
1940
+ context.currentNode,
1941
+ {
1942
+ level: node.getAttribute("level") || "single",
1943
+ count: node.getAttribute("count"),
1944
+ from: node.getAttribute("from"),
1945
+ },
1946
+ (candidate, pattern) =>
1947
+ this.matchesPattern(candidate, pattern, context),
1948
+ );
1605
1949
  }
1606
1950
 
1607
- const formatted = this.formatNumber(number, format);
1608
- const text = context.outputDocument.createTextNode(formatted);
1951
+ const text = context.outputDocument.createTextNode(
1952
+ formatXsltNumber(numbers, format),
1953
+ );
1609
1954
  output.appendChild(text);
1610
1955
  }
1611
1956
 
1612
- countNumber(node, level, spec, context) {
1613
- const count = spec.getAttribute("count");
1614
- const _from = spec.getAttribute("from");
1615
-
1616
- // Simplified implementation
1617
- if (level === "single") {
1618
- // Count preceding siblings matching pattern
1619
- let n = 1;
1620
- let sibling = node.previousSibling;
1621
- while (sibling) {
1622
- if (sibling.nodeType === 1) {
1623
- if (!count || this.matchesPattern(sibling, count, context)) {
1624
- n++;
1625
- }
1626
- }
1627
- sibling = sibling.previousSibling;
1628
- }
1629
- return n;
1630
- }
1631
-
1632
- return 1;
1633
- }
1634
-
1957
+ /**
1958
+ * Format a single number with an `xsl:number` format token.
1959
+ *
1960
+ * @param {number} number - The number to format
1961
+ * @param {string} format - The format token, e.g. `1`, `01`, `a`, `I`
1962
+ * @returns {string} The formatted number
1963
+ */
1635
1964
  formatNumber(number, format) {
1636
- // Simple format implementation
1637
- if (/^[0-9]+$/.test(format)) {
1638
- return String(number).padStart(format.length, "0");
1639
- }
1640
-
1641
- if (format === "a") {
1642
- return String.fromCharCode(96 + ((number - 1) % 26) + 1);
1643
- }
1644
-
1645
- if (format === "A") {
1646
- return String.fromCharCode(64 + ((number - 1) % 26) + 1);
1647
- }
1648
-
1649
- if (format === "i") {
1650
- return this.toRoman(number).toLowerCase();
1651
- }
1652
-
1653
- if (format === "I") {
1654
- return this.toRoman(number);
1655
- }
1656
-
1657
- return String(number);
1965
+ return formatXsltNumber([number], format);
1658
1966
  }
1659
1967
 
1968
+ /**
1969
+ * Convert a number to an upper case Roman numeral.
1970
+ *
1971
+ * @param {number} num - The number to convert
1972
+ * @returns {string} The Roman numeral
1973
+ */
1660
1974
  toRoman(num) {
1661
- const romanNumerals = [
1662
- ["M", 1000],
1663
- ["CM", 900],
1664
- ["D", 500],
1665
- ["CD", 400],
1666
- ["C", 100],
1667
- ["XC", 90],
1668
- ["L", 50],
1669
- ["XL", 40],
1670
- ["X", 10],
1671
- ["IX", 9],
1672
- ["V", 5],
1673
- ["IV", 4],
1674
- ["I", 1],
1675
- ];
1676
-
1677
- let result = "";
1678
- for (const [numeral, value] of romanNumerals) {
1679
- while (num >= value) {
1680
- result += numeral;
1681
- num -= value;
1682
- }
1683
- }
1684
- return result;
1975
+ return toRoman(num);
1685
1976
  }
1686
1977
 
1687
1978
  xslMessage(node, context, _output) {
@@ -1787,6 +2078,7 @@ export class XsltEngine {
1787
2078
  context.currentNodeList.length,
1788
2079
  { ...context.variables, ...context.parameters },
1789
2080
  context.namespaces,
2081
+ context,
1790
2082
  );
1791
2083
  return this.xpathEvaluator.evaluate(ast, xpathContext);
1792
2084
  }