xml-twig
Version:
Node module for processing huge XML documents in tree mode
1,304 lines (1,164 loc) • 44.7 kB
JavaScript
/**
* @version: 1.7.13
* @author: Wernfried Domscheit
* @copyright: Copyright (c) 2025 Wernfried Domscheit. All rights reserved.
* @website: https://www.npmjs.com/package/xml-twig
*/
const SAX = 'sax';
const EXPAT = ['expat', 'node-expat'];
/**
* @external XMLWriter
* @see {@link https://www.npmjs.com/package/xml-writer|xml-writer}
*/
/**
* @external sax
* @see {@link https://www.npmjs.com/package/sax|sax}
*/
/**
* @external node-expat
* @see {@link https://www.npmjs.com/package/node-expat|node-expat}
*/
/**
* @external libxmljs
* Though module looks promising, it is not implemented, because it does not support Streams.
* According to {@link https://github.com/libxmljs/libxmljs/issues/390|Stream Support} it was requested in 2016, i.e. 8 years ago.
* Apart from that, documentation is very sparse.
* @see {@link https://www.npmjs.com/package/libxmljs|libxmljs}
*/
/*
* Other parsers I had a look at:
* {@link https://www.npmjs.com/package/sax-wasm|sax-wasm}: not a 'stream.Writable'
* {@link https://www.npmjs.com/package/@rubensworks/saxes|saxes}: not a 'stream.Writable'
* {@link https://www.npmjs.com/package/node-xml-stream|node-xml-stream}: Lacks comment and processinginstruction and maybe self closing tags
* {@link https://www.npmjs.com/package/node-xml-stream-parser|node-xml-stream-parser}: Lacks comment and processinginstruction
* {@link https://www.npmjs.com/package/saxes-stream|saxes-stream}: not a 'stream.Writable'
* {@link https://www.npmjs.com/package/xml-streamer|xml-streamer}: based on 'node-expat', does not add any benefit
*/
class RootHandler { }
class AnyHandler { }
/**
* @constant {RootHandler} Root
* @type {RootHandler}
*/
const Root = new RootHandler();
/**
* @constant {AnyHandler} Any
* @type {AnyHandler}
*/
const Any = new AnyHandler();
/**
* Optional settings for the Twig parser
* @typedef ParserOptions
* @property {'sax' | 'expat'} method - The underlying parser. Either `'sax'`, `'expat'`.
* @property {boolean} xmlns - If `true`, then namespaces are accessible by `namespace` property.
* @property {boolean} trim - If `true`, then turn any whitespace into a single space. Text and comments are trimmed.
* @property {boolean} resumeAfterError - If `true` then parser continues reading after an error. Otherwise it throws exception.
* @property {boolean} partial - If `true` then unhandled elements are purged.
* @property {string} [file - Optional. The name of file to be parsed. Just used for information and logging purpose.
* @example { method: 'expat', xmlns: true }
* @default { method: 'sax', xmlns: false, trim: true, resumeAfterError: false, partial: false }
*/
/**
* Reference to handler functions for Twig objects.<br>
* Element can be specified as string, Regular Expression, custom function, `Twig.Root` or `Twig.Any`<br>
* You can specify a `function` or a `event` name
* @typedef TwigHandler
* @property {HandlerCondition} tag - Element specification
* @property {HandlerFunction} function - Definition of handler function, either anonymous or explicit function
* @property {string} event - Type of the event to be emitted
*/
/**
* Condition to specify when handler shall be called<br>
* - If `string` then the element name must be equal to the string
* - If `string[]` then the element name must be included in string array
* - If `RegExp` then the element name must match the Regular Expression
* - If [HandlerConditionFilter](#HandlerConditionFilter) then function must return `true`
* - Use `Twig.Root` to call the handler on root element, i.e. when the end of document is reached
* - Use `Twig.Any` to call the handler on every element
* @typedef {string|string[]|RegExp|HandlerConditionFilter|Root|Any} HandlerCondition
*/
/**
* Handler function for Twig objects, i.e. the way you like to use the XML element.
* @typedef {function} HandlerFunction
* @param {Twig} elt - The current Twig element on which the function was called.
*/
/**
* Custom filter function to specify when handler shall be called
* @typedef {function} HandlerConditionFilter
* @param {string} name - Name of the element
* @returns {boolean} If the function returns `true`, then it is included in the filter
*/
/**
* Optional condition to get elements<br>
* - If `undefined`, then all elements are returned.<br>
* - If `string` then the element name must be equal to the string
* - If `RegExp` then the element name must match the Regular Expression
* - If [ElementConditionFilter](#ElementConditionFilter) then function must return `true`
* - Use [Twig](#Twig) object to find a specific element
* @typedef {string|RegExp|ElementConditionFilter|Twig|undefined} ElementCondition
*/
/**
* Custom filter function to select desired elements
* @typedef {function} ElementConditionFilter
* @param {string} name - Name of the element
* @param {Twig} elt - The Twig object
* @returns {boolean} If the function returns `true`, then it is included in the filter
*/
/**
* @typedef Parser
* @property {number} currentLine - The currently processed line in the XML-File.
* @property {number} currentColumn - The currently processed column in the XML-File.
* @property {string} file - The name of file to be parsed. Just used for information and logging purpose.
* @property {object} twig - Object with XML tree and current XML element
* @property {string} method - The underlying parser. Either `'sax'`, `'expat'`.
* @returns {external:sax|external:node-expat} The parser Object
*/
/**
* Create a new Twig parser
* @param {TwigHandler|TwigHandler[]} handler - Object or array of element specification and function to handle elements
* @param {ParserOptions} options - Object of optional options
* @throws {UnsupportedParser} - For an unsupported parser. Currently `expat` and `sax` (default) are supported.
* @returns {Parser} The parser Object
*/
function createParser(handler, options = {}) {
options = Object.assign({ method: SAX, xmlns: false, trim: true, resumeAfterError: false, partial: false }, options);
let parser;
let namespaces = {};
const handlerCheck = Array.isArray(handler) ? handler : [handler];
if (handlerCheck.find(x => x.tag === undefined) != null || handlerCheck.find(x => x.tag.length == 0) != null)
throw new ReferenceError(`'handler.tag' is not defined`);
if (options.partial && handlerCheck.find(x => x.tag instanceof AnyHandler) != null)
console.warn(`Using option '{ partial: true }' and handler '{ tag: Any, function: ${any.function.toString()} }' does not make much sense`);
// `parser.on("...", err => {...}` does not work, because I need access to 'this'
if (options.method === SAX) {
// Set options to have the same behavior as in expat
parser = require("sax").createStream(true, { strictEntities: true, position: true, xmlns: options.xmlns, trim: options.trim });
Object.defineProperty(parser, 'currentLine', {
enumerable: true,
get() { return parser._parser.line + 1; }
});
Object.defineProperty(parser, 'currentColumn', {
enumerable: true,
get() { return parser._parser.column + 1; }
});
parser.on("closetag", onClose.bind(null, handler, parser, options));
parser.on("opentagstart", onStart.bind(null, parser, {
handler: Array.isArray(handler) ? handler : [handler],
options: options,
namespaces: namespaces
}));
parser.on("processinginstruction", function (pi) {
if (pi.name === 'xml') {
// SAX parser handles XML declaration as Processing Instruction
let declaration = {};
for (let item of pi.body.split(' ')) {
let [k, v] = item.split('=');
declaration[k] = v.replaceAll('"', '').replaceAll("'", '');
}
parser.twig.tree = new Twig(parser, null);
Object.defineProperty(parser.twig.tree, 'declaration', {
value: declaration,
writable: false,
enumerable: true
});
} else if (parser.twig.tree.PI === undefined) {
Object.defineProperty(parser.twig.tree, 'PI', {
value: { target: pi.name, data: pi.body },
writable: false,
enumerable: true
});
}
});
parser.on("attribute", function (attr) {
if (options.xmlns && (attr.uri ?? '') !== '' && attr.local !== undefined) {
namespaces[attr.local] = attr.uri;
if (parser.twig.current.name.includes(':')) {
Object.defineProperty(parser.twig.current, 'namespace', {
value: { local: attr.local, uri: attr.uri },
writable: false,
enumerable: true
});
} else {
parser.twig.current.attribute(attr.name, attr.value);
}
} else {
parser.twig.current.attribute(attr.name, attr.value);
}
});
parser.on("cdata", function (str) {
parser.twig.current.text = options.trim ? str.trim() : str;
});
parser.on('end', function () {
parser.twig = { current: null, tree: null };
parser.emit("finish");
parser.emit("close");
});
} else if (EXPAT.includes(options.method)) {
parser = require("node-expat").createParser();
Object.defineProperty(parser, 'currentLine', {
enumerable: true,
get() { return parser.parser.getCurrentLineNumber(); }
});
Object.defineProperty(parser, 'currentColumn', {
enumerable: true,
get() { return parser.parser.getCurrentColumnNumber(); }
});
parser.on("endElement", onClose.bind(null, handler, parser, options));
parser.on("startElement", onStart.bind(null, parser, {
handler: Array.isArray(handler) ? handler : [handler],
options: options,
namespaces: namespaces
}));
parser.on('xmlDecl', function (version, encoding, standalone) {
parser.twig.tree = new Twig(parser, null);
let dec = {};
if (version !== undefined) dec.version = version;
if (encoding !== undefined) dec.encoding = encoding;
if (standalone !== undefined) dec.standalone = standalone;
Object.defineProperty(parser.twig.tree, 'declaration', {
value: dec,
writable: false,
enumerable: true
});
});
parser.on('processingInstruction', function (target, data) {
parser.twig.tree.PI = { target: target, data: data };
});
parser.on('end', function () {
parser.twig = { current: null, tree: null };
parser.emit("finish");
});
} else {
throw new UnsupportedParser(options.method);
}
Object.defineProperty(parser, 'twig', {
enumerable: true,
value: { current: null, tree: null },
writable: true
});
Object.defineProperty(parser, 'method', {
value: options.method,
writable: false,
enumerable: true
});
if (options.file != null) {
Object.defineProperty(parser, 'file', {
value: options.file,
writable: false,
enumerable: true
});
}
// Common events
parser.on('text', function (str) {
if (parser.twig.current === null) return;
parser.twig.current.text = options.trim ? str.trim() : str;
});
parser.on("comment", function (str) {
if (parser.twig.current.hasOwnProperty('comment')) {
if (typeof parser.twig.current.comment === 'string') {
parser.twig.current.comment = [parser.twig.current.comment, str.trim()];
} else {
parser.twig.current.comment.push(str.trim());
}
} else {
Object.defineProperty(parser.twig.current, 'comment', {
value: str.trim(),
writable: true,
enumerable: true,
configurable: true
});
}
});
parser.on('error', function (err) {
console.error(`error at line [${parser.currentLine}], column [${parser.currentColumn}]`, err);
if (options.resumeAfterError) {
parser.underlyingParser.error = null;
parser.underlyingParser.resume();
}
});
return parser;
}
/**
* Common Event hanlder for starting tag
* @param {Parser} parser - The main parser object
* @param {object} binds - Additional parameter object
* @param {object|string} node - Node or Node name
* @param {object} attrs - Node Attributes
*/
function onStart(parser, binds, node, attrs) {
const name = typeof node === 'string' ? node : node.name;
const handler = binds.handler;
const options = binds.options;
let namespaces = binds.namespaces;
let attrNS = {};
if (options.xmlns && attrs !== undefined) {
for (let key of Object.keys(attrs).filter(x => !(x.startsWith('xmlns:') && name.includes(':'))))
attrNS[key] = attrs[key];
}
if (parser.twig.tree === null) {
parser.twig.tree = new Twig(parser, name, parser.twig.current, options.xmlns ? attrNS : attrs);
} else {
if (parser.twig.current.isRoot && parser.twig.current.name === undefined) {
parser.twig.current.setRoot(name);
if (attrs !== undefined) {
for (let [key, val] of Object.entries(options.xmlns ? attrNS : attrs))
parser.twig.current.attribute(key, val);
}
} else {
let elt = new Twig(parser, name, parser.twig.current, options.xmlns ? attrNS : attrs);
if (options.partial) {
for (let hndl of handler) {
if (typeof hndl.tag === 'string' && name === hndl.tag) {
elt.pin();
break;
} else if (Array.isArray(hndl.tag) && hndl.tag.includes(name)) {
elt.pin();
break;
} else if (hndl.tag instanceof RegExp && hndl.tag.test(name)) {
elt.pin();
break;
} else if (typeof hndl.tag === 'function' && hndl.tag(name, parser.twig.current ?? parser.twig.tree)) {
elt.pin();
break;
}
}
}
}
}
if (options.xmlns) {
if (EXPAT.includes(options.method)) {
for (let key of Object.keys(attrs).filter(x => x.startsWith('xmlns:')))
namespaces[key.split(':')[1]] = attrs[key];
}
if (name.includes(':')) {
let prefix = name.split(':')[0];
if (namespaces[prefix] !== undefined) {
Object.defineProperty(parser.twig.current, 'namespace', {
value: { local: prefix, uri: namespaces[prefix] },
writable: false,
enumerable: true
});
}
}
}
}
/**
* Common Event hanlder for closing tag. On closed elements it either calls the Handler function or emits the specified event.
* @param {TwigHandler|TwigHandler[]} handler - Object or array of element specification and function to handle elements
* @param {Parser} parser - The main parser object
* @param {external:sax|external:node-expat} parser - SAXStream or node-expat Stream object
* @param {ParserOptions} options - Object of optional options
* @param {string} name - Event handler parameter
*/
function onClose(handler, parser, options, name) {
parser.twig.current.close();
let purge = true;
for (let hndl of Array.isArray(handler) ? handler : [handler]) {
if (hndl.tag instanceof AnyHandler) {
if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
purge = false;
} else if (hndl.tag instanceof RootHandler && parser.twig.current.isRoot) {
if (typeof hndl.function === 'function') hndl.function(parser.twig.tree, parser);
if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.tree);
purge = false;
} else if (Array.isArray(hndl.tag) && hndl.tag.includes(name)) {
if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
purge = false;
} else if (typeof hndl.tag === 'string' && name === hndl.tag) {
if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
purge = false;
} else if (hndl.tag instanceof RegExp && hndl.tag.test(name)) {
if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
purge = false;
} else if (typeof hndl.tag === 'function' && hndl.tag(name, parser.twig.current ?? parser.twig.tree)) {
if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
purge = false;
}
}
if (options.partial && purge && !parser.twig.current.pinned && !parser.twig.current.isRoot)
parser.twig.parser.twig.current.purge();
parser.twig.current = parser.twig.current.parent();
}
/**
* Generic class modeling a XML Node
* @class Twig
*/
class Twig {
/**
* Optional condition to get attributes<br>
* - If `undefined`, then all attributes are returned.<br>
* - If `string` then the attribute name must be equal to the string
* - If `RegExp` then the attribute name must match the Regular Expression
* - If [AttributeConditionFilter](#AttributeConditionFilter) then the attribute must filter function
* @typedef {string|RegExp|AttributeConditionFilter} AttributeCondition
*/
/**
* Custom filter function to get desired attributes
* @typedef {function} AttributeConditionFilter
* @param {string} name - Name of the attribute
* @param {string|number} value - Value of the attribute
*/
/**
* XML Processing Instruction object, exist only on root
* @typedef {object} #PI
*/
/**
* XML Declaration object, exist only on root
* @typedef {object} #declaration
*/
/**
* XML namespace of element. Exist onl when parsed with `xmlns: true`
* @typedef {object} #namespace
*/
/**
* Comment or array of comments inside the XML Elements
* @typedef {string|string[]} #comment
*/
/**
* XML attribute `{ <attribute 1>: <value 1>, <attribute 2>: <value 2>, ... }`
* @type {?object}
*/
#attributes = {};
/**
* Content of XML Element
* @type {?string|number}
*/
#text = null;
/**
* The XML tag name
* @type {string}
*/
#name;
/**
* Child XML Elements
* @type {Twig[]}
*/
#children = [];
/**
* The parent object. Undefined on root element
* @type {Twig | undefined}
*/
#parent;
/**
* Determines whether twig is needed in partial load
* @type {boolean}
*/
#pinned = false;
/**
* Create a new Twig object
* @param {Parser} parser - The main parser object
* @param {?string} name - The name of the XML element
* @param {Twig} parent - The parent object
* @param {object} attributes - Attribute object
* @param {string|number} index - Position name 'first', 'last' or the position in the current `children` array.<br>Defaults to 'last'
*/
constructor(parser, name, parent, attributes, index) {
if (index === undefined)
parser.twig.current = this;
if (name === null) {
// Root element not available yet
parser.twig.tree = this;
} else {
this.#name = name;
if (attributes !== undefined)
this.#attributes = attributes;
if (parent === undefined) {
// Root element
parser.twig.tree = this;
} else {
this.#parent = parent;
if (this.#parent.#pinned)
this.#pinned = true;
if (index === 'last' || index === undefined) {
parent.#children.push(this);
} else if (index === 'first') {
parent.#children.unshift(this);
} else if (typeof index === 'number') {
parent.#children = parent.#children.slice(0, index).concat(this, parent.#children.slice(index));
} else {
parent.#children.push(this);
}
}
}
}
/**
* Purges the current, typically used after element has been processed.<br>The root object cannot be purged.
*/
purge = function () {
if (!this.isRoot)
this.#parent.#children = this.#parent.#children.filter(x => !Object.is(this, x));
};
/**
* Purges up to the elt element. This allows you to keep part of the tree in memory when you purge.<br>
* The `elt` object is not purged. If you like to purge including `elt`, use `.purgeUpTo(elt.previous())`
* @param {Twig} elt - Up to this element the tree will be purged.
* If `undefined` then the current element is purged (i.e. `purge()`)
*/
purgeUpTo = function (elt) {
if (elt === undefined) {
this.purge();
} else {
let toPurge = this;
while (toPurge !== null && !Object.is(toPurge, elt)) {
const prev = toPurge.previous();
toPurge.purge();
toPurge = prev;
}
}
};
/**
* Escapes special XML characters. According W3C specification these are only `&, <, >, ", '` - this is a XML parser, not HTML!
* @param {string} text - Input text to be escaped
*/
escapeEntity = function (text) {
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
};
/**
* Sets the name of root element. In some cases the root is created before the XML-Root element is available<br>
* Used internally!
* @param {string} name - The element name
* @private
*/
setRoot(name) {
if (this.isRoot)
this.#name = name;
}
/**
* Returns `true` if the element is empty, otherwise `false`.
* An empty element has no text nor any child elements, however empty elements can have attributes.
* @returns {boolean} true if empty element
*/
get isEmpty() {
return this.#text === null && this.#children.length == 0;
}
/**
* Returns the level of the element. Root element has 0, children have 1, grand-children 2 and so on
* @returns {number} The level of the element.
*/
get level() {
let ret = 0;
let p = this.#parent;
while (p !== undefined) {
p = p.#parent;
ret++;
}
return ret;
}
/**
* Returns `true` if element is the root object
* @returns {boolean} true if root element
*/
get isRoot() {
return this.#parent === undefined;
}
/**
* Returns `true` if element has child elements
* @returns {boolean} true if has child elements exists
*/
get hasChildren() {
return this.#children.length > 0;
}
/**
* The position in `#children` array. For root object 0
* @returns {number} Position of element in parent
*/
get index() {
return this.isRoot ? 0 : this.#parent.#children.indexOf(this);
}
/**
* The X-Path position of the element
* NOTE: Applies only to currently loaded elements.
* @returns {string} X-Path
*/
get path() {
if (this.isRoot)
return `/${this.#name}`;
let ret = [];
if (this.#parent.children(this.#name).length > 1) {
let sameChildren = this.#parent.children(this.#name);
ret.unshift(`${this.#name}[${sameChildren.indexOf(this) + 1}]`);
} else {
ret.unshift(this.#name);
}
if (!this.isRoot) {
let parent = this.#parent;
while (!parent.isRoot) {
if (parent.#parent.children(parent.#name).length > 1) {
let sameChildren = parent.#parent.children(parent.#name);
ret.unshift(`${parent.#name}[${sameChildren.indexOf(parent) + 1}]`);
} else {
ret.unshift(parent.#name);
}
parent = parent.#parent;
}
}
return '/' + ret.join('/');
}
/**
* Returns the name of the element.
* @returns {string} Element name
*/
get name() {
return this.#name;
}
/**
* Returns the name of the element. Synonym for `twig.name`
* @returns {string} Element name
*/
get tag() {
return this.#name;
}
/**
* The text of the element. No matter if given as text or CDATA entity
* @returns {string} Element text or empty string
*/
get text() {
return this.#text ?? '';
}
/**
* Update the text of the element
* @param {string|number|bigint|boolean} value - New text of the element
* @throws {UnsupportedType} - If value is not a string, boolean or numeric type
*/
set text(value) {
if (this.#text === null) this.#text = '';
if (typeof value === 'string')
this.#text += value;
else if (['number', 'bigint', 'boolean'].includes(typeof value))
this.#text += value.toString();
else
throw new UnsupportedType(value);
}
/**
* Pins the current element. Used for partial reading.
*/
pin = function () {
this.#pinned = true;
};
/**
* Checks if element is pinned
* @returns {boolean} `true` when the element is pinned
*/
get pinned() {
return this.#pinned;
}
/**
* Closes the element
*/
close = function () {
Object.seal(this);
};
/**
* XML-Twig for dummies :-)
* @returns {string} The XML-Tree which is currently available in RAM - no valid XML Structure
*/
debug = function () {
return this.root().writer(true, true).output;
};
/**
* Returns XML string of the element
* @returns {string} The XML-Element as string
*/
toString = function () {
return this.writer(true).toString();
};
/**
* Internal recursive function used by `writer()`
* @param {external:XMLWriter} xw - The writer object
* @param {Twig[]} childArray - Array of child elements
*/
#addChild = function (xw, childArray, cur, debug) {
for (let elt of childArray) {
xw.startElement(elt.name);
for (let [key, val] of Object.entries(elt.attributes))
xw.writeAttribute(key, val);
if (elt.text !== null)
xw.text(elt.text);
this.#addChild(xw, elt.children(), elt, debug);
}
if (!debug || Object.isSealed(cur)) xw.endElement();
};
/**
* Creates xml-writer from current element
* @param {?boolean|string|external:XMLWriter} par - `true` or intention character or an already created XMLWriter
* @returns {external:XMLWriter}
*/
writer = function (par, debug) {
const XMLWriter = require('xml-writer');
let xw = par instanceof XMLWriter ? par : new XMLWriter(par);
xw.startElement(this.#name);
for (let [key, val] of Object.entries(this.#attributes))
xw.writeAttribute(key, val);
if (this.#text !== null)
xw.text(this.#text);
this.#addChild(xw, this.#children, this, debug);
if (!debug || Object.isSealed(this)) xw.endElement();
return xw;
};
/**
* Returns attribute value or `null` if not found.<br>
* If more than one matches the condition, then it returns object as [attribute()](#attribute)
* @param {AttributeCondition} condition - Optional condition to select attribute
* @returns {?string|number|object} - The value of the attribute or `null` if the does not exist
*/
attr = function (condition) {
let attr = this.attribute(condition);
if (attr === null)
return null;
return Object.keys(attr).length === 1 ? attr[Object.keys(attr)[0]] : attr;
};
/**
* Returns all attributes of the element
* @returns {object} All XML Attributes
*/
get attributes() {
return this.#attributes;
}
/**
* Check if the attribute exist or not
* @param {string} name - The name of the attribute
* @returns {boolean} - Returns `true` if the attribute exists, else `false`
*/
hasAttribute = function (name) {
return this.#attributes[name] !== undefined;
};
/**
* Retrieve or update XML attribute. For update, the condition must be a string, i.e. must match to one attribute only.
* @param {AttributeCondition} condition - Optional condition to select attributes
* @param {string|number|bigint|boolean} value - New value of the attribute.<br>If `undefined` then existing attributes is returned.
* @returns {object} Attributes or `null` if no matching attribute found
* @example attribute((name, val) => { return name === 'age' && val > 50})
* attribute((name) => { return ['firstName', 'lastName'].includes(name) })
* attribute('firstName')
* attribute(/name/i)
*/
attribute = function (condition, value) {
if (value === undefined) {
let attr;
if (condition === undefined) {
attr = this.#attributes;
} else if (typeof condition === 'function') {
attr = Object.fromEntries(Object.entries(this.#attributes).filter(([key, val]) => condition(key, val)));
} else if (typeof condition === 'string') {
attr = this.attribute(key => key === condition);
} else if (condition instanceof RegExp) {
attr = this.attribute(key => condition.test(key));
} else if (condition instanceof Twig) {
throw new UnsupportedCondition(condition, ['string', 'RegEx', 'function']);
} else {
return this.attribute();
}
return attr === null || Object.keys(attr).length == 0 ? null : attr;
} else if (typeof condition === 'string') {
if (typeof value === 'string')
this.#attributes[condition] = value;
else if (['number', 'bigint', 'boolean'].includes(typeof value))
this.#attributes[condition] = value.toString();
else
throw new UnsupportedType(value);
} else {
console.warn('Condition must be a `string` if you like to update an attribute');
}
};
/**
* Delete the attribute
* @param {string} name - The attribute name
*/
deleteAttribute = function (name) {
delete this.#attributes[name];
};
/**
* Returns the root object
* @returns {Twig} The root element of XML tree
*/
root = function () {
if (this.isRoot) {
return this;
} else {
let ret = this.#parent;
while (!ret.isRoot) {
ret = ret.#parent;
}
return ret;
}
};
/**
* Returns the parent element or null if root element
* @returns {Twig} The parament element
*/
parent = function () {
return this.isRoot ? null : this.#parent;
};
/**
* @returns {Twig} - The current element
*/
self = function () {
return this;
};
/**
* Common function to filter Twig elements from array
* @param {Twig|Twig[]} elements - Array of elements you like to filter or a single element
* @param {ElementCondition} condition - The filter condition
* @returns {Twig[]} List of matching elements or empty array
*/
filterElements(elements, condition) {
if (!Array.isArray(elements))
return this.filterElements([elements], condition);
if (condition !== undefined) {
if (typeof condition === 'string') {
return elements.filter(x => x.name === condition);
} else if (condition instanceof RegExp) {
return elements.filter(x => condition.test(x.name));
} else if (condition instanceof Twig) {
return elements.filter(x => Object.is(x, condition));
} else if (typeof condition === 'function') {
return elements.filter(x => condition(x.name, x));
}
}
return elements;
}
/**
* Common function to filter Twig element
* @param {Twig} element - Element you like to filter
* @param {ElementCondition} condition - The filter condition
* @returns {boolean} `true` if the condition matches
*/
testElement(element, condition) {
if (condition === undefined) {
return true;
} else if (typeof condition === 'string') {
return element.name === condition;
} else if (condition instanceof RegExp) {
return condition.test(element.name);
} else if (condition instanceof Twig) {
return Object.is(element, condition);
} else if (typeof condition === 'function') {
return condition(element.name, element);
}
return false;
}
/**
* All children, optionally matching `condition` of the current element or empty array
* @param {ElementCondition} condition - Optional condition
* @returns {Twig[]}
*/
children = function (condition) {
return this.filterElements(this.#children, condition);
};
/**
* Returns the next matching element.
* @param {ElementCondition} condition - Optional condition
* @returns {?Twig} - The next element
* @see https://www.w3.org/TR/xpath-datamodel-31/#document-order
*/
next = function (condition) {
if (this === null)
return null;
let elt;
if (this.hasChildren) {
elt = this.#children[0];
} else {
elt = this.nextSibling();
if (elt === null) {
elt = this.#parent;
elt = elt.nextSibling();
}
}
if (elt === null)
return null;
return this.testElement(elt, condition) ? elt : elt.next(condition);
};
/**
* Returns the previous matching element.
* @param {ElementCondition} condition - Optional condition
* @returns {?Twig} - The previous element
* @see https://www.w3.org/TR/xpath-datamodel-31/#document-order
*/
previous = function (condition) {
if (this === null || this.isRoot)
return null;
let elt = this.prevSibling();
if (elt === null) {
elt = this.parent();
} else {
elt = elt.descendantOrSelf();
elt = elt[elt.length - 1];
}
return this.testElement(elt, condition) ? elt : elt.previous(condition);
};
/**
* Returns the first matching element. This is usually the root element
* @param {ElementCondition} condition - Optional condition
* @returns {?Twig} - The first element
*/
first = function (condition) {
if (this === null)
return null;
return this.testElement(this.root(), condition) ? this.root() : this.root().next(condition);
};
/**
* Returns the last matching element.
* @param {ElementCondition} condition - Optional condition
* @returns {?Twig} - The last element
*/
last = function (condition) {
if (this === null)
return null;
let elt = this.root();
if (this.root().hasChildren) {
elt = this.root().#children[this.root().#children.length - 1];
while (elt.hasChildren)
elt = elt.children()[elt.children().length - 1];
}
return this.testElement(elt, condition) ? elt : elt.previous(condition);
};
/**
* Check if the element is the first child of the parent
* @returns {boolean} `true` if the first child else `false`
*/
get isFirstChild() {
if (this.isRoot) {
return false;
} else {
return this.index === 0;
}
}
/**
* Check if the element is the last child of the parent
* @returns {boolean} `true` if the last child else `false`
*/
get isLastChild() {
if (this.isRoot) {
return false;
} else {
return this.index === this.#parent.#children.length - 1;
}
}
/**
* Returns descendants (children, grandchildren, etc.) of the current element
* @param {ElementCondition} condition - Optional condition
* @returns {Twig[]} - Array of descendants or empty array
*/
descendant = function (condition) {
let elts = [];
for (let c of this.#children) {
elts.push(c);
elts = elts.concat(c.descendant());
}
return this.filterElements(elts, condition);
};
/**
* Returns descendants (children, grandchildren, etc.) of the current element and the current element itself
* @param {ElementCondition} condition - Optional condition
* @returns {Twig[]} - Array of descendants or empty array
*/
descendantOrSelf = function (condition) {
let elts = [this];
for (let c of this.#children) {
elts.push(c);
elts = elts.concat(c.descendant());
}
return this.filterElements(elts, condition);
};
/**
* Returns ancestors (parent, grandparent, etc.) of the current element
* @param {ElementCondition} condition - Optional condition
* @returns {Twig[]} - Array of ancestors or empty array
*/
ancestor = function (condition) {
let elts = [];
if (!this.isRoot) {
let parent = this.#parent;
elts.push(parent);
while (!parent.isRoot) {
parent = parent.#parent;
elts.push(parent);
}
}
return this.filterElements(elts, condition);
};
/**
* Returns ancestors (parent, grandparent, etc.) of the current element and the current element itself
* @param {ElementCondition} condition - Optional condition
* @returns {Twig[]} - Array of ancestors or empty array
*/
ancestorOrSelf = function (condition) {
let elts = [this];
if (!this.isRoot) {
let parent = this.#parent;
elts.push(parent);
while (!parent.isRoot) {
parent = parent.#parent;
elts.push(parent);
}
}
return this.filterElements(elts, condition);
};
/**
* Returns all sibling element of the current element
* @param {ElementCondition} condition - Optional condition
* @returns {Twig[]} - Array of sibling or empty array
*/
sibling = function (condition) {
let elts = [];
if (!this.isRoot) {
elts = this.#parent.#children.filter(x => !Object.is(x, this));
}
return this.filterElements(elts, condition);
};
/**
* Returns all sibling element of the current element and the current element itself
* @param {ElementCondition} condition - Optional condition
* @returns {Twig[]} - Array of sibling or empty array
*/
siblingOrSelf = function (condition) {
let elts = [this];
if (!this.isRoot) {
elts = this.#parent.#children;
}
return this.filterElements(elts, condition);
};
/**
* Returns all following sibling element of the current element
* @param {ElementCondition} condition - Optional condition
* @returns {Twig[]} - Array of sibling or empty array
*/
followingSibling = function (condition) {
let elts = [];
if (!this.isRoot) {
elts = this.#parent.#children.slice(this.index + 1);
}
return this.filterElements(elts, condition);
};
/**
* Returns all preceding sibling element of the current element
* @param {ElementCondition} condition - Optional condition
* @returns {Twig[]} - Array of sibling or empty array
*/
precedingSibling = function (condition) {
let elts = [];
if (!this.isRoot) {
elts = this.#parent.#children.slice(0, this.index);
}
return this.filterElements(elts, condition);
};
/**
* Returns next sibling element of the current element
* @param {ElementCondition} condition - Optional condition
* @returns {?Twig} - The next sibling or `null`
*/
nextSibling = function (condition) {
let elt;
if (!this.isRoot)
elt = this.#parent.#children[this.index + 1];
if (elt === undefined)
return null;
return this.testElement(elt, condition) ? elt : elt.nextSibling(condition);
};
/**
* Returns previous sibling element of the current element
* @param {ElementCondition} condition - Optional condition
* @returns {?Twig} - The previous sibling or `null`
*/
prevSibling = function (condition) {
if (!this.isRoot && this.index > 0) {
let elt = this.#parent.#children[this.index - 1];
return this.testElement(elt, condition) ? elt : elt.prevSibling(condition);
} else {
return null;
}
};
/**
* Find a specific element within current element. Same as `.descendant(condition)[0]`
* @param {ElementCondition} condition - Find condition
* @returns {?Twig} - First matching element or `null`
*/
find = function (condition) {
let children = this.filterElements(this.#children, condition);
if (children.length > 0)
return children[0];
for (let child of this.#children) {
let ret = child.find(condition);
if (ret !== null)
return ret;
}
return null;
};
/**
* Add a new element in the current element
* @param {string} name - The tag name
* @param {?string} text - Text of the element
* @param {?object} attributes - Element attributes
* @param {name|number} position - Position name 'first', 'last' or the position in the `children`
* @returns {Twig} - The appended element
*/
addElement = function (parser, name, text, attributes, position) {
let twig = new Twig(parser, name, this, attributes ?? {}, position ?? 'last');
twig.#text = text ?? null;
twig.close();
return twig;
};
/**
* Deletes the current element from tree, same as `purge()`. The root object cannot be deleted.
*/
delete = function () {
this.purge();
};
}
/**
* Generic error for non implemented feature
* @exception NotImplementedYet
*/
class NotImplementedYet extends TypeError {
constructor() {
super(`Net yet implemented`);
}
}
/**
* Error for unsupported parser
* @exception UnsupportedParser
*/
class UnsupportedParser extends TypeError {
/**
* @param {string} t Parser type
*/
constructor(t) {
super(`Parser '${t}' is not supported. Use 'expat', 'sax' (default)`);
}
}
/**
* Generic error for unsupported data type
* @exception UnsupportedType
*/
class UnsupportedType extends TypeError {
/**
* @param {*} t Parameter which was used
*/
constructor(t) {
super(`Type ${typeof t} is not supported in XML`);
}
}
/**
* Generic error for unsupported condition
* @exception UnsupportedCondition
*/
class UnsupportedCondition extends TypeError {
/**
* @param {*} condition The condition value
* @param {string[]} t List of supported data types
*/
constructor(condition, t) {
super(`Condition '${JSON.stringify(condition)}' must be a ${t.map(x => `'${x}'`).join(' or ')}`);
}
}
module.exports = { createParser, Twig, Any, Root };