From 4985c1ff273a542617ce3c31ac3e30f69b69c37c Mon Sep 17 00:00:00 2001 From: Leonel Sanches da Silva <53848829+leonelsanchesdasilva@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:05:40 -0800 Subject: [PATCH 1/4] Fixing issue with Namespaced Attributes (https://github.com/DesignLiquido/xslt-processor/issues/135). --- src/dom/xml-functions.ts | 5 +- src/dom/xml-parser.ts | 2 +- src/dom/xnode.ts | 2 + src/xpath/lib | 2 +- src/xpath/selector.ts | 4 +- src/xpath/xpath.ts | 8 +- src/xslt/xslt.ts | 51 ++++-- tests/namespaced-attributes.test.tsx | 234 +++++++++++++++++++++++++++ 8 files changed, 281 insertions(+), 27 deletions(-) create mode 100644 tests/namespaced-attributes.test.tsx diff --git a/src/dom/xml-functions.ts b/src/dom/xml-functions.ts index 8c03246..5964ab7 100644 --- a/src/dom/xml-functions.ts +++ b/src/dom/xml-functions.ts @@ -224,7 +224,10 @@ function xmlTransformedTextRecursive(node: XNode, buffer: string[], options: Xml const nodeType = node.nodeType const nodeValue = node.nodeValue; if (nodeType === DOM_TEXT_NODE) { - if (node.nodeValue && node.nodeValue.trim() !== '') { + // For text nodes created by xsl:text, don't trim whitespace + // For other text nodes, skip whitespace-only ones + const isFromXslText = node.fromXslText === true; + if (node.nodeValue && (isFromXslText || node.nodeValue.trim() !== '')) { const finalText = node.escape && options.escape ? xmlEscapeText(node.nodeValue): xmlUnescapeText(node.nodeValue); buffer.push(finalText); diff --git a/src/dom/xml-parser.ts b/src/dom/xml-parser.ts index 9436af4..3ae7a3b 100644 --- a/src/dom/xml-parser.ts +++ b/src/dom/xml-parser.ts @@ -285,7 +285,7 @@ export class XmlParser { } else if (!tag && char === '<') { let text = xml.slice(start, i); if (text && parent !== root) { - domAppendChild(parent, domCreateTextNode(xmlDocument, text)); + domAppendChild(parent, domCreateTextNode(xmlDocument, he.decode(text))); } if (xml.slice(i + 1, i + 4) === '!--') { let endTagIndex = xml.slice(i + 4).indexOf('-->'); diff --git a/src/dom/xnode.ts b/src/dom/xnode.ts index 8c1f199..2a8a7d4 100644 --- a/src/dom/xnode.ts +++ b/src/dom/xnode.ts @@ -28,6 +28,7 @@ export class XNode { visited: boolean; escape: boolean; + fromXslText: boolean; static _unusedXNodes: any[] = []; @@ -36,6 +37,7 @@ export class XNode { this.childNodes = []; this.visited = false; this.escape = true; + this.fromXslText = false; this.siblingPosition = -1; this.init(type, name, opt_value, opt_owner, opt_namespace); diff --git a/src/xpath/lib b/src/xpath/lib index 3186237..eee689c 160000 --- a/src/xpath/lib +++ b/src/xpath/lib @@ -1 +1 @@ -Subproject commit 31862374f940816acfecb0757c693820c4228c29 +Subproject commit eee689c487ff2a67d50fc166541959dd6611711f diff --git a/src/xpath/selector.ts b/src/xpath/selector.ts index 6e001e7..653ab7d 100644 --- a/src/xpath/selector.ts +++ b/src/xpath/selector.ts @@ -43,7 +43,7 @@ export class XPathSelector { nodeType: this.getNodeType(node), nodeName: node.nodeName || '#document', localName: node.localName || node.nodeName, - namespaceURI: node.namespaceUri || null, + namespaceUri: node.namespaceUri || null, textContent: node.nodeValue, parentNode: null, // Não converter para evitar ciclos childNodes: [], // Será preenchido depois @@ -99,7 +99,7 @@ export class XPathSelector { text: xpathNode.textContent, value: xpathNode.textContent, localName: xpathNode.localName, - namespaceURI: xpathNode.namespaceURI, + namespaceUri: xpathNode.namespaceUri, parentNode: xpathNode.parentNode ? this.convertFromXPathNode(xpathNode.parentNode) : undefined, children: xpathNode.childNodes ? Array.from(xpathNode.childNodes).map(child => this.convertFromXPathNode(child)) : undefined, attributes: xpathNode.attributes ? Array.from(xpathNode.attributes).map(attr => this.convertFromXPathNode(attr)) : undefined, diff --git a/src/xpath/xpath.ts b/src/xpath/xpath.ts index 0916053..9f61599 100644 --- a/src/xpath/xpath.ts +++ b/src/xpath/xpath.ts @@ -149,13 +149,7 @@ class NodeConverter { const adapted = node as any; // Add XPathNode-compatible properties if not present - if (!('namespaceURI' in adapted)) { - Object.defineProperty(adapted, 'namespaceURI', { - get() { return this.namespaceUri; }, - enumerable: true, - configurable: true - }); - } + // namespaceUri is now the standard property in XPathNode interface if (!('textContent' in adapted)) { Object.defineProperty(adapted, 'textContent', { diff --git a/src/xslt/xslt.ts b/src/xslt/xslt.ts index 3d9bd2d..6c6692e 100644 --- a/src/xslt/xslt.ts +++ b/src/xslt/xslt.ts @@ -357,10 +357,14 @@ export class Xslt { // This is the XSLT 3.0 compliant behavior - only ONE template executes per node. for (let j = 0; j < modifiedContext.contextSize(); ++j) { const currentNode = modifiedContext.nodeList[j]; - // If the current node is text, there's no need to test all the templates // against it. Just appending it to its parent is fine. if (currentNode.nodeType === DOM_TEXT_NODE) { + // Check if this whitespace-only text node should be stripped + if (!this.xsltPassText(currentNode)) { + // Skip whitespace-only text nodes in apply-templates + continue; + } const textNodeContext = context.clone( [currentNode], 0 @@ -1265,6 +1269,8 @@ export class Xslt { protected xsltText(context: ExprContext, template: XNode, output?: XNode) { const text = xmlValue(template); const node = domCreateTextNode(this.outputDocument, text); + // Mark this node as coming from xsl:text so it won't be trimmed during serialization + node.fromXslText = true; const disableOutputEscaping = template.childNodes.filter( (a) => a.nodeType === DOM_ATTRIBUTE_NODE && a.nodeName === 'disable-output-escaping' ); @@ -1272,6 +1278,8 @@ export class Xslt { node.escape = false; } const destinationTextNode = output || this.outputDocument; + // Set siblingPosition to preserve insertion order during serialization + node.siblingPosition = destinationTextNode.childNodes.length; destinationTextNode.appendChild(node); } @@ -1429,8 +1437,6 @@ export class Xslt { for (const t of expandedTemplates) { try { - // For initial template selection, evaluate patterns from document root - // without axis override to ensure consistent matching for all patterns // For initial template selection, evaluate patterns from document root // without axis override to ensure consistent matching for all patterns const matchedNodes = this.xsltMatch(t.matchPattern, contextClone); @@ -1444,19 +1450,28 @@ export class Xslt { } if (matchCandidates.length > 0) { - // Sort by: importPrecedence DESC, effectivePriority DESC, documentOrder DESC - matchCandidates.sort((a, b) => { - if (a.priority.importPrecedence !== b.priority.importPrecedence) { - return b.priority.importPrecedence - a.priority.importPrecedence; - } - if (a.priority.effectivePriority !== b.priority.effectivePriority) { - return b.priority.effectivePriority - a.priority.effectivePriority; - } - return b.priority.documentOrder - a.priority.documentOrder; - }); + // First, check if "/" pattern matches - it's the document entry point and should be preferred + const rootPatternMatch = matchCandidates.find(c => c.priority.matchPattern === '/'); + let winner: { priority: TemplatePriority; matchedNodes: XNode[] }; + + if (rootPatternMatch) { + // Use the root template as entry point + winner = rootPatternMatch; + } else { + // Sort by: importPrecedence DESC, effectivePriority DESC, documentOrder DESC + matchCandidates.sort((a, b) => { + if (a.priority.importPrecedence !== b.priority.importPrecedence) { + return b.priority.importPrecedence - a.priority.importPrecedence; + } + if (a.priority.effectivePriority !== b.priority.effectivePriority) { + return b.priority.effectivePriority - a.priority.effectivePriority; + } + return b.priority.documentOrder - a.priority.documentOrder; + }); + winner = matchCandidates[0]; + } // Detect conflicts - const winner = matchCandidates[0]; const conflicts = matchCandidates.filter(t => t.priority.importPrecedence === winner.priority.importPrecedence && t.priority.effectivePriority === winner.priority.effectivePriority @@ -1625,7 +1640,13 @@ export class Xslt { // siblings of the children. const contextClone = context.clone(); for (let i = 0; i < template.childNodes.length; ++i) { - await this.xsltProcessContext(contextClone, template.childNodes[i], output); + const child = template.childNodes[i]; + // Skip attribute nodes - they are stored in childNodes but should not be + // processed as template content. Attributes belong to the element itself. + if (child.nodeType === DOM_ATTRIBUTE_NODE) { + continue; + } + await this.xsltProcessContext(contextClone, child, output); } } diff --git a/tests/namespaced-attributes.test.tsx b/tests/namespaced-attributes.test.tsx new file mode 100644 index 0000000..3ec558d --- /dev/null +++ b/tests/namespaced-attributes.test.tsx @@ -0,0 +1,234 @@ +/* eslint-disable no-console */ +/* eslint-disable no-undef */ +import assert from 'assert'; + +import { Xslt } from '../src/xslt'; +import { XmlParser } from '../src/dom'; + +describe('namespaced attributes', () => { + it('extract namespaced attribute with direct namespace prefix (@ns:attribute)', async () => { + const xml = ` + + +`; + + const xslt = ` + + + + + + + + +`; + + const xmlDoc = new XmlParser().xmlParse(xml); + const xsltDoc = new XmlParser().xmlParse(xslt); + + const processor = new Xslt(); + const result = await processor.xsltProcess(xmlDoc, xsltDoc); + + assert.equal(result, 'TestValue', 'Should extract namespaced attribute using direct prefix'); + }); + + it('extract namespaced attribute using local-name()', async () => { + const xml = ` + + +`; + + const xslt = ` + + + + + + + + +`; + + const xmlDoc = new XmlParser().xmlParse(xml); + const xsltDoc = new XmlParser().xmlParse(xslt); + + const processor = new Xslt(); + const result = await processor.xsltProcess(xmlDoc, xsltDoc); + + assert.equal(result, 'TestValue', 'Should extract namespaced attribute using local-name()'); + }); + + it('extract namespaced attribute using namespace-uri() and local-name()', async () => { + const xml = ` + + +`; + + const xslt = ` + + + + + + + + +`; + + const xmlDoc = new XmlParser().xmlParse(xml); + const xsltDoc = new XmlParser().xmlParse(xslt); + + const processor = new Xslt(); + const result = await processor.xsltProcess(xmlDoc, xsltDoc); + + assert.equal(result, 'TestValue', 'Should extract namespaced attribute using namespace-uri() and local-name()'); + }); + + it('all three methods for extracting namespaced attributes', async () => { + const xml = ` + + +`; + + const xslt = ` + + + + + + Method 1 (@ns:attribute): + + + + Method 2 (local-name): + + + + Method 3 (namespace-uri): + + + + +`; + + const xmlDoc = new XmlParser().xmlParse(xml); + const xsltDoc = new XmlParser().xmlParse(xslt); + + const processor = new Xslt(); + const result = await processor.xsltProcess(xmlDoc, xsltDoc); + + const expectedOutput = `Method 1 (@ns:attribute): TestValue +Method 2 (local-name): TestValue +Method 3 (namespace-uri): TestValue +`; + + assert.equal(result, expectedOutput, 'All three methods should return the same value'); + }); + + it('namespaced attribute with call-template', async () => { + const xml = ``; + + const xslt = ` + + + + A| + + |C + + + + B| + + |D + + + + + +`; + + const parser = new XmlParser(); + const xmlDoc = await parser.xmlParse(xml); + const xsltDoc = await parser.xmlParse(xslt); + const xsltProc = new Xslt(); + const result = await xsltProc.xsltProcess(xmlDoc, xsltDoc); + + const expectedOutput = 'A|B|VALUE|D|C'; + assert.equal(result, expectedOutput, `Expected "${expectedOutput}" but got "${result}". Output order bug: VALUE should appear inline, not deferred to the end`); + }); + + it('multiple namespaced attributes', async () => { + const xml = ` + + +`; + + const xslt = ` + + + + + + + | + + | + + + +`; + + const xmlDoc = new XmlParser().xmlParse(xml); + const xsltDoc = new XmlParser().xmlParse(xslt); + + const processor = new Xslt(); + const result = await processor.xsltProcess(xmlDoc, xsltDoc); + + const expectedOutput = 'Value1|Value2|Value3'; + assert.equal(result, expectedOutput, 'Should extract multiple namespaced attributes'); + }); + + it('mixed namespaced and non-namespaced attributes', async () => { + const xml = ` + + +`; + + const xslt = ` + + + + + + + | + + + +`; + + const xmlDoc = new XmlParser().xmlParse(xml); + const xsltDoc = new XmlParser().xmlParse(xslt); + + const processor = new Xslt(); + const result = await processor.xsltProcess(xmlDoc, xsltDoc); + + const expectedOutput = 'Normal|Namespaced'; + assert.equal(result, expectedOutput, 'Should extract both namespaced and non-namespaced attributes'); + }); +}); From d3918f757c37241011fb4ab362da500e7bf4f312 Mon Sep 17 00:00:00 2001 From: Leonel Sanches da Silva <53848829+leonelsanchesdasilva@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:11:02 -0800 Subject: [PATCH 2/4] Update tests/namespaced-attributes.test.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/namespaced-attributes.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/namespaced-attributes.test.tsx b/tests/namespaced-attributes.test.tsx index 3ec558d..62c9dec 100644 --- a/tests/namespaced-attributes.test.tsx +++ b/tests/namespaced-attributes.test.tsx @@ -1,5 +1,4 @@ -/* eslint-disable no-console */ -/* eslint-disable no-undef */ +/* eslint-env mocha */ import assert from 'assert'; import { Xslt } from '../src/xslt'; From ebd2aa57b672e45513e545387a5cd3784c1948c4 Mon Sep 17 00:00:00 2001 From: Leonel Sanches da Silva <53848829+leonelsanchesdasilva@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:13:23 -0800 Subject: [PATCH 3/4] Update src/xslt/xslt.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/xslt/xslt.ts | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/xslt/xslt.ts b/src/xslt/xslt.ts index 6c6692e..8399d89 100644 --- a/src/xslt/xslt.ts +++ b/src/xslt/xslt.ts @@ -1450,27 +1450,18 @@ export class Xslt { } if (matchCandidates.length > 0) { - // First, check if "/" pattern matches - it's the document entry point and should be preferred - const rootPatternMatch = matchCandidates.find(c => c.priority.matchPattern === '/'); - let winner: { priority: TemplatePriority; matchedNodes: XNode[] }; - - if (rootPatternMatch) { - // Use the root template as entry point - winner = rootPatternMatch; - } else { - // Sort by: importPrecedence DESC, effectivePriority DESC, documentOrder DESC - matchCandidates.sort((a, b) => { - if (a.priority.importPrecedence !== b.priority.importPrecedence) { - return b.priority.importPrecedence - a.priority.importPrecedence; - } - if (a.priority.effectivePriority !== b.priority.effectivePriority) { - return b.priority.effectivePriority - a.priority.effectivePriority; - } - return b.priority.documentOrder - a.priority.documentOrder; - }); - winner = matchCandidates[0]; - } + // Sort by: importPrecedence DESC, effectivePriority DESC, documentOrder DESC + matchCandidates.sort((a, b) => { + if (a.priority.importPrecedence !== b.priority.importPrecedence) { + return b.priority.importPrecedence - a.priority.importPrecedence; + } + if (a.priority.effectivePriority !== b.priority.effectivePriority) { + return b.priority.effectivePriority - a.priority.effectivePriority; + } + return b.priority.documentOrder - a.priority.documentOrder; + }); + const winner: { priority: TemplatePriority; matchedNodes: XNode[] } = matchCandidates[0]; // Detect conflicts const conflicts = matchCandidates.filter(t => t.priority.importPrecedence === winner.priority.importPrecedence && From 99a655bf68d3a61e301ec35b44697d26a2ef12f2 Mon Sep 17 00:00:00 2001 From: Leonel Sanches da Silva <53848829+leonelsanchesdasilva@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:17:26 -0800 Subject: [PATCH 4/4] Reverting Copilot suggestion. --- src/xslt/xslt.ts | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/xslt/xslt.ts b/src/xslt/xslt.ts index 8399d89..6c6692e 100644 --- a/src/xslt/xslt.ts +++ b/src/xslt/xslt.ts @@ -1450,18 +1450,27 @@ export class Xslt { } if (matchCandidates.length > 0) { - // Sort by: importPrecedence DESC, effectivePriority DESC, documentOrder DESC - matchCandidates.sort((a, b) => { - if (a.priority.importPrecedence !== b.priority.importPrecedence) { - return b.priority.importPrecedence - a.priority.importPrecedence; - } - if (a.priority.effectivePriority !== b.priority.effectivePriority) { - return b.priority.effectivePriority - a.priority.effectivePriority; - } - return b.priority.documentOrder - a.priority.documentOrder; - }); + // First, check if "/" pattern matches - it's the document entry point and should be preferred + const rootPatternMatch = matchCandidates.find(c => c.priority.matchPattern === '/'); + let winner: { priority: TemplatePriority; matchedNodes: XNode[] }; + + if (rootPatternMatch) { + // Use the root template as entry point + winner = rootPatternMatch; + } else { + // Sort by: importPrecedence DESC, effectivePriority DESC, documentOrder DESC + matchCandidates.sort((a, b) => { + if (a.priority.importPrecedence !== b.priority.importPrecedence) { + return b.priority.importPrecedence - a.priority.importPrecedence; + } + if (a.priority.effectivePriority !== b.priority.effectivePriority) { + return b.priority.effectivePriority - a.priority.effectivePriority; + } + return b.priority.documentOrder - a.priority.documentOrder; + }); + winner = matchCandidates[0]; + } - const winner: { priority: TemplatePriority; matchedNodes: XNode[] } = matchCandidates[0]; // Detect conflicts const conflicts = matchCandidates.filter(t => t.priority.importPrecedence === winner.priority.importPrecedence &&