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 &&