@tradik/xslt-processor 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1812 @@
1
+ /**
2
+ * XSLT 1.0 Processing Engine
3
+ * Based on W3C XSLT 1.0 Specification: http://www.w3.org/TR/1999/REC-xslt-19991116
4
+ *
5
+ * Processes XSLT stylesheets and transforms XML documents.
6
+ */
7
+
8
+ import { parse as parseXPath } from "../xpath/parser.js";
9
+ import { XPathEvaluator, XPathContext } from "../xpath/evaluator.js";
10
+
11
+ const XSLT_NS = "http://www.w3.org/1999/XSL/Transform";
12
+
13
+ /**
14
+ * XSLT Processing Context
15
+ */
16
+ export class XsltContext {
17
+ constructor(options = {}) {
18
+ this.currentNode = options.currentNode;
19
+ this.currentNodeList = options.currentNodeList || [];
20
+ this.position = options.position || 1;
21
+ this.variables = { ...options.variables };
22
+ this.parameters = { ...options.parameters };
23
+ this.outputDocument = options.outputDocument;
24
+ this.stylesheet = options.stylesheet;
25
+ this.namespaces = { ...options.namespaces };
26
+ this.templates = options.templates || [];
27
+ this.keys = options.keys || {};
28
+ this.decimalFormats = options.decimalFormats || {};
29
+ this.outputMethod = options.outputMethod || "xml";
30
+ this.xpathEvaluator = options.xpathEvaluator || new XPathEvaluator();
31
+ }
32
+
33
+ clone(overrides = {}) {
34
+ return new XsltContext({
35
+ currentNode: overrides.currentNode ?? this.currentNode,
36
+ currentNodeList: overrides.currentNodeList ?? this.currentNodeList,
37
+ position: overrides.position ?? this.position,
38
+ variables: overrides.variables
39
+ ? { ...this.variables, ...overrides.variables }
40
+ : { ...this.variables },
41
+ parameters: overrides.parameters
42
+ ? { ...this.parameters, ...overrides.parameters }
43
+ : { ...this.parameters },
44
+ outputDocument: this.outputDocument,
45
+ stylesheet: this.stylesheet,
46
+ namespaces: overrides.namespaces
47
+ ? { ...this.namespaces, ...overrides.namespaces }
48
+ : { ...this.namespaces },
49
+ templates: this.templates,
50
+ keys: this.keys,
51
+ decimalFormats: this.decimalFormats,
52
+ outputMethod: this.outputMethod,
53
+ xpathEvaluator: this.xpathEvaluator,
54
+ });
55
+ }
56
+
57
+ getVariable(name) {
58
+ if (name in this.variables) {
59
+ return this.variables[name];
60
+ }
61
+ if (name in this.parameters) {
62
+ return this.parameters[name];
63
+ }
64
+ throw new Error(`Undefined variable: $${name}`);
65
+ }
66
+
67
+ setVariable(name, value) {
68
+ this.variables[name] = value;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * XSLT Engine
74
+ */
75
+ export class XsltEngine {
76
+ constructor(options = {}) {
77
+ this.xpathEvaluator = new XPathEvaluator();
78
+ this.templates = [];
79
+ this.keys = {};
80
+ this.globalVariables = {};
81
+ this.globalParameters = {};
82
+ this.outputSettings = {
83
+ method: "xml",
84
+ encoding: "UTF-8",
85
+ indent: "no",
86
+ omitXmlDeclaration: "no",
87
+ doctypePublic: null,
88
+ doctypeSystem: null,
89
+ mediaType: null,
90
+ cdataSectionElements: [],
91
+ };
92
+ this.namespaces = {};
93
+ this.decimalFormats = {};
94
+ this.stylesheetDoc = null;
95
+ this.attributeSets = {};
96
+ this.namespaceAliases = {};
97
+ this.stripSpace = [];
98
+ this.preserveSpace = [];
99
+
100
+ // Import/Include support
101
+ this.stylesheetLoader = options.stylesheetLoader || null;
102
+ this.currentImportPrecedence = 0;
103
+ this.processedStylesheets = new Set();
104
+ this.baseUri = options.baseUri || "";
105
+ }
106
+
107
+ /**
108
+ * Set the stylesheet loader function for xsl:import and xsl:include
109
+ * @param {Function} loader - Function(href, baseUri) => Document or string (XML)
110
+ */
111
+ setStylesheetLoader(loader) {
112
+ this.stylesheetLoader = loader;
113
+ }
114
+
115
+ /**
116
+ * Resolve a relative URI against a base URI
117
+ */
118
+ 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;
133
+ }
134
+
135
+ /**
136
+ * Load an external stylesheet document
137
+ */
138
+ loadStylesheet(href, baseUri) {
139
+ if (!this.stylesheetLoader) {
140
+ throw new Error(
141
+ `Cannot load stylesheet "${href}": no stylesheetLoader configured. ` +
142
+ "Use engine.setStylesheetLoader(fn) to provide a loader function.",
143
+ );
144
+ }
145
+
146
+ const resolvedUri = this.resolveUri(href, baseUri);
147
+ const result = this.stylesheetLoader(resolvedUri, baseUri);
148
+
149
+ // If result is a string, it needs to be parsed (caller should handle this)
150
+ return { document: result, uri: resolvedUri };
151
+ }
152
+
153
+ /**
154
+ * Parse XML string to document (helper for stylesheet loading)
155
+ */
156
+ parseXmlString(xmlString) {
157
+ if (typeof DOMParser !== "undefined") {
158
+ const parser = new DOMParser();
159
+ const doc = parser.parseFromString(xmlString, "application/xml");
160
+ const parseError = doc.querySelector("parsererror");
161
+ if (parseError) {
162
+ throw new Error(`XML parse error: ${parseError.textContent}`);
163
+ }
164
+ return doc;
165
+ }
166
+ throw new Error("XML parsing not available in this environment");
167
+ }
168
+
169
+ /**
170
+ * Import and compile an XSLT stylesheet
171
+ * @param {Document|Element} stylesheetNode - The stylesheet document or root element
172
+ * @param {string} [stylesheetUri] - Optional URI of the stylesheet for resolving imports
173
+ */
174
+ importStylesheet(stylesheetNode, stylesheetUri) {
175
+ const isMainStylesheet = this.stylesheetDoc === null;
176
+
177
+ if (isMainStylesheet) {
178
+ this.stylesheetDoc = stylesheetNode.ownerDocument || stylesheetNode;
179
+ if (stylesheetUri) {
180
+ this.baseUri = stylesheetUri;
181
+ this.processedStylesheets.add(stylesheetUri);
182
+ }
183
+ }
184
+
185
+ const root = stylesheetNode.documentElement || stylesheetNode;
186
+
187
+ // Validate stylesheet
188
+ if (
189
+ !this.isXsltElement(root, "stylesheet") &&
190
+ !this.isXsltElement(root, "transform")
191
+ ) {
192
+ // Check for literal result element (simplified stylesheet)
193
+ if (root.getAttribute && root.getAttribute("xsl:version")) {
194
+ this.processLiteralResultStylesheet(root);
195
+ return;
196
+ }
197
+ throw new Error(
198
+ "Invalid XSLT stylesheet: root element must be xsl:stylesheet or xsl:transform",
199
+ );
200
+ }
201
+
202
+ // Collect namespaces from root
203
+ this.collectNamespaces(root);
204
+
205
+ // XSLT 1.0: xsl:import elements MUST come first and are processed with lower precedence
206
+ // Process imports first (they have lower precedence than the importing stylesheet)
207
+ const imports = [];
208
+ const otherElements = [];
209
+
210
+ for (const child of root.childNodes) {
211
+ if (child.nodeType !== 1) continue;
212
+
213
+ if (this.isXsltElement(child, "import")) {
214
+ imports.push(child);
215
+ } else {
216
+ otherElements.push(child);
217
+ }
218
+ }
219
+
220
+ // Process imports (lower precedence - process before current stylesheet)
221
+ for (const importNode of imports) {
222
+ this.processImport(importNode, stylesheetUri || this.baseUri);
223
+ }
224
+
225
+ // Process other top-level elements (including includes)
226
+ for (const child of otherElements) {
227
+ if (this.isXsltElement(child, "template")) {
228
+ this.registerTemplate(child);
229
+ } else if (this.isXsltElement(child, "output")) {
230
+ this.processOutput(child);
231
+ } else if (this.isXsltElement(child, "variable")) {
232
+ this.processGlobalVariable(child);
233
+ } else if (this.isXsltElement(child, "param")) {
234
+ this.processGlobalParam(child);
235
+ } else if (this.isXsltElement(child, "key")) {
236
+ this.processKey(child);
237
+ } else if (this.isXsltElement(child, "decimal-format")) {
238
+ this.processDecimalFormat(child);
239
+ } else if (this.isXsltElement(child, "namespace-alias")) {
240
+ this.processNamespaceAlias(child);
241
+ } else if (this.isXsltElement(child, "attribute-set")) {
242
+ this.processAttributeSet(child);
243
+ } else if (this.isXsltElement(child, "strip-space")) {
244
+ this.processStripSpace(child);
245
+ } else if (this.isXsltElement(child, "preserve-space")) {
246
+ this.processPreserveSpace(child);
247
+ } else if (this.isXsltElement(child, "include")) {
248
+ this.processInclude(child, stylesheetUri || this.baseUri);
249
+ }
250
+ }
251
+
252
+ // Increment import precedence after processing this stylesheet
253
+ if (isMainStylesheet) {
254
+ this.currentImportPrecedence++;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Process xsl:include element
260
+ * Includes are merged at the same import precedence level
261
+ */
262
+ processInclude(node, baseUri) {
263
+ const href = node.getAttribute("href");
264
+ if (!href) {
265
+ throw new Error("xsl:include requires an href attribute");
266
+ }
267
+
268
+ const resolvedUri = this.resolveUri(href, baseUri);
269
+
270
+ // Check for circular includes
271
+ if (this.processedStylesheets.has(resolvedUri)) {
272
+ throw new Error(`Circular stylesheet reference detected: ${resolvedUri}`);
273
+ }
274
+
275
+ this.processedStylesheets.add(resolvedUri);
276
+
277
+ try {
278
+ const { document: stylesheetDoc } = this.loadStylesheet(href, baseUri);
279
+
280
+ // Parse if string
281
+ let doc = stylesheetDoc;
282
+ if (typeof stylesheetDoc === "string") {
283
+ doc = this.parseXmlString(stylesheetDoc);
284
+ }
285
+
286
+ // Process the included stylesheet at the same import precedence
287
+ const savedPrecedence = this.currentImportPrecedence;
288
+ this.processIncludedStylesheet(doc, resolvedUri);
289
+ this.currentImportPrecedence = savedPrecedence;
290
+ } catch (error) {
291
+ throw new Error(
292
+ `Failed to include stylesheet "${href}": ${error.message}`,
293
+ );
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Process xsl:import element
299
+ * Imports have lower precedence than the importing stylesheet
300
+ */
301
+ processImport(node, baseUri) {
302
+ const href = node.getAttribute("href");
303
+ if (!href) {
304
+ throw new Error("xsl:import requires an href attribute");
305
+ }
306
+
307
+ const resolvedUri = this.resolveUri(href, baseUri);
308
+
309
+ // Check for circular imports
310
+ if (this.processedStylesheets.has(resolvedUri)) {
311
+ throw new Error(`Circular stylesheet reference detected: ${resolvedUri}`);
312
+ }
313
+
314
+ this.processedStylesheets.add(resolvedUri);
315
+
316
+ try {
317
+ const { document: stylesheetDoc } = this.loadStylesheet(href, baseUri);
318
+
319
+ // Parse if string
320
+ let doc = stylesheetDoc;
321
+ if (typeof stylesheetDoc === "string") {
322
+ doc = this.parseXmlString(stylesheetDoc);
323
+ }
324
+
325
+ // Process the imported stylesheet (imports have lower precedence)
326
+ // Don't increment precedence yet - imported templates get current (lower) precedence
327
+ this.processIncludedStylesheet(doc, resolvedUri);
328
+
329
+ // After processing import, increment precedence for next imports and main stylesheet
330
+ this.currentImportPrecedence++;
331
+ } catch (error) {
332
+ throw new Error(
333
+ `Failed to import stylesheet "${href}": ${error.message}`,
334
+ );
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Process an included/imported stylesheet document
340
+ */
341
+ processIncludedStylesheet(stylesheetDoc, stylesheetUri) {
342
+ const root = stylesheetDoc.documentElement || stylesheetDoc;
343
+
344
+ // Validate stylesheet
345
+ if (
346
+ !this.isXsltElement(root, "stylesheet") &&
347
+ !this.isXsltElement(root, "transform")
348
+ ) {
349
+ throw new Error(
350
+ "Included/imported document is not a valid XSLT stylesheet",
351
+ );
352
+ }
353
+
354
+ // Collect namespaces
355
+ this.collectNamespaces(root);
356
+
357
+ // Process imports first (they have lower precedence)
358
+ const imports = [];
359
+ const otherElements = [];
360
+
361
+ for (const child of root.childNodes) {
362
+ if (child.nodeType !== 1) continue;
363
+
364
+ if (this.isXsltElement(child, "import")) {
365
+ imports.push(child);
366
+ } else {
367
+ otherElements.push(child);
368
+ }
369
+ }
370
+
371
+ // Process nested imports
372
+ for (const importNode of imports) {
373
+ this.processImport(importNode, stylesheetUri);
374
+ }
375
+
376
+ // Process other elements
377
+ for (const child of otherElements) {
378
+ if (this.isXsltElement(child, "template")) {
379
+ this.registerTemplate(child);
380
+ } else if (this.isXsltElement(child, "output")) {
381
+ this.processOutput(child);
382
+ } else if (this.isXsltElement(child, "variable")) {
383
+ this.processGlobalVariable(child);
384
+ } else if (this.isXsltElement(child, "param")) {
385
+ this.processGlobalParam(child);
386
+ } else if (this.isXsltElement(child, "key")) {
387
+ this.processKey(child);
388
+ } else if (this.isXsltElement(child, "decimal-format")) {
389
+ this.processDecimalFormat(child);
390
+ } else if (this.isXsltElement(child, "namespace-alias")) {
391
+ this.processNamespaceAlias(child);
392
+ } else if (this.isXsltElement(child, "attribute-set")) {
393
+ this.processAttributeSet(child);
394
+ } else if (this.isXsltElement(child, "strip-space")) {
395
+ this.processStripSpace(child);
396
+ } else if (this.isXsltElement(child, "preserve-space")) {
397
+ this.processPreserveSpace(child);
398
+ } else if (this.isXsltElement(child, "include")) {
399
+ this.processInclude(child, stylesheetUri);
400
+ }
401
+ }
402
+ }
403
+
404
+ processLiteralResultStylesheet(root) {
405
+ // Simplified stylesheet - entire document is one template matching /
406
+ this.templates.push({
407
+ match: "/",
408
+ name: null,
409
+ mode: null,
410
+ priority: 0.5,
411
+ node: root,
412
+ });
413
+ }
414
+
415
+ collectNamespaces(node) {
416
+ if (!node.attributes) return;
417
+
418
+ for (const attr of node.attributes) {
419
+ if (attr.name.startsWith("xmlns:")) {
420
+ const prefix = attr.name.substring(6);
421
+ if (attr.value !== XSLT_NS) {
422
+ this.namespaces[prefix] = attr.value;
423
+ }
424
+ } else if (attr.name === "xmlns" && attr.value !== XSLT_NS) {
425
+ this.namespaces[""] = attr.value;
426
+ }
427
+ }
428
+ }
429
+
430
+ registerTemplate(node) {
431
+ const match = node.getAttribute("match");
432
+ const name = node.getAttribute("name");
433
+ const mode = node.getAttribute("mode") || null;
434
+ 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
+ });
447
+ }
448
+
449
+ 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;
477
+ }
478
+
479
+ processOutput(node) {
480
+ const method = node.getAttribute("method");
481
+ if (method) this.outputSettings.method = method;
482
+
483
+ const encoding = node.getAttribute("encoding");
484
+ if (encoding) this.outputSettings.encoding = encoding;
485
+
486
+ const indent = node.getAttribute("indent");
487
+ if (indent) this.outputSettings.indent = indent;
488
+
489
+ const omit = node.getAttribute("omit-xml-declaration");
490
+ if (omit) this.outputSettings.omitXmlDeclaration = omit;
491
+
492
+ const doctypePublic = node.getAttribute("doctype-public");
493
+ if (doctypePublic) this.outputSettings.doctypePublic = doctypePublic;
494
+
495
+ const doctypeSystem = node.getAttribute("doctype-system");
496
+ if (doctypeSystem) this.outputSettings.doctypeSystem = doctypeSystem;
497
+
498
+ const mediaType = node.getAttribute("media-type");
499
+ if (mediaType) this.outputSettings.mediaType = mediaType;
500
+
501
+ const cdataElements = node.getAttribute("cdata-section-elements");
502
+ if (cdataElements) {
503
+ this.outputSettings.cdataSectionElements = cdataElements
504
+ .split(/\s+/)
505
+ .filter(Boolean);
506
+ }
507
+ }
508
+
509
+ processGlobalVariable(node) {
510
+ const name = node.getAttribute("name");
511
+ const select = node.getAttribute("select");
512
+
513
+ this.globalVariables[name] = { node, select };
514
+ }
515
+
516
+ processGlobalParam(node) {
517
+ const name = node.getAttribute("name");
518
+ const select = node.getAttribute("select");
519
+
520
+ this.globalParameters[name] = { node, select };
521
+ }
522
+
523
+ processKey(node) {
524
+ const name = node.getAttribute("name");
525
+ const match = node.getAttribute("match");
526
+ const use = node.getAttribute("use");
527
+
528
+ this.keys[name] = { match, use };
529
+ }
530
+
531
+ processDecimalFormat(node) {
532
+ const name = node.getAttribute("name") || "";
533
+
534
+ this.decimalFormats[name] = {
535
+ decimalSeparator: node.getAttribute("decimal-separator") || ".",
536
+ groupingSeparator: node.getAttribute("grouping-separator") || ",",
537
+ percent: node.getAttribute("percent") || "%",
538
+ perMille: node.getAttribute("per-mille") || "\u2030",
539
+ zeroDigit: node.getAttribute("zero-digit") || "0",
540
+ digit: node.getAttribute("digit") || "#",
541
+ patternSeparator: node.getAttribute("pattern-separator") || ";",
542
+ infinity: node.getAttribute("infinity") || "Infinity",
543
+ nan: node.getAttribute("NaN") || "NaN",
544
+ minusSign: node.getAttribute("minus-sign") || "-",
545
+ };
546
+ }
547
+
548
+ processNamespaceAlias(node) {
549
+ const stylesheet = node.getAttribute("stylesheet-prefix");
550
+ const result = node.getAttribute("result-prefix");
551
+ this.namespaceAliases[stylesheet] = result;
552
+ }
553
+
554
+ processAttributeSet(node) {
555
+ const name = node.getAttribute("name");
556
+ const useAttributeSets = node.getAttribute("use-attribute-sets");
557
+
558
+ this.attributeSets[name] = {
559
+ node,
560
+ useAttributeSets: useAttributeSets
561
+ ? useAttributeSets.split(/\s+/).filter(Boolean)
562
+ : [],
563
+ };
564
+ }
565
+
566
+ processStripSpace(node) {
567
+ const elements = node.getAttribute("elements");
568
+ if (elements) {
569
+ this.stripSpace.push(...elements.split(/\s+/).filter(Boolean));
570
+ }
571
+ }
572
+
573
+ processPreserveSpace(node) {
574
+ const elements = node.getAttribute("elements");
575
+ if (elements) {
576
+ this.preserveSpace.push(...elements.split(/\s+/).filter(Boolean));
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Transform a source document
582
+ */
583
+ transform(sourceNode, ownerDocument) {
584
+ const doc =
585
+ ownerDocument || (typeof document !== "undefined" ? document : null);
586
+
587
+ if (!doc) {
588
+ throw new Error("No output document available");
589
+ }
590
+
591
+ // Create context
592
+ const context = new XsltContext({
593
+ currentNode: sourceNode.documentElement || sourceNode,
594
+ currentNodeList: [sourceNode.documentElement || sourceNode],
595
+ position: 1,
596
+ outputDocument: doc,
597
+ stylesheet: this.stylesheetDoc,
598
+ namespaces: { ...this.namespaces },
599
+ templates: this.templates,
600
+ keys: this.keys,
601
+ decimalFormats: this.decimalFormats,
602
+ outputMethod: this.outputSettings.method,
603
+ xpathEvaluator: this.xpathEvaluator,
604
+ });
605
+
606
+ // Evaluate global variables
607
+ for (const [name, def] of Object.entries(this.globalParameters)) {
608
+ if (!(name in context.parameters)) {
609
+ context.parameters[name] = this.evaluateVariable(def, context);
610
+ }
611
+ }
612
+
613
+ for (const [name, def] of Object.entries(this.globalVariables)) {
614
+ context.variables[name] = this.evaluateVariable(def, context);
615
+ }
616
+
617
+ // Create result document fragment
618
+ const fragment = doc.createDocumentFragment();
619
+
620
+ // Apply templates to root
621
+ this.applyTemplates(
622
+ [sourceNode.documentElement || sourceNode],
623
+ null,
624
+ context,
625
+ fragment,
626
+ );
627
+
628
+ return fragment;
629
+ }
630
+
631
+ /**
632
+ * Transform to a complete document
633
+ */
634
+ transformToDocument(sourceNode) {
635
+ // For Node.js environments, we need a document implementation
636
+ const doc = this.createDocument();
637
+ const fragment = this.transform(sourceNode, doc);
638
+
639
+ // Move fragment contents to document
640
+ while (fragment.firstChild) {
641
+ doc.appendChild(fragment.firstChild);
642
+ }
643
+
644
+ return doc;
645
+ }
646
+
647
+ createDocument() {
648
+ if (typeof document !== "undefined") {
649
+ return document.implementation.createDocument(null, null, null);
650
+ }
651
+
652
+ // For Node.js - would need JSDOM or similar
653
+ throw new Error("Document creation not available in this environment");
654
+ }
655
+
656
+ evaluateVariable(def, context) {
657
+ if (def.select) {
658
+ return this.evaluateXPath(def.select, context);
659
+ }
660
+
661
+ // If no select, evaluate content as result tree fragment
662
+ const fragment = context.outputDocument.createDocumentFragment();
663
+ this.processChildren(def.node, context, fragment);
664
+ return fragment;
665
+ }
666
+
667
+ /**
668
+ * Apply templates to a node list
669
+ */
670
+ applyTemplates(nodes, mode, context, output) {
671
+ const nodeList = Array.isArray(nodes) ? nodes : [nodes];
672
+
673
+ for (let i = 0; i < nodeList.length; i++) {
674
+ const node = nodeList[i];
675
+ const template = this.findMatchingTemplate(node, mode, context);
676
+
677
+ if (template) {
678
+ const newContext = context.clone({
679
+ currentNode: node,
680
+ currentNodeList: nodeList,
681
+ position: i + 1,
682
+ });
683
+
684
+ this.processTemplate(template.node, newContext, output);
685
+ } else {
686
+ // Built-in templates
687
+ this.applyBuiltinTemplate(node, mode, context, output);
688
+ }
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Find the best matching template for a node
694
+ */
695
+ findMatchingTemplate(node, mode, context) {
696
+ let bestMatch = null;
697
+ let bestPriority = -Infinity;
698
+ let bestImportPrecedence = -Infinity;
699
+
700
+ for (const template of this.templates) {
701
+ if (template.mode !== mode) continue;
702
+ if (!template.match) continue;
703
+
704
+ if (this.matchesPattern(node, template.match, context)) {
705
+ const priority = template.priority;
706
+ const importPrecedence = template.importPrecedence || 0;
707
+
708
+ if (
709
+ importPrecedence > bestImportPrecedence ||
710
+ (importPrecedence === bestImportPrecedence && priority > bestPriority)
711
+ ) {
712
+ bestMatch = template;
713
+ bestPriority = priority;
714
+ bestImportPrecedence = importPrecedence;
715
+ }
716
+ }
717
+ }
718
+
719
+ return bestMatch;
720
+ }
721
+
722
+ /**
723
+ * Check if a node matches an XSLT pattern
724
+ */
725
+ matchesPattern(node, pattern, context) {
726
+ // Split union patterns
727
+ const patterns = this.splitUnionPattern(pattern);
728
+
729
+ for (const p of patterns) {
730
+ if (this.matchesSinglePattern(node, p.trim(), context)) {
731
+ return true;
732
+ }
733
+ }
734
+
735
+ return false;
736
+ }
737
+
738
+ splitUnionPattern(pattern) {
739
+ // Simple split on | not inside predicates or strings
740
+ const parts = [];
741
+ let current = "";
742
+ let depth = 0;
743
+ let inString = false;
744
+ let stringChar = "";
745
+
746
+ for (let i = 0; i < pattern.length; i++) {
747
+ const char = pattern[i];
748
+
749
+ if (inString) {
750
+ current += char;
751
+ if (char === stringChar) {
752
+ inString = false;
753
+ }
754
+ } else if (char === '"' || char === "'") {
755
+ inString = true;
756
+ stringChar = char;
757
+ current += char;
758
+ } else if (char === "[") {
759
+ depth++;
760
+ current += char;
761
+ } else if (char === "]") {
762
+ depth--;
763
+ current += char;
764
+ } else if (char === "|" && depth === 0) {
765
+ parts.push(current);
766
+ current = "";
767
+ } else {
768
+ current += char;
769
+ }
770
+ }
771
+
772
+ if (current) {
773
+ parts.push(current);
774
+ }
775
+
776
+ return parts;
777
+ }
778
+
779
+ matchesSinglePattern(node, pattern, context) {
780
+ try {
781
+ // Handle root pattern
782
+ if (pattern === "/") {
783
+ return (
784
+ node.nodeType === 9 || node === node.ownerDocument?.documentElement
785
+ );
786
+ }
787
+
788
+ // Handle patterns like "item" (child match)
789
+ // Need to check if node would be selected by pattern from parent
790
+ const ast = parseXPath(pattern);
791
+
792
+ // For patterns starting with /, evaluate from root
793
+ if (pattern.startsWith("/")) {
794
+ const doc = node.ownerDocument || node;
795
+ const xpathContext = new XPathContext(
796
+ doc,
797
+ 1,
798
+ 1,
799
+ { ...context.variables, ...context.parameters },
800
+ context.namespaces,
801
+ );
802
+ const result = this.xpathEvaluator.evaluate(ast, xpathContext);
803
+ const nodes = Array.isArray(result) ? result : [result];
804
+ return nodes.includes(node);
805
+ }
806
+
807
+ // For relative patterns, check if this node matches when evaluated from parent
808
+ if (node.parentNode) {
809
+ const xpathContext = new XPathContext(
810
+ node.parentNode,
811
+ 1,
812
+ 1,
813
+ { ...context.variables, ...context.parameters },
814
+ context.namespaces,
815
+ );
816
+ const result = this.xpathEvaluator.evaluate(ast, xpathContext);
817
+ const nodes = Array.isArray(result) ? result : [result];
818
+ return nodes.includes(node);
819
+ }
820
+
821
+ // For document node without parent
822
+ const xpathContext = new XPathContext(
823
+ node,
824
+ 1,
825
+ 1,
826
+ { ...context.variables, ...context.parameters },
827
+ context.namespaces,
828
+ );
829
+ const result = this.xpathEvaluator.evaluate(ast, xpathContext);
830
+ const nodes = Array.isArray(result) ? result : [result];
831
+ return nodes.includes(node);
832
+ } catch {
833
+ return false;
834
+ }
835
+ }
836
+
837
+ /**
838
+ * Apply built-in template rules
839
+ */
840
+ applyBuiltinTemplate(node, mode, context, output) {
841
+ switch (node.nodeType) {
842
+ case 1: // Element
843
+ case 9: // Document
844
+ case 11: // Document Fragment
845
+ // Process children
846
+ this.applyTemplates(Array.from(node.childNodes), mode, context, output);
847
+ break;
848
+
849
+ case 3: // Text
850
+ case 4: {
851
+ // CDATA
852
+ // Copy text value
853
+ const text = context.outputDocument.createTextNode(
854
+ node.nodeValue || "",
855
+ );
856
+ output.appendChild(text);
857
+ break;
858
+ }
859
+
860
+ case 2: {
861
+ // Attribute
862
+ // Copy attribute value as text
863
+ const attrText = context.outputDocument.createTextNode(
864
+ node.nodeValue || "",
865
+ );
866
+ output.appendChild(attrText);
867
+ break;
868
+ }
869
+
870
+ // Comments and PIs have no built-in template
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Process template content
876
+ */
877
+ processTemplate(templateNode, context, output) {
878
+ // Process template parameters first
879
+ const localContext = context.clone();
880
+
881
+ for (const child of templateNode.childNodes) {
882
+ if (child.nodeType === 1 && this.isXsltElement(child, "param")) {
883
+ const name = child.getAttribute("name");
884
+ if (!(name in localContext.parameters)) {
885
+ localContext.parameters[name] = this.evaluateVariable(
886
+ { node: child, select: child.getAttribute("select") },
887
+ localContext,
888
+ );
889
+ }
890
+ }
891
+ }
892
+
893
+ this.processChildren(templateNode, localContext, output);
894
+ }
895
+
896
+ /**
897
+ * Process child nodes of an XSLT element
898
+ */
899
+ processChildren(node, context, output) {
900
+ for (const child of node.childNodes) {
901
+ this.processNode(child, context, output);
902
+ }
903
+ }
904
+
905
+ /**
906
+ * Process a single node in the stylesheet
907
+ */
908
+ processNode(node, context, output) {
909
+ switch (node.nodeType) {
910
+ case 1: // Element
911
+ this.processElement(node, context, output);
912
+ break;
913
+
914
+ case 3: // Text
915
+ case 4: {
916
+ // CDATA
917
+ // Output text if not whitespace only (or if preserving space)
918
+ const text = node.nodeValue;
919
+ if (text && (text.trim() || this.shouldPreserveSpace(node))) {
920
+ const textNode = context.outputDocument.createTextNode(text);
921
+ output.appendChild(textNode);
922
+ }
923
+ break;
924
+ }
925
+ }
926
+ }
927
+
928
+ shouldPreserveSpace(node) {
929
+ // Check xml:space attribute on ancestors
930
+ let current = node.parentNode;
931
+ while (current && current.nodeType === 1) {
932
+ const space = current.getAttribute("xml:space");
933
+ if (space === "preserve") return true;
934
+ if (space === "default") return false;
935
+ current = current.parentNode;
936
+ }
937
+ return false;
938
+ }
939
+
940
+ /**
941
+ * Process an element in the stylesheet
942
+ */
943
+ processElement(node, context, output) {
944
+ // Check if XSLT element
945
+ if (this.isXsltNamespace(node)) {
946
+ this.processXsltElement(node, context, output);
947
+ } else {
948
+ // Literal result element
949
+ this.processLiteralResultElement(node, context, output);
950
+ }
951
+ }
952
+
953
+ /**
954
+ * Process an XSLT instruction element
955
+ */
956
+ processXsltElement(node, context, output) {
957
+ const localName = node.localName || node.nodeName.replace(/^xsl:/, "");
958
+
959
+ switch (localName) {
960
+ case "apply-templates":
961
+ this.xslApplyTemplates(node, context, output);
962
+ break;
963
+
964
+ case "call-template":
965
+ this.xslCallTemplate(node, context, output);
966
+ break;
967
+
968
+ case "value-of":
969
+ this.xslValueOf(node, context, output);
970
+ break;
971
+
972
+ case "text":
973
+ this.xslText(node, context, output);
974
+ break;
975
+
976
+ case "element":
977
+ this.xslElement(node, context, output);
978
+ break;
979
+
980
+ case "attribute":
981
+ this.xslAttribute(node, context, output);
982
+ break;
983
+
984
+ case "if":
985
+ this.xslIf(node, context, output);
986
+ break;
987
+
988
+ case "choose":
989
+ this.xslChoose(node, context, output);
990
+ break;
991
+
992
+ case "for-each":
993
+ this.xslForEach(node, context, output);
994
+ break;
995
+
996
+ case "copy":
997
+ this.xslCopy(node, context, output);
998
+ break;
999
+
1000
+ case "copy-of":
1001
+ this.xslCopyOf(node, context, output);
1002
+ break;
1003
+
1004
+ case "variable":
1005
+ this.xslVariable(node, context, output);
1006
+ break;
1007
+
1008
+ case "param":
1009
+ // Params are processed at template start
1010
+ break;
1011
+
1012
+ case "comment":
1013
+ this.xslComment(node, context, output);
1014
+ break;
1015
+
1016
+ case "processing-instruction":
1017
+ this.xslProcessingInstruction(node, context, output);
1018
+ break;
1019
+
1020
+ case "number":
1021
+ this.xslNumber(node, context, output);
1022
+ break;
1023
+
1024
+ case "sort":
1025
+ // Handled by apply-templates and for-each
1026
+ break;
1027
+
1028
+ case "with-param":
1029
+ // Handled by call-template and apply-templates
1030
+ break;
1031
+
1032
+ case "message":
1033
+ this.xslMessage(node, context, output);
1034
+ break;
1035
+
1036
+ case "fallback":
1037
+ // Used for forward compatibility
1038
+ break;
1039
+
1040
+ default:
1041
+ console.warn(`Unknown XSLT element: ${localName}`);
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * Process a literal result element (non-XSLT)
1047
+ */
1048
+ processLiteralResultElement(node, context, output) {
1049
+ // Create element in output
1050
+ let outputElement;
1051
+ const namespaceURI = node.namespaceURI;
1052
+ const nodeName = node.nodeName;
1053
+
1054
+ // Apply namespace aliases
1055
+ let resolvedNS = namespaceURI;
1056
+ if (namespaceURI) {
1057
+ for (const [from, to] of Object.entries(this.namespaceAliases)) {
1058
+ if (this.namespaces[from] === namespaceURI) {
1059
+ resolvedNS = this.namespaces[to] || to;
1060
+ break;
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ if (resolvedNS && context.outputDocument.createElementNS) {
1066
+ outputElement = context.outputDocument.createElementNS(
1067
+ resolvedNS,
1068
+ nodeName,
1069
+ );
1070
+ } else {
1071
+ outputElement = context.outputDocument.createElement(nodeName);
1072
+ }
1073
+
1074
+ // Copy attributes (except XSLT namespace)
1075
+ if (node.attributes) {
1076
+ for (const attr of node.attributes) {
1077
+ if (attr.namespaceURI === XSLT_NS) continue;
1078
+ if (attr.name.startsWith("xmlns")) continue;
1079
+
1080
+ // Process attribute value templates
1081
+ const value = this.processAttributeValueTemplate(attr.value, context);
1082
+ outputElement.setAttribute(attr.name, value);
1083
+ }
1084
+ }
1085
+
1086
+ // Process children
1087
+ this.processChildren(node, context, outputElement);
1088
+
1089
+ output.appendChild(outputElement);
1090
+ }
1091
+
1092
+ /**
1093
+ * Process attribute value templates (expressions in curly braces)
1094
+ */
1095
+ processAttributeValueTemplate(value, context) {
1096
+ if (!value.includes("{")) return value;
1097
+
1098
+ let result = "";
1099
+ let i = 0;
1100
+
1101
+ while (i < value.length) {
1102
+ if (value[i] === "{") {
1103
+ if (value[i + 1] === "{") {
1104
+ // Escaped brace
1105
+ result += "{";
1106
+ i += 2;
1107
+ } else {
1108
+ // Find closing brace
1109
+ let depth = 1;
1110
+ let j = i + 1;
1111
+ while (j < value.length && depth > 0) {
1112
+ if (value[j] === "{") depth++;
1113
+ else if (value[j] === "}") depth--;
1114
+ j++;
1115
+ }
1116
+
1117
+ const expr = value.substring(i + 1, j - 1);
1118
+ const evalResult = this.evaluateXPath(expr, context);
1119
+ result += this.xpathEvaluator.toString(evalResult);
1120
+ i = j;
1121
+ }
1122
+ } else if (value[i] === "}") {
1123
+ if (value[i + 1] === "}") {
1124
+ // Escaped brace
1125
+ result += "}";
1126
+ i += 2;
1127
+ } else {
1128
+ throw new Error("Unmatched } in attribute value template");
1129
+ }
1130
+ } else {
1131
+ result += value[i];
1132
+ i++;
1133
+ }
1134
+ }
1135
+
1136
+ return result;
1137
+ }
1138
+
1139
+ // XSLT Instructions
1140
+ xslApplyTemplates(node, context, output) {
1141
+ const select = node.getAttribute("select") || "node()";
1142
+ const mode = node.getAttribute("mode") || null;
1143
+
1144
+ // Evaluate select expression
1145
+ let nodes = this.evaluateXPath(select, context);
1146
+ if (!Array.isArray(nodes)) {
1147
+ nodes = nodes ? [nodes] : [];
1148
+ }
1149
+
1150
+ // Collect sort specifications
1151
+ const sortSpecs = [];
1152
+ for (const child of node.childNodes) {
1153
+ if (child.nodeType === 1 && this.isXsltElement(child, "sort")) {
1154
+ sortSpecs.push({
1155
+ select: child.getAttribute("select") || ".",
1156
+ order: child.getAttribute("order") || "ascending",
1157
+ dataType: child.getAttribute("data-type") || "text",
1158
+ caseOrder: child.getAttribute("case-order") || "upper-first",
1159
+ lang: child.getAttribute("lang"),
1160
+ });
1161
+ }
1162
+ }
1163
+
1164
+ // Apply sorting
1165
+ if (sortSpecs.length > 0) {
1166
+ nodes = this.sortNodes(nodes, sortSpecs, context);
1167
+ }
1168
+
1169
+ // Collect with-param values
1170
+ const params = {};
1171
+ for (const child of node.childNodes) {
1172
+ if (child.nodeType === 1 && this.isXsltElement(child, "with-param")) {
1173
+ const name = child.getAttribute("name");
1174
+ const selectAttr = child.getAttribute("select");
1175
+ if (selectAttr) {
1176
+ params[name] = this.evaluateXPath(selectAttr, context);
1177
+ } else {
1178
+ const fragment = context.outputDocument.createDocumentFragment();
1179
+ this.processChildren(child, context, fragment);
1180
+ params[name] = fragment;
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ // Apply templates with new context including params
1186
+ const newContext = context.clone({
1187
+ parameters: { ...context.parameters, ...params },
1188
+ });
1189
+ this.applyTemplates(nodes, mode, newContext, output);
1190
+ }
1191
+
1192
+ xslCallTemplate(node, context, output) {
1193
+ const name = node.getAttribute("name");
1194
+
1195
+ // Find named template
1196
+ const template = this.templates.find((t) => t.name === name);
1197
+ if (!template) {
1198
+ throw new Error(`Template not found: ${name}`);
1199
+ }
1200
+
1201
+ // Collect with-param values
1202
+ const params = {};
1203
+ for (const child of node.childNodes) {
1204
+ if (child.nodeType === 1 && this.isXsltElement(child, "with-param")) {
1205
+ const paramName = child.getAttribute("name");
1206
+ const selectAttr = child.getAttribute("select");
1207
+ if (selectAttr) {
1208
+ params[paramName] = this.evaluateXPath(selectAttr, context);
1209
+ } else {
1210
+ const fragment = context.outputDocument.createDocumentFragment();
1211
+ this.processChildren(child, context, fragment);
1212
+ params[paramName] = fragment;
1213
+ }
1214
+ }
1215
+ }
1216
+
1217
+ // Call template with params
1218
+ const newContext = context.clone({
1219
+ parameters: { ...context.parameters, ...params },
1220
+ });
1221
+ this.processTemplate(template.node, newContext, output);
1222
+ }
1223
+
1224
+ xslValueOf(node, context, output) {
1225
+ const select = node.getAttribute("select");
1226
+ const disableOutputEscaping =
1227
+ node.getAttribute("disable-output-escaping") === "yes";
1228
+
1229
+ const result = this.evaluateXPath(select, context);
1230
+ const text = this.xpathEvaluator.toString(result);
1231
+
1232
+ if (text) {
1233
+ const textNode = context.outputDocument.createTextNode(text);
1234
+ if (disableOutputEscaping) {
1235
+ textNode._disableOutputEscaping = true;
1236
+ }
1237
+ output.appendChild(textNode);
1238
+ }
1239
+ }
1240
+
1241
+ xslText(node, context, output) {
1242
+ const disableOutputEscaping =
1243
+ node.getAttribute("disable-output-escaping") === "yes";
1244
+ let text = "";
1245
+
1246
+ for (const child of node.childNodes) {
1247
+ if (child.nodeType === 3 || child.nodeType === 4) {
1248
+ text += child.nodeValue || "";
1249
+ }
1250
+ }
1251
+
1252
+ if (text) {
1253
+ const textNode = context.outputDocument.createTextNode(text);
1254
+ if (disableOutputEscaping) {
1255
+ textNode._disableOutputEscaping = true;
1256
+ }
1257
+ output.appendChild(textNode);
1258
+ }
1259
+ }
1260
+
1261
+ xslElement(node, context, output) {
1262
+ const name = this.processAttributeValueTemplate(
1263
+ node.getAttribute("name"),
1264
+ context,
1265
+ );
1266
+ const namespace = node.getAttribute("namespace");
1267
+ const useAttributeSets = node.getAttribute("use-attribute-sets");
1268
+
1269
+ let element;
1270
+ if (namespace) {
1271
+ const ns = this.processAttributeValueTemplate(namespace, context);
1272
+ element = context.outputDocument.createElementNS(ns, name);
1273
+ } else {
1274
+ element = context.outputDocument.createElement(name);
1275
+ }
1276
+
1277
+ // Apply attribute sets
1278
+ if (useAttributeSets) {
1279
+ this.applyAttributeSets(useAttributeSets, context, element);
1280
+ }
1281
+
1282
+ this.processChildren(node, context, element);
1283
+ output.appendChild(element);
1284
+ }
1285
+
1286
+ xslAttribute(node, context, output) {
1287
+ const name = this.processAttributeValueTemplate(
1288
+ node.getAttribute("name"),
1289
+ context,
1290
+ );
1291
+ const namespace = node.getAttribute("namespace");
1292
+
1293
+ // Collect content
1294
+ const fragment = context.outputDocument.createDocumentFragment();
1295
+ this.processChildren(node, context, fragment);
1296
+
1297
+ // Get text content
1298
+ let value = "";
1299
+ const getText = (n) => {
1300
+ if (n.nodeType === 3 || n.nodeType === 4) {
1301
+ value += n.nodeValue || "";
1302
+ } else if (n.childNodes) {
1303
+ for (const child of n.childNodes) {
1304
+ getText(child);
1305
+ }
1306
+ }
1307
+ };
1308
+ getText(fragment);
1309
+
1310
+ // Add attribute to parent element
1311
+ if (output.nodeType === 1) {
1312
+ if (namespace) {
1313
+ const ns = this.processAttributeValueTemplate(namespace, context);
1314
+ output.setAttributeNS(ns, name, value);
1315
+ } else {
1316
+ output.setAttribute(name, value);
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ xslIf(node, context, output) {
1322
+ const test = node.getAttribute("test");
1323
+ const result = this.evaluateXPath(test, context);
1324
+
1325
+ if (this.xpathEvaluator.toBoolean(result)) {
1326
+ this.processChildren(node, context, output);
1327
+ }
1328
+ }
1329
+
1330
+ xslChoose(node, context, output) {
1331
+ for (const child of node.childNodes) {
1332
+ if (child.nodeType !== 1) continue;
1333
+
1334
+ if (this.isXsltElement(child, "when")) {
1335
+ const test = child.getAttribute("test");
1336
+ const result = this.evaluateXPath(test, context);
1337
+
1338
+ if (this.xpathEvaluator.toBoolean(result)) {
1339
+ this.processChildren(child, context, output);
1340
+ return;
1341
+ }
1342
+ } else if (this.isXsltElement(child, "otherwise")) {
1343
+ this.processChildren(child, context, output);
1344
+ return;
1345
+ }
1346
+ }
1347
+ }
1348
+
1349
+ xslForEach(node, context, output) {
1350
+ const select = node.getAttribute("select");
1351
+
1352
+ let nodes = this.evaluateXPath(select, context);
1353
+ if (!Array.isArray(nodes)) {
1354
+ nodes = nodes ? [nodes] : [];
1355
+ }
1356
+
1357
+ // Collect sort specifications
1358
+ const sortSpecs = [];
1359
+ for (const child of node.childNodes) {
1360
+ if (child.nodeType === 1 && this.isXsltElement(child, "sort")) {
1361
+ sortSpecs.push({
1362
+ select: child.getAttribute("select") || ".",
1363
+ order: child.getAttribute("order") || "ascending",
1364
+ dataType: child.getAttribute("data-type") || "text",
1365
+ caseOrder: child.getAttribute("case-order") || "upper-first",
1366
+ lang: child.getAttribute("lang"),
1367
+ });
1368
+ }
1369
+ }
1370
+
1371
+ // Apply sorting
1372
+ if (sortSpecs.length > 0) {
1373
+ nodes = this.sortNodes(nodes, sortSpecs, context);
1374
+ }
1375
+
1376
+ // Process each node
1377
+ for (let i = 0; i < nodes.length; i++) {
1378
+ const newContext = context.clone({
1379
+ currentNode: nodes[i],
1380
+ currentNodeList: nodes,
1381
+ position: i + 1,
1382
+ });
1383
+
1384
+ this.processChildren(node, newContext, output);
1385
+ }
1386
+ }
1387
+
1388
+ xslCopy(node, context, output) {
1389
+ const currentNode = context.currentNode;
1390
+ const useAttributeSets = node.getAttribute("use-attribute-sets");
1391
+
1392
+ switch (currentNode.nodeType) {
1393
+ case 1: {
1394
+ // Element
1395
+ let copy;
1396
+ if (currentNode.namespaceURI) {
1397
+ copy = context.outputDocument.createElementNS(
1398
+ currentNode.namespaceURI,
1399
+ currentNode.nodeName,
1400
+ );
1401
+ } else {
1402
+ copy = context.outputDocument.createElement(currentNode.nodeName);
1403
+ }
1404
+
1405
+ if (useAttributeSets) {
1406
+ this.applyAttributeSets(useAttributeSets, context, copy);
1407
+ }
1408
+
1409
+ this.processChildren(node, context, copy);
1410
+ output.appendChild(copy);
1411
+ break;
1412
+ }
1413
+
1414
+ case 2: // Attribute
1415
+ if (output.nodeType === 1) {
1416
+ output.setAttribute(currentNode.name, currentNode.value);
1417
+ }
1418
+ break;
1419
+
1420
+ case 3: // Text
1421
+ case 4: {
1422
+ // CDATA
1423
+ const textCopy = context.outputDocument.createTextNode(
1424
+ currentNode.nodeValue || "",
1425
+ );
1426
+ output.appendChild(textCopy);
1427
+ break;
1428
+ }
1429
+
1430
+ case 7: {
1431
+ // Processing Instruction
1432
+ const piCopy = context.outputDocument.createProcessingInstruction(
1433
+ currentNode.target,
1434
+ currentNode.data,
1435
+ );
1436
+ output.appendChild(piCopy);
1437
+ break;
1438
+ }
1439
+
1440
+ case 8: {
1441
+ // Comment
1442
+ const commentCopy = context.outputDocument.createComment(
1443
+ currentNode.nodeValue || "",
1444
+ );
1445
+ output.appendChild(commentCopy);
1446
+ break;
1447
+ }
1448
+
1449
+ case 9: // Document
1450
+ case 11: // Document Fragment
1451
+ this.processChildren(node, context, output);
1452
+ break;
1453
+ }
1454
+ }
1455
+
1456
+ xslCopyOf(node, context, output) {
1457
+ const select = node.getAttribute("select");
1458
+ const result = this.evaluateXPath(select, context);
1459
+
1460
+ this.copyToOutput(result, context, output);
1461
+ }
1462
+
1463
+ copyToOutput(value, context, output) {
1464
+ if (Array.isArray(value)) {
1465
+ for (const item of value) {
1466
+ this.copyToOutput(item, context, output);
1467
+ }
1468
+ return;
1469
+ }
1470
+
1471
+ if (value && value.nodeType) {
1472
+ // Deep copy node
1473
+ const clone = this.deepCloneNode(value, context.outputDocument);
1474
+ output.appendChild(clone);
1475
+ } else if (
1476
+ typeof value === "string" ||
1477
+ typeof value === "number" ||
1478
+ typeof value === "boolean"
1479
+ ) {
1480
+ const text = context.outputDocument.createTextNode(String(value));
1481
+ output.appendChild(text);
1482
+ }
1483
+ }
1484
+
1485
+ deepCloneNode(node, targetDoc) {
1486
+ switch (node.nodeType) {
1487
+ case 1: {
1488
+ // Element
1489
+ let clone;
1490
+ if (node.namespaceURI && targetDoc.createElementNS) {
1491
+ clone = targetDoc.createElementNS(node.namespaceURI, node.nodeName);
1492
+ } else {
1493
+ clone = targetDoc.createElement(node.nodeName);
1494
+ }
1495
+
1496
+ if (node.attributes) {
1497
+ for (const attr of node.attributes) {
1498
+ clone.setAttribute(attr.name, attr.value);
1499
+ }
1500
+ }
1501
+
1502
+ for (const child of node.childNodes) {
1503
+ clone.appendChild(this.deepCloneNode(child, targetDoc));
1504
+ }
1505
+
1506
+ return clone;
1507
+ }
1508
+
1509
+ case 3: // Text
1510
+ case 4: // CDATA
1511
+ return targetDoc.createTextNode(node.nodeValue || "");
1512
+
1513
+ case 7: // Processing Instruction
1514
+ return targetDoc.createProcessingInstruction(node.target, node.data);
1515
+
1516
+ case 8: // Comment
1517
+ return targetDoc.createComment(node.nodeValue || "");
1518
+
1519
+ case 11: {
1520
+ // Document Fragment
1521
+ const frag = targetDoc.createDocumentFragment();
1522
+ for (const child of node.childNodes) {
1523
+ frag.appendChild(this.deepCloneNode(child, targetDoc));
1524
+ }
1525
+ return frag;
1526
+ }
1527
+
1528
+ default:
1529
+ return targetDoc.createTextNode("");
1530
+ }
1531
+ }
1532
+
1533
+ xslVariable(node, context, _output) {
1534
+ const name = node.getAttribute("name");
1535
+ const select = node.getAttribute("select");
1536
+
1537
+ let value;
1538
+ if (select) {
1539
+ value = this.evaluateXPath(select, context);
1540
+ } else {
1541
+ const fragment = context.outputDocument.createDocumentFragment();
1542
+ this.processChildren(node, context, fragment);
1543
+ value = fragment;
1544
+ }
1545
+
1546
+ context.setVariable(name, value);
1547
+ }
1548
+
1549
+ xslComment(node, context, output) {
1550
+ const fragment = context.outputDocument.createDocumentFragment();
1551
+ this.processChildren(node, context, fragment);
1552
+
1553
+ let text = "";
1554
+ const getText = (n) => {
1555
+ if (n.nodeType === 3 || n.nodeType === 4) {
1556
+ text += n.nodeValue || "";
1557
+ } else if (n.childNodes) {
1558
+ for (const child of n.childNodes) {
1559
+ getText(child);
1560
+ }
1561
+ }
1562
+ };
1563
+ getText(fragment);
1564
+
1565
+ const comment = context.outputDocument.createComment(text);
1566
+ output.appendChild(comment);
1567
+ }
1568
+
1569
+ xslProcessingInstruction(node, context, output) {
1570
+ const name = this.processAttributeValueTemplate(
1571
+ node.getAttribute("name"),
1572
+ context,
1573
+ );
1574
+
1575
+ const fragment = context.outputDocument.createDocumentFragment();
1576
+ this.processChildren(node, context, fragment);
1577
+
1578
+ let data = "";
1579
+ const getText = (n) => {
1580
+ if (n.nodeType === 3 || n.nodeType === 4) {
1581
+ data += n.nodeValue || "";
1582
+ } else if (n.childNodes) {
1583
+ for (const child of n.childNodes) {
1584
+ getText(child);
1585
+ }
1586
+ }
1587
+ };
1588
+ getText(fragment);
1589
+
1590
+ const pi = context.outputDocument.createProcessingInstruction(name, data);
1591
+ output.appendChild(pi);
1592
+ }
1593
+
1594
+ xslNumber(node, context, output) {
1595
+ const value = node.getAttribute("value");
1596
+ const format = node.getAttribute("format") || "1";
1597
+ const level = node.getAttribute("level") || "single";
1598
+
1599
+ let number;
1600
+ if (value) {
1601
+ number = Math.round(
1602
+ this.xpathEvaluator.toNumber(this.evaluateXPath(value, context)),
1603
+ );
1604
+ } else {
1605
+ // Count based on level
1606
+ number = this.countNumber(context.currentNode, level, node, context);
1607
+ }
1608
+
1609
+ const formatted = this.formatNumber(number, format);
1610
+ const text = context.outputDocument.createTextNode(formatted);
1611
+ output.appendChild(text);
1612
+ }
1613
+
1614
+ countNumber(node, level, spec, context) {
1615
+ const count = spec.getAttribute("count");
1616
+ const _from = spec.getAttribute("from");
1617
+
1618
+ // Simplified implementation
1619
+ if (level === "single") {
1620
+ // Count preceding siblings matching pattern
1621
+ let n = 1;
1622
+ let sibling = node.previousSibling;
1623
+ while (sibling) {
1624
+ if (sibling.nodeType === 1) {
1625
+ if (!count || this.matchesPattern(sibling, count, context)) {
1626
+ n++;
1627
+ }
1628
+ }
1629
+ sibling = sibling.previousSibling;
1630
+ }
1631
+ return n;
1632
+ }
1633
+
1634
+ return 1;
1635
+ }
1636
+
1637
+ formatNumber(number, format) {
1638
+ // Simple format implementation
1639
+ if (/^[0-9]+$/.test(format)) {
1640
+ return String(number).padStart(format.length, "0");
1641
+ }
1642
+
1643
+ if (format === "a") {
1644
+ return String.fromCharCode(96 + ((number - 1) % 26) + 1);
1645
+ }
1646
+
1647
+ if (format === "A") {
1648
+ return String.fromCharCode(64 + ((number - 1) % 26) + 1);
1649
+ }
1650
+
1651
+ if (format === "i") {
1652
+ return this.toRoman(number).toLowerCase();
1653
+ }
1654
+
1655
+ if (format === "I") {
1656
+ return this.toRoman(number);
1657
+ }
1658
+
1659
+ return String(number);
1660
+ }
1661
+
1662
+ toRoman(num) {
1663
+ const romanNumerals = [
1664
+ ["M", 1000],
1665
+ ["CM", 900],
1666
+ ["D", 500],
1667
+ ["CD", 400],
1668
+ ["C", 100],
1669
+ ["XC", 90],
1670
+ ["L", 50],
1671
+ ["XL", 40],
1672
+ ["X", 10],
1673
+ ["IX", 9],
1674
+ ["V", 5],
1675
+ ["IV", 4],
1676
+ ["I", 1],
1677
+ ];
1678
+
1679
+ let result = "";
1680
+ for (const [numeral, value] of romanNumerals) {
1681
+ while (num >= value) {
1682
+ result += numeral;
1683
+ num -= value;
1684
+ }
1685
+ }
1686
+ return result;
1687
+ }
1688
+
1689
+ xslMessage(node, context, _output) {
1690
+ const terminate = node.getAttribute("terminate") === "yes";
1691
+
1692
+ const fragment = context.outputDocument.createDocumentFragment();
1693
+ this.processChildren(node, context, fragment);
1694
+
1695
+ let text = "";
1696
+ const getText = (n) => {
1697
+ if (n.nodeType === 3 || n.nodeType === 4) {
1698
+ text += n.nodeValue || "";
1699
+ } else if (n.childNodes) {
1700
+ for (const child of n.childNodes) {
1701
+ getText(child);
1702
+ }
1703
+ }
1704
+ };
1705
+ getText(fragment);
1706
+
1707
+ console.log("XSLT Message:", text);
1708
+
1709
+ if (terminate) {
1710
+ throw new Error(`XSLT terminated: ${text}`);
1711
+ }
1712
+ }
1713
+
1714
+ applyAttributeSets(names, context, element) {
1715
+ const setNames = names.split(/\s+/).filter(Boolean);
1716
+
1717
+ for (const name of setNames) {
1718
+ const attrSet = this.attributeSets[name];
1719
+ if (attrSet) {
1720
+ // Apply inherited sets first
1721
+ if (attrSet.useAttributeSets.length > 0) {
1722
+ this.applyAttributeSets(
1723
+ attrSet.useAttributeSets.join(" "),
1724
+ context,
1725
+ element,
1726
+ );
1727
+ }
1728
+
1729
+ // Apply attributes from this set
1730
+ for (const child of attrSet.node.childNodes) {
1731
+ if (child.nodeType === 1 && this.isXsltElement(child, "attribute")) {
1732
+ this.xslAttribute(child, context, element);
1733
+ }
1734
+ }
1735
+ }
1736
+ }
1737
+ }
1738
+
1739
+ sortNodes(nodes, sortSpecs, context) {
1740
+ return [...nodes].sort((a, b) => {
1741
+ for (const spec of sortSpecs) {
1742
+ const contextA = context.clone({ currentNode: a });
1743
+ const contextB = context.clone({ currentNode: b });
1744
+
1745
+ let valueA = this.evaluateXPath(spec.select, contextA);
1746
+ let valueB = this.evaluateXPath(spec.select, contextB);
1747
+
1748
+ // Convert to string for comparison (ensure non-null values)
1749
+ valueA = this.xpathEvaluator.toString(valueA) || "";
1750
+ valueB = this.xpathEvaluator.toString(valueB) || "";
1751
+
1752
+ if (spec.dataType === "number") {
1753
+ valueA = parseFloat(valueA) || 0;
1754
+ valueB = parseFloat(valueB) || 0;
1755
+ } else {
1756
+ // Text comparison
1757
+ if (spec.caseOrder === "lower-first") {
1758
+ valueA = valueA.toLowerCase();
1759
+ valueB = valueB.toLowerCase();
1760
+ } else {
1761
+ valueA = valueA.toUpperCase();
1762
+ valueB = valueB.toUpperCase();
1763
+ }
1764
+ }
1765
+
1766
+ let cmp;
1767
+ if (typeof valueA === "number") {
1768
+ cmp = valueA - valueB;
1769
+ } else {
1770
+ cmp = valueA.localeCompare(valueB, spec.lang || undefined);
1771
+ }
1772
+
1773
+ if (spec.order === "descending") {
1774
+ cmp = -cmp;
1775
+ }
1776
+
1777
+ if (cmp !== 0) return cmp;
1778
+ }
1779
+
1780
+ return 0;
1781
+ });
1782
+ }
1783
+
1784
+ evaluateXPath(expr, context) {
1785
+ const ast = parseXPath(expr);
1786
+ const xpathContext = new XPathContext(
1787
+ context.currentNode,
1788
+ context.position,
1789
+ context.currentNodeList.length,
1790
+ { ...context.variables, ...context.parameters },
1791
+ context.namespaces,
1792
+ );
1793
+ return this.xpathEvaluator.evaluate(ast, xpathContext);
1794
+ }
1795
+
1796
+ isXsltNamespace(node) {
1797
+ return (
1798
+ node.namespaceURI === XSLT_NS ||
1799
+ (node.nodeName && node.nodeName.startsWith("xsl:"))
1800
+ );
1801
+ }
1802
+
1803
+ isXsltElement(node, localName) {
1804
+ if (node.nodeType !== 1) return false;
1805
+
1806
+ const nodeName = node.localName || node.nodeName;
1807
+ return (
1808
+ (node.namespaceURI === XSLT_NS && nodeName === localName) ||
1809
+ node.nodeName === `xsl:${localName}`
1810
+ );
1811
+ }
1812
+ }