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');
+ });
+});
+