From ad653d80387a95a05ff8a5d870e65a140c7811d6 Mon Sep 17 00:00:00 2001 From: Leonel Sanches da Silva <53848829+leonelsanchesdasilva@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:32:06 -0800 Subject: [PATCH 1/2] Add Adaptive Output support. --- src/dom/xml-functions.ts | 102 ++++++++++- src/xslt/xslt-options.ts | 2 +- src/xslt/xslt.ts | 13 +- tests/xml/xml-adaptive-output.test.tsx | 226 +++++++++++++++++++++++++ 4 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 tests/xml/xml-adaptive-output.test.tsx diff --git a/src/dom/xml-functions.ts b/src/dom/xml-functions.ts index 3e92b46..8c03246 100644 --- a/src/dom/xml-functions.ts +++ b/src/dom/xml-functions.ts @@ -230,21 +230,31 @@ function xmlTransformedTextRecursive(node: XNode, buffer: string[], options: Xml buffer.push(finalText); } } else if (nodeType === DOM_CDATA_SECTION_NODE) { - if (options.cData) { + if (options.outputMethod === 'text') { + // For text output, extract the raw content without CDATA markers + buffer.push(nodeValue); + } else if (options.cData) { buffer.push(xmlEscapeText(nodeValue)); } else { buffer.push(``); } } else if (nodeType == DOM_COMMENT_NODE) { - buffer.push(``); + if (options.outputMethod !== 'text') { + buffer.push(``); + } } else if (nodeType == DOM_ELEMENT_NODE) { - // If node didn't have a transformed name, but its children - // had transformations, children should be present at output. - // This is called here "muted logic". - if (node.nodeName !== null && node.nodeName !== undefined) { - xmlElementLogicTrivial(node, buffer, options); + if (options.outputMethod === 'text') { + // For text output, only extract text content from elements + xmlElementLogicTextOnly(node, buffer, options); } else { - xmlElementLogicMuted(node, buffer, options); + // If node didn't have a transformed name, but its children + // had transformations, children should be present at output. + // This is called here "muted logic". + if (node.nodeName !== null && node.nodeName !== undefined) { + xmlElementLogicTrivial(node, buffer, options); + } else { + xmlElementLogicMuted(node, buffer, options); + } } } else if (nodeType === DOM_DOCUMENT_NODE || nodeType === DOM_DOCUMENT_FRAGMENT_NODE) { let childNodes = node.firstChild ? [] : node.childNodes; @@ -356,6 +366,29 @@ function xmlElementLogicMuted(node: XNode, buffer: any[], options: XmlOutputOpti } } +/** + * XML element output for text mode - extracts only text content without tags. + * @param node The XML node. + * @param buffer The output buffer. + * @param options XML output options. + */ +function xmlElementLogicTextOnly(node: XNode, buffer: string[], options: XmlOutputOptions) { + let childNodes: XNode[] = []; + if (node.firstChild) { + let child = node.firstChild; + while (child) { + childNodes.push(child); + child = child.nextSibling; + } + } else { + childNodes = node.childNodes; + } + childNodes = childNodes.sort((a, b) => a.siblingPosition - b.siblingPosition); + for (let i = 0; i < childNodes.length; ++i) { + xmlTransformedTextRecursive(childNodes[i], buffer, options); + } +} + /** * Gets the full node name. * When namespace is set, the node name is `namespace:node`. @@ -570,6 +603,59 @@ function nodeToJsonObject(node: XNode): any { return null; } +/** + * Detects the most appropriate output format for a node based on its structure. + * This implements XSLT 3.1 adaptive output behavior. + * @param node The node to analyze. + * @returns The detected output method: 'text' or 'xml'. + */ +export function detectAdaptiveOutputFormat(node: XNode): 'text' | 'xml' { + if (!node) { + return 'xml'; + } + + const nodeType = node.nodeType; + + // If it's a document or fragment, check its children + if (nodeType === DOM_DOCUMENT_NODE || nodeType === DOM_DOCUMENT_FRAGMENT_NODE) { + const children = node.childNodes || []; + let elementCount = 0; + let textCount = 0; + let hasSignificantText = false; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.nodeType === DOM_ELEMENT_NODE) { + elementCount++; + } else if (child.nodeType === DOM_TEXT_NODE) { + const text = child.nodeValue ? child.nodeValue.trim() : ''; + if (text.length > 0) { + textCount++; + hasSignificantText = true; + } + } + } + + // If there's only text content and no elements, use text output + if (elementCount === 0 && hasSignificantText) { + return 'text'; + } + // Otherwise, use XML output + return 'xml'; + } + + // If it's a single text node with content, use text output + if (nodeType === DOM_TEXT_NODE || nodeType === DOM_CDATA_SECTION_NODE) { + const text = node.nodeValue ? node.nodeValue.trim() : ''; + if (text.length > 0) { + return 'text'; + } + } + + // For elements and other node types, use XML output + return 'xml'; +} + /** * Converts an XML document to a JSON string. * The root element becomes the top-level object. diff --git a/src/xslt/xslt-options.ts b/src/xslt/xslt-options.ts index 1c914a8..ad0f9d8 100644 --- a/src/xslt/xslt-options.ts +++ b/src/xslt/xslt-options.ts @@ -4,6 +4,6 @@ export type XsltOptions = { cData: boolean, escape: boolean, selfClosingTags: boolean, - outputMethod?: 'xml' | 'html' | 'text' | 'xhtml' | 'json', + outputMethod?: 'xml' | 'html' | 'text' | 'xhtml' | 'json' | 'adaptive', parameters?: XsltParameter[] } diff --git a/src/xslt/xslt.ts b/src/xslt/xslt.ts index 70a4a27..99fec7f 100644 --- a/src/xslt/xslt.ts +++ b/src/xslt/xslt.ts @@ -20,6 +20,7 @@ import { xmlGetAttribute, xmlTransformedText, xmlToJson, + detectAdaptiveOutputFormat, xmlValue, xmlValueLegacyBehavior } from '../dom'; @@ -77,7 +78,7 @@ export class Xslt { decimalFormatSettings: XsltDecimalFormatSettings; outputDocument: XDocument; - outputMethod: 'xml' | 'html' | 'text' | 'name' | 'xhtml' | 'json'; + outputMethod: 'xml' | 'html' | 'text' | 'name' | 'xhtml' | 'json' | 'adaptive'; outputOmitXmlDeclaration: string; version: string; firstTemplateRan: boolean; @@ -143,7 +144,7 @@ export class Xslt { * The exported entry point of the XSL-T processor. * @param xmlDoc The input document root, as DOM node. * @param stylesheet The stylesheet document root, as DOM node. - * @returns the processed document, as XML text in a string, or JSON string if outputMethod is 'json'. + * @returns the processed document, as XML text in a string, JSON string if outputMethod is 'json', or text if outputMethod is 'text' or 'adaptive' (with text content). */ async xsltProcess(xmlDoc: XDocument, stylesheet: XDocument) { const outputDocument = new XDocument(); @@ -163,11 +164,17 @@ export class Xslt { return xmlToJson(outputDocument); } + // Handle adaptive output format + let outputMethod = this.outputMethod; + if (this.outputMethod === 'adaptive') { + outputMethod = detectAdaptiveOutputFormat(outputDocument); + } + const transformedOutputXml: string = xmlTransformedText(outputDocument, { cData: this.options.cData, escape: this.options.escape, selfClosingTags: this.options.selfClosingTags, - outputMethod: this.outputMethod + outputMethod: outputMethod as 'xml' | 'html' | 'text' | 'xhtml' }); return transformedOutputXml; diff --git a/tests/xml/xml-adaptive-output.test.tsx b/tests/xml/xml-adaptive-output.test.tsx new file mode 100644 index 0000000..c809d19 --- /dev/null +++ b/tests/xml/xml-adaptive-output.test.tsx @@ -0,0 +1,226 @@ +import assert from 'assert'; + +import { Xslt } from '../../src/xslt'; +import { XmlParser } from '../../src/dom'; + +describe('Adaptive Output', () => { + it('should detect text output for pure text result', async () => { + const xmlString = ` + Hello World + `; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'adaptive' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + // Should output pure text without XML wrapper + assert.strictEqual(result.trim(), 'Hello World'); + }); + + it('should detect XML output for element result', async () => { + const xmlString = ` + John + Jane + `; + + const xsltString = ` + + + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'adaptive' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + // Should output as XML + assert(result.includes('')); + assert(result.includes('')); + assert(result.includes('')); + }); + + it('should detect XML output for complex nested structure', async () => { + const xmlString = ` + + Alice + 30 + + `; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'adaptive' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + // Should output as XML + assert(result.includes('')); + assert(result.includes('')); + assert(result.includes('')); + }); + + it('should detect text output for concatenated text nodes', async () => { + const xmlString = `Hello World`; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'adaptive' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + // Should output as plain text + assert.strictEqual(result.trim(), 'Hello World'); + }); + + it('should detect text output for number/boolean conversion', async () => { + const xmlString = ` + 42 + `; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'adaptive' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + // Should output as plain text + assert.strictEqual(result.trim(), '42'); + }); + + it('should detect XML output for multiple top-level elements', async () => { + const xmlString = ` + One + Two + `; + + const xsltString = ` + + + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'adaptive' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + // Should output as XML (multiple elements at top level) + assert(result.includes('')); + assert(result.includes('')); + }); + + it('should detect XML output for empty result defaulting to xml', async () => { + const xmlString = ``; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'adaptive' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + // Should output as XML + assert(result.includes(' { + const xmlString = ` + Important Text + `; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'adaptive' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + // Should output as plain text + assert.strictEqual(result.trim(), 'Important Text'); + }); + + it('should work with transformation producing XML structure', async () => { + const xmlString = ` + Alice + Bob + `; + + const xsltString = ` + + + + + + + + + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'adaptive' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + // Should output as XML + assert(result.includes('')); + assert(result.includes('')); + assert(result.includes('')); + assert(result.includes('')); + }); +}); From d3dd42f83bc151bf73a7411d9fbf07259b274896 Mon Sep 17 00:00:00 2001 From: Leonel Sanches da Silva <53848829+leonelsanchesdasilva@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:33:30 -0800 Subject: [PATCH 2/2] Adding `concat` bug to TODO.md. --- TODO.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index d9ad4c7..4dd5377 100644 --- a/TODO.md +++ b/TODO.md @@ -3,6 +3,7 @@ XSLT-processor TODO * XSLT validation, besides the version number; * XSL:number -* Implement `` with correct template precedence. +* Implement `` with correct template precedence. +* **BUG: concat() function with XPath expressions** - When using `concat()` with XPath expressions as arguments (e.g., `concat(root/first, ' ', root/second)`), the function returns malformed output like "1, first, null 1, second, null" instead of properly concatenating the string values. This appears to be an issue with how XPath NodeSetValue results are being converted to strings within the concat function. Help is much appreciated. It seems to currently work for most of our purposes, but fixes and additions are always welcome!