@tradik/xslt-processor 1.0.2 → 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 +292 -47
  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 +2073 -163
  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 +2077 -162
  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 +2072 -161
  15. package/dist/xslt-processor.js.map +4 -4
  16. package/package.json +27 -16
  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 +474 -185
  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 };
654
+
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];
519
677
 
520
- this.globalParameters[name] = { node, select };
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,17 +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
- // Apply templates to root
622
- this.applyTemplates(
623
- [sourceNode.documentElement || sourceNode],
624
- null,
625
- context,
626
- fragment,
627
- );
821
+ // Apply templates to document node (not documentElement)
822
+ // This ensures "/" template has document as context, so paths like
823
+ // "RootElement/child" work correctly
824
+ this.applyTemplates([source], null, context, fragment);
628
825
 
629
- 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
+ );
630
848
  }
631
849
 
632
850
  /**
@@ -634,7 +852,7 @@ export class XsltEngine {
634
852
  */
635
853
  transformToDocument(sourceNode) {
636
854
  // For Node.js environments, we need a document implementation
637
- const doc = this.createDocument();
855
+ const doc = this.createDocument(sourceNode);
638
856
  const fragment = this.transform(sourceNode, doc);
639
857
 
640
858
  // Move fragment contents to document
@@ -645,16 +863,66 @@ export class XsltEngine {
645
863
  return doc;
646
864
  }
647
865
 
648
- 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) {
649
895
  if (typeof document !== "undefined") {
650
896
  return document.implementation.createDocument(null, null, null);
651
897
  }
652
898
 
653
- // 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
+
654
908
  throw new Error("Document creation not available in this environment");
655
909
  }
656
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
+ */
657
921
  evaluateVariable(def, context) {
922
+ if ("value" in def) {
923
+ return def.value;
924
+ }
925
+
658
926
  if (def.select) {
659
927
  return this.evaluateXPath(def.select, context);
660
928
  }
@@ -680,6 +948,8 @@ export class XsltEngine {
680
948
  currentNode: node,
681
949
  currentNodeList: nodeList,
682
950
  position: i + 1,
951
+ currentTemplate: template,
952
+ currentMode: mode,
683
953
  });
684
954
 
685
955
  this.processTemplate(template.node, newContext, output);
@@ -693,7 +963,7 @@ export class XsltEngine {
693
963
  /**
694
964
  * Find the best matching template for a node
695
965
  */
696
- findMatchingTemplate(node, mode, context) {
966
+ findMatchingTemplate(node, mode, context, maxImportPrecedence = Infinity) {
697
967
  let bestMatch = null;
698
968
  let bestPriority = -Infinity;
699
969
  let bestImportPrecedence = -Infinity;
@@ -701,6 +971,7 @@ export class XsltEngine {
701
971
  for (const template of this.templates) {
702
972
  if (template.mode !== mode) continue;
703
973
  if (!template.match) continue;
974
+ if ((template.importPrecedence || 0) >= maxImportPrecedence) continue;
704
975
 
705
976
  if (this.matchesPattern(node, template.match, context)) {
706
977
  const priority = template.priority;
@@ -799,20 +1070,24 @@ export class XsltEngine {
799
1070
  1,
800
1071
  { ...context.variables, ...context.parameters },
801
1072
  context.namespaces,
1073
+ context,
802
1074
  );
803
1075
  const result = this.xpathEvaluator.evaluate(ast, xpathContext);
804
1076
  const nodes = Array.isArray(result) ? result : [result];
805
1077
  return nodes.includes(node);
806
1078
  }
807
1079
 
808
- // For relative patterns, check if this node matches when evaluated from parent
809
- 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) {
810
1084
  const xpathContext = new XPathContext(
811
- node.parentNode,
1085
+ parent,
812
1086
  1,
813
1087
  1,
814
1088
  { ...context.variables, ...context.parameters },
815
1089
  context.namespaces,
1090
+ context,
816
1091
  );
817
1092
  const result = this.xpathEvaluator.evaluate(ast, xpathContext);
818
1093
  const nodes = Array.isArray(result) ? result : [result];
@@ -826,6 +1101,7 @@ export class XsltEngine {
826
1101
  1,
827
1102
  { ...context.variables, ...context.parameters },
828
1103
  context.namespaces,
1104
+ context,
829
1105
  );
830
1106
  const result = this.xpathEvaluator.evaluate(ast, xpathContext);
831
1107
  const nodes = Array.isArray(result) ? result : [result];
@@ -962,6 +1238,10 @@ export class XsltEngine {
962
1238
  this.xslApplyTemplates(node, context, output);
963
1239
  break;
964
1240
 
1241
+ case "apply-imports":
1242
+ this.xslApplyImports(node, context, output);
1243
+ break;
1244
+
965
1245
  case "call-template":
966
1246
  this.xslCallTemplate(node, context, output);
967
1247
  break;
@@ -1045,42 +1325,56 @@ export class XsltEngine {
1045
1325
 
1046
1326
  /**
1047
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}
1048
1337
  */
1049
1338
  processLiteralResultElement(node, context, output) {
1050
- // Create element in output
1051
- let outputElement;
1052
- const namespaceURI = node.namespaceURI;
1053
- const nodeName = node.nodeName;
1054
-
1055
- // Apply namespace aliases
1056
- let resolvedNS = namespaceURI;
1057
- if (namespaceURI) {
1058
- for (const [from, to] of Object.entries(this.namespaceAliases)) {
1059
- if (this.namespaces[from] === namespaceURI) {
1060
- resolvedNS = this.namespaces[to] || to;
1061
- break;
1062
- }
1063
- }
1064
- }
1065
-
1066
- if (resolvedNS && context.outputDocument.createElementNS) {
1067
- outputElement = context.outputDocument.createElementNS(
1068
- resolvedNS,
1069
- nodeName,
1070
- );
1071
- } else {
1072
- 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);
1073
1357
  }
1074
1358
 
1075
- // Copy attributes (except XSLT namespace)
1076
1359
  if (node.attributes) {
1077
1360
  for (const attr of node.attributes) {
1078
- if (attr.namespaceURI === XSLT_NS) continue;
1079
- if (attr.name.startsWith("xmlns")) continue;
1361
+ if (!shouldCopyAttribute(attr, XSLT_NS)) continue;
1080
1362
 
1081
- // Process attribute value templates
1082
1363
  const value = this.processAttributeValueTemplate(attr.value, context);
1083
- 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
+ }
1084
1378
  }
1085
1379
  }
1086
1380
 
@@ -1190,6 +1484,44 @@ export class XsltEngine {
1190
1484
  this.applyTemplates(nodes, mode, newContext, output);
1191
1485
  }
1192
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
+
1193
1525
  xslCallTemplate(node, context, output) {
1194
1526
  const name = node.getAttribute("name");
1195
1527
 
@@ -1595,96 +1927,52 @@ export class XsltEngine {
1595
1927
  xslNumber(node, context, output) {
1596
1928
  const value = node.getAttribute("value");
1597
1929
  const format = node.getAttribute("format") || "1";
1598
- const level = node.getAttribute("level") || "single";
1599
1930
 
1600
- let number;
1931
+ let numbers;
1601
1932
  if (value) {
1602
- number = Math.round(
1603
- this.xpathEvaluator.toNumber(this.evaluateXPath(value, context)),
1604
- );
1933
+ numbers = [
1934
+ Math.round(
1935
+ this.xpathEvaluator.toNumber(this.evaluateXPath(value, context)),
1936
+ ),
1937
+ ];
1605
1938
  } else {
1606
- // Count based on level
1607
- 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
+ );
1608
1949
  }
1609
1950
 
1610
- const formatted = this.formatNumber(number, format);
1611
- const text = context.outputDocument.createTextNode(formatted);
1951
+ const text = context.outputDocument.createTextNode(
1952
+ formatXsltNumber(numbers, format),
1953
+ );
1612
1954
  output.appendChild(text);
1613
1955
  }
1614
1956
 
1615
- countNumber(node, level, spec, context) {
1616
- const count = spec.getAttribute("count");
1617
- const _from = spec.getAttribute("from");
1618
-
1619
- // Simplified implementation
1620
- if (level === "single") {
1621
- // Count preceding siblings matching pattern
1622
- let n = 1;
1623
- let sibling = node.previousSibling;
1624
- while (sibling) {
1625
- if (sibling.nodeType === 1) {
1626
- if (!count || this.matchesPattern(sibling, count, context)) {
1627
- n++;
1628
- }
1629
- }
1630
- sibling = sibling.previousSibling;
1631
- }
1632
- return n;
1633
- }
1634
-
1635
- return 1;
1636
- }
1637
-
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
+ */
1638
1964
  formatNumber(number, format) {
1639
- // Simple format implementation
1640
- if (/^[0-9]+$/.test(format)) {
1641
- return String(number).padStart(format.length, "0");
1642
- }
1643
-
1644
- if (format === "a") {
1645
- return String.fromCharCode(96 + ((number - 1) % 26) + 1);
1646
- }
1647
-
1648
- if (format === "A") {
1649
- return String.fromCharCode(64 + ((number - 1) % 26) + 1);
1650
- }
1651
-
1652
- if (format === "i") {
1653
- return this.toRoman(number).toLowerCase();
1654
- }
1655
-
1656
- if (format === "I") {
1657
- return this.toRoman(number);
1658
- }
1659
-
1660
- return String(number);
1965
+ return formatXsltNumber([number], format);
1661
1966
  }
1662
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
+ */
1663
1974
  toRoman(num) {
1664
- const romanNumerals = [
1665
- ["M", 1000],
1666
- ["CM", 900],
1667
- ["D", 500],
1668
- ["CD", 400],
1669
- ["C", 100],
1670
- ["XC", 90],
1671
- ["L", 50],
1672
- ["XL", 40],
1673
- ["X", 10],
1674
- ["IX", 9],
1675
- ["V", 5],
1676
- ["IV", 4],
1677
- ["I", 1],
1678
- ];
1679
-
1680
- let result = "";
1681
- for (const [numeral, value] of romanNumerals) {
1682
- while (num >= value) {
1683
- result += numeral;
1684
- num -= value;
1685
- }
1686
- }
1687
- return result;
1975
+ return toRoman(num);
1688
1976
  }
1689
1977
 
1690
1978
  xslMessage(node, context, _output) {
@@ -1790,6 +2078,7 @@ export class XsltEngine {
1790
2078
  context.currentNodeList.length,
1791
2079
  { ...context.variables, ...context.parameters },
1792
2080
  context.namespaces,
2081
+ context,
1793
2082
  );
1794
2083
  return this.xpathEvaluator.evaluate(ast, xpathContext);
1795
2084
  }