diff --git a/README.md b/README.md index 49956b2..c91e84d 100644 --- a/README.md +++ b/README.md @@ -89,12 +89,57 @@ const xslt = new Xslt(options); - `cData` (`boolean`, default `true`): resolves CDATA elements in the output. Content under CDATA is resolved as text. This overrides `escape` for CDATA content. - `escape` (`boolean`, default `true`): replaces symbols like `<`, `>`, `&` and `"` by the corresponding [HTML/XML entities](https://www.tutorialspoint.com/xml/xml_character_entities.htm). Can be overridden by `disable-output-escaping`, that also does the opposite, unescaping `>` and `<` by `<` and `>`, respectively. - `selfClosingTags` (`boolean`, default `true`): Self-closes tags that don't have inner elements, if `true`. For instance, `` becomes ``. -- `outputMethod` (`string`, default `xml`): Specifies the default output method. if `` is declared in your XSLT file, this will be overridden. Valid values: `xml`, `html`, `text`, `name`, `xhtml`. +- `outputMethod` (`string`, default `xml`): Specifies the default output method. if `` is declared in your XSLT file, this will be overridden. Valid values: `xml`, `html`, `text`, `name`, `xhtml`, `json`. - `parameters` (`array`, default `[]`): external parameters that you want to use. - `name`: the parameter name; - `namespaceUri` (optional): the namespace; - `value`: the value. +#### JSON Output Format + +When using `outputMethod: 'json'`, the XSLT processor will convert the resulting XML document to JSON format. This is useful for APIs and modern JavaScript applications. + +**Example:** + +```js +const xslt = new Xslt({ outputMethod: 'json' }); +const xmlParser = new XmlParser(); + +const xmlString = ` + + Alice + Bob + +`; + +const xsltString = ` + + + + + `; + +const result = await xslt.xsltProcess( + xmlParser.xmlParse(xmlString), + xmlParser.xmlParse(xsltString) +); + +// result will be a JSON string: +// {"root":{"users":{"user":["Alice","Bob"]}}} + +const parsed = JSON.parse(result); +console.log(parsed.root.users.user); // ["Alice", "Bob"] +``` + +**JSON Structure Rules:** + +- Each element becomes a property in a JSON object +- Text-only elements become string values +- Elements with multiple children of the same name become arrays +- Empty elements are omitted from the output +- Attributes are prefixed with `@` (when present in the output) +- Mixed text and element content uses the `#text` property for text nodes + ### Direct use in browsers You can simply add a tag like this: diff --git a/src/dom/xml-functions.ts b/src/dom/xml-functions.ts index ae79c30..3e92b46 100644 --- a/src/dom/xml-functions.ts +++ b/src/dom/xml-functions.ts @@ -449,3 +449,178 @@ export function xmlOwnerDocument(node: XNode): XDocument { return xmlOwnerDocument(node.ownerDocument); } + +/** + * Converts an XNode to a JSON-serializable object. + * Uses JSON.parse(JSON.stringify()) approach to filter out unwanted properties. + * @param node The node to convert. + * @returns A JSON-serializable object representation of the node. + */ +function nodeToJsonObject(node: XNode): any { + if (!node) { + return null; + } + + const nodeType = node.nodeType; + + // Handle text nodes + if (nodeType === DOM_TEXT_NODE || nodeType === DOM_CDATA_SECTION_NODE) { + const text = node.nodeValue ? node.nodeValue.trim() : ''; + return text.length > 0 ? text : null; + } + + // Handle comment nodes + if (nodeType === DOM_COMMENT_NODE) { + return null; // Skip comments in JSON output + } + + // Handle document and document fragments + if (nodeType === DOM_DOCUMENT_NODE || nodeType === DOM_DOCUMENT_FRAGMENT_NODE) { + const children = node.childNodes || []; + const childObjects = []; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const childObj = nodeToJsonObject(child); + if (childObj !== null) { + childObjects.push(childObj); + } + } + + if (childObjects.length === 0) { + return null; + } else if (childObjects.length === 1) { + return childObjects[0]; + } else { + return childObjects; + } + } + + // Handle element nodes + if (nodeType === DOM_ELEMENT_NODE) { + const obj: any = {}; + const element = node as any; + const hasAttributes = element.attributes && element.attributes.length > 0; + + // Add attributes with @ prefix + if (hasAttributes) { + for (let i = 0; i < element.attributes.length; i++) { + const attr = element.attributes[i]; + obj['@' + attr.nodeName] = attr.nodeValue; + } + } + + // Process child nodes + const children = element.childNodes || []; + let textContent = ''; + let hasElementChildren = false; + const childElements: { [key: string]: any } = {}; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const childType = child.nodeType; + + if (childType === DOM_TEXT_NODE || childType === DOM_CDATA_SECTION_NODE) { + const text = child.nodeValue ? child.nodeValue.trim() : ''; + if (text.length > 0) { + textContent += text; + } + } else if (childType === DOM_ELEMENT_NODE) { + hasElementChildren = true; + const childElement = child as any; + const childName = childElement.localName || childElement.nodeName; + const childObj = nodeToJsonObject(child); + + if (childObj !== null) { + if (childElements[childName]) { + // Multiple elements with same name - convert to array + if (!Array.isArray(childElements[childName])) { + childElements[childName] = [childElements[childName]]; + } + childElements[childName].push(childObj); + } else { + childElements[childName] = childObj; + } + } + } + } + + // Add child elements to object + Object.assign(obj, childElements); + + // Add text content if no element children and has text + if (!hasElementChildren && textContent.length > 0) { + if (!hasAttributes && Object.keys(childElements).length === 0) { + // Only text, no attributes or element children + return textContent; + } else { + // Has attributes and/or element children plus text + obj['#text'] = textContent; + } + } + + // If completely empty (no attributes, no children, no text), return null + if (Object.keys(obj).length === 0) { + return null; + } + + return obj; + } + + return null; +} + +/** + * Converts an XML document to a JSON string. + * The root element becomes the top-level object. + * Element attributes are prefixed with '@'. + * Text nodes become the '#text' property or the value itself. + * @param node The root node to convert. + * @returns A JSON string representation of the document. + */ +export function xmlToJson(node: XNode): string { + if (!node) { + return '{}'; + } + + // For document nodes, find the root element and wrap it + let rootElement: XNode = node; + if (node.nodeType === DOM_DOCUMENT_NODE || node.nodeType === DOM_DOCUMENT_FRAGMENT_NODE) { + const children = node.childNodes || []; + for (let i = 0; i < children.length; i++) { + if (children[i].nodeType === DOM_ELEMENT_NODE) { + rootElement = children[i]; + break; + } + } + } + + // Convert the root element to JSON + const element = rootElement as any; + const rootName = element.localName || element.nodeName; + const jsonObj: any = {}; + + // Build the root element object + const elementContent = nodeToJsonObject(rootElement); + + if (elementContent === null) { + // Empty root element + jsonObj[rootName] = {}; + } else if (typeof elementContent === 'object' && !Array.isArray(elementContent)) { + // Object with properties/attributes + jsonObj[rootName] = elementContent; + } else { + // Simple text content + jsonObj[rootName] = elementContent; + } + + // Use JSON.stringify to clean up the object and then JSON.parse and stringify again + // This ensures we only have plain properties without circular references + try { + const cleaned = JSON.parse(JSON.stringify(jsonObj)); + return JSON.stringify(cleaned); + } catch (error) { + // Fallback if stringification fails + return JSON.stringify(jsonObj); + } +} diff --git a/src/xslt/xslt-options.ts b/src/xslt/xslt-options.ts index 15ec562..1c914a8 100644 --- a/src/xslt/xslt-options.ts +++ b/src/xslt/xslt-options.ts @@ -4,5 +4,6 @@ export type XsltOptions = { cData: boolean, escape: boolean, selfClosingTags: boolean, + outputMethod?: 'xml' | 'html' | 'text' | 'xhtml' | 'json', parameters?: XsltParameter[] } diff --git a/src/xslt/xslt.ts b/src/xslt/xslt.ts index a0b0068..70a4a27 100644 --- a/src/xslt/xslt.ts +++ b/src/xslt/xslt.ts @@ -19,6 +19,7 @@ import { domSetAttribute, xmlGetAttribute, xmlTransformedText, + xmlToJson, xmlValue, xmlValueLegacyBehavior } from '../dom'; @@ -76,7 +77,7 @@ export class Xslt { decimalFormatSettings: XsltDecimalFormatSettings; outputDocument: XDocument; - outputMethod: 'xml' | 'html' | 'text' | 'name' | 'xhtml'; + outputMethod: 'xml' | 'html' | 'text' | 'name' | 'xhtml' | 'json'; outputOmitXmlDeclaration: string; version: string; firstTemplateRan: boolean; @@ -115,9 +116,10 @@ export class Xslt { cData: options.cData === true, escape: options.escape === true, selfClosingTags: options.selfClosingTags === true, + outputMethod: options.outputMethod, parameters: options.parameters || [] }; - this.outputMethod = 'xml'; + this.outputMethod = options.outputMethod || 'xml'; this.outputOmitXmlDeclaration = 'no'; this.stripSpacePatterns = []; this.preserveSpacePatterns = []; @@ -141,7 +143,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. + * @returns the processed document, as XML text in a string, or JSON string if outputMethod is 'json'. */ async xsltProcess(xmlDoc: XDocument, stylesheet: XDocument) { const outputDocument = new XDocument(); @@ -155,6 +157,12 @@ export class Xslt { } await this.xsltProcessContext(expressionContext, stylesheet, this.outputDocument); + + // Handle JSON output format + if (this.outputMethod === 'json') { + return xmlToJson(outputDocument); + } + const transformedOutputXml: string = xmlTransformedText(outputDocument, { cData: this.options.cData, escape: this.options.escape, diff --git a/tests/xml/xml-output-json.test.tsx b/tests/xml/xml-output-json.test.tsx new file mode 100644 index 0000000..b711f66 --- /dev/null +++ b/tests/xml/xml-output-json.test.tsx @@ -0,0 +1,169 @@ +import assert from 'assert'; + +import { Xslt } from '../../src/xslt'; +import { XmlParser } from '../../src/dom'; + +describe('JSON Output', () => { + it('should convert simple XML to JSON', async () => { + const xmlString = ` + test + `; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'json' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + const parsed = JSON.parse(result); + assert.strictEqual(parsed.root.item, 'test'); + }); + + it('should handle nested elements in JSON', async () => { + const xmlString = ` + + Alice + Bob + + `; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'json' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + const parsed = JSON.parse(result); + assert(Array.isArray(parsed.root.users.user)); + assert.strictEqual(parsed.root.users.user.length, 2); + assert.strictEqual(parsed.root.users.user[0], 'Alice'); + assert.strictEqual(parsed.root.users.user[1], 'Bob'); + }); + + it('should handle complex nested structure in JSON', async () => { + const xmlString = ` + + John + 30 +
+ Main St + NYC +
+
+
`; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'json' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + const parsed = JSON.parse(result); + assert.strictEqual(parsed.root.person.name, 'John'); + assert.strictEqual(parsed.root.person.age, '30'); + assert.strictEqual(parsed.root.person.address.street, 'Main St'); + assert.strictEqual(parsed.root.person.address.city, 'NYC'); + }); + + it('should handle empty elements in JSON', async () => { + const xmlString = ` + + value + `; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'json' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + const parsed = JSON.parse(result); + // Empty elements should not appear in JSON + assert(parsed.root.empty === undefined); + // Elements with content should be present + assert.strictEqual(parsed.root.withContent, 'value'); + }); + + it('should handle XSLT transformations with JSON output', async () => { + const xmlString = ` + Product A + Product B + `; + + const xsltString = ` + + + + + + + + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'json' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + const parsed = JSON.parse(result); + assert(Array.isArray(parsed.products.product)); + assert.strictEqual(parsed.products.product.length, 2); + assert.strictEqual(parsed.products.product[0].name, 'Product A'); + assert.strictEqual(parsed.products.product[1].name, 'Product B'); + }); + + it('should return valid JSON for empty document', async () => { + const xmlString = ``; + + const xsltString = ` + + + + + `; + + const xsltClass = new Xslt({ outputMethod: 'json' }); + const xmlParser = new XmlParser(); + const xml = xmlParser.xmlParse(xmlString); + const xslt = xmlParser.xmlParse(xsltString); + const result = await xsltClass.xsltProcess(xml, xslt); + + // Should be valid JSON + const parsed = JSON.parse(result); + assert(typeof parsed === 'object'); + }); +}); +