xml-twig 1.7.1 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/twig.js CHANGED
@@ -1,1296 +1,1303 @@
1
- const SAX = 'sax';
2
- const EXPAT = ['expat', 'node-expat'];
3
-
4
- /**
5
- * @external XMLWriter
6
- * @see {@link https://www.npmjs.com/package/xml-writer|xml-writer}
7
- */
8
-
9
- /**
10
- * @external sax
11
- * @see {@link https://www.npmjs.com/package/sax|sax}
12
- */
13
-
14
- /**
15
- * @external node-expat
16
- * @see {@link https://www.npmjs.com/package/node-expat|node-expat}
17
- */
18
-
19
- /**
20
- * @external libxmljs
21
- * Though module looks promising, it is not implemented, because it does not support Streams.
22
- * According to {@link https://github.com/libxmljs/libxmljs/issues/390|Stream Support} it was requested in 2016, i.e. 8 years ago.
23
- * Apart from that, documentation is very sparse.
24
- * @see {@link https://www.npmjs.com/package/libxmljs|libxmljs}
25
- */
26
-
27
- /*
28
- * Other parsers I had a look at:
29
- * {@link https://www.npmjs.com/package/sax-wasm|sax-wasm}: not a 'stream.Writable'
30
- * {@link https://www.npmjs.com/package/@rubensworks/saxes|saxes}: not a 'stream.Writable'
31
- * {@link https://www.npmjs.com/package/node-xml-stream|node-xml-stream}: Lacks comment and processinginstruction and maybe self closing tags
32
- * {@link https://www.npmjs.com/package/node-xml-stream-parser|node-xml-stream-parser}: Lacks comment and processinginstruction
33
- * {@link https://www.npmjs.com/package/saxes-stream|saxes-stream}: not a 'stream.Writable'
34
- * {@link https://www.npmjs.com/package/xml-streamer|xml-streamer}: based on 'node-expat', does not add any benefit
35
- */
36
-
37
-
38
- class RootHandler { }
39
- class AnyHandler { }
40
-
41
-
42
- /**
43
- * @constant {RootHandler} Root
44
- * @type {RootHandler}
45
- */
46
- const Root = new RootHandler();
47
-
48
- /**
49
- * @constant {AnyHandler} Any
50
- * @type {AnyHandler}
51
- */
52
- const Any = new AnyHandler();
53
-
54
-
55
- /**
56
- * Optional settings for the Twig parser
57
- * @typedef ParserOptions
58
- * @property {'sax' | 'expat'} [method] - The underlying parser. Either `'sax'`, `'expat'`.
59
- * @property {boolean} [xmlns] - If `true`, then namespaces are accessible by `namespace` property.
60
- * @property {boolean} [trim] - If `true`, then turn any whitespace into a single space. Text and comments are trimmed.
61
- * @property {boolean} [resumeAfterError] - If `true` then parser continues reading after an error. Otherwise it throws exception.
62
- * @property {boolean} [partial] - If `true` then unhandled elements are purged.
63
- * @property {string} [file] - Optional. The name of file to be parsed. Just used for information and logging purpose.
64
- * @example { method: 'expat', xmlns: true }
65
- * @default { method: 'sax', xmlns: false, trim: true, resumeAfterError: false, partial: false }
66
- */
67
-
68
- /**
69
- * Reference to handler functions for Twig objects.<br>
70
- * Element can be specified as string, Regular Expression, custom function, `Twig.Root` or `Twig.Any`<br>
71
- * You can specify a `function` or a `event` name
72
- * @typedef TwigHandler
73
- * @property {HandlerCondition} tag - Element specification
74
- * @property {HandlerFunction} [function] - Definition of handler function, either anonymous or explicit function
75
- * @property {string} [event] - Type of the event to be emitted
76
- */
77
-
78
- /**
79
- * Condition to specify when handler shall be called<br>
80
- * - If `string` then the element name must be equal to the string
81
- * - If `string[]` then the element name must be included in string array
82
- * - If `RegExp` then the element name must match the Regular Expression
83
- * - If [HandlerConditionFilter](#HandlerConditionFilter) then function must return `true`
84
- * - Use `Twig.Root` to call the handler on root element, i.e. when the end of document is reached
85
- * - Use `Twig.Any` to call the handler on every element
86
- * @typedef {string|string[]|RegExp|HandlerConditionFilter|Root|Any} HandlerCondition
87
- */
88
-
89
- /**
90
- * Handler function for Twig objects, i.e. the way you like to use the XML element.
91
- * @typedef {function} HandlerFunction
92
- * @param {Twig} elt - The current Twig element on which the function was called.
93
- */
94
-
95
- /**
96
- * Custom filter function to specify when handler shall be called
97
- * @typedef {function} HandlerConditionFilter
98
- * @param {string} name - Name of the element
99
- * @returns {boolean} If the function returns `true`, then it is included in the filter
100
- */
101
-
102
- /**
103
- * Optional condition to get elements<br>
104
- * - If `undefined`, then all elements are returned.<br>
105
- * - If `string` then the element name must be equal to the string
106
- * - If `RegExp` then the element name must match the Regular Expression
107
- * - If [ElementConditionFilter](#ElementConditionFilter) then function must return `true`
108
- * - Use [Twig](#Twig) object to find a specific element
109
- * @typedef {string|RegExp|ElementConditionFilter|Twig|undefined} ElementCondition
110
- */
111
-
112
- /**
113
- * Custom filter function to select desired elements
114
- * @typedef {function} ElementConditionFilter
115
- * @param {string} name - Name of the element
116
- * @param {Twig} elt - The Twig object
117
- * @returns {boolean} If the function returns `true`, then it is included in the filter
118
- */
119
-
120
- /**
121
- * @typedef Parser
122
- * @property {number} [currentLine] - The currently processed line in the XML-File.
123
- * @property {number} [currentColumn] - The currently processed column in the XML-File.
124
- * @property {string} [file] - The name of file to be parsed. Just used for information and logging purpose.
125
- * @property {object} [twig] - Object with XML tree and current XML element
126
- * @property {string} [method] - The underlying parser. Either `'sax'`, `'expat'`.
127
- * @returns {external:sax|external:node-expat} The parser Object
128
- */
129
-
130
- /**
131
- * Create a new Twig parser
132
- * @param {TwigHandler|TwigHandler[]} handler - Object or array of element specification and function to handle elements
133
- * @param {ParserOptions} [options] - Object of optional options
134
- * @throws {UnsupportedParser} - For an unsupported parser. Currently `expat` and `sax` (default) are supported.
135
- * @returns {Parser} The parser Object
136
- */
137
- function createParser(handler, options = {}) {
138
- options = Object.assign({ method: SAX, xmlns: false, trim: true, resumeAfterError: false, partial: false }, options);
139
- let parser;
140
- let namespaces = {};
141
-
142
- const handlerCheck = Array.isArray(handler) ? handler : [handler];
143
- if (handlerCheck.find(x => x.tag === undefined) != null || handlerCheck.find(x => x.tag.length == 0) != null)
144
- throw new ReferenceError(`'handler.tag' is not defined`);
145
- if (options.partial && handlerCheck.find(x => x.tag instanceof AnyHandler) != null)
146
- console.warn(`Using option '{ partial: true }' and handler '{ tag: Any, function: ${any.function.toString()} }' does not make much sense`);
147
-
148
- // `parser.on("...", err => {...}` does not work, because I need access to 'this'
149
- if (options.method === SAX) {
150
- // Set options to have the same behavior as in expat
151
- parser = require("sax").createStream(true, { strictEntities: true, position: true, xmlns: options.xmlns, trim: options.trim });
152
-
153
- Object.defineProperty(parser, 'currentLine', {
154
- enumerable: true,
155
- get() { return parser._parser.line + 1; }
156
- });
157
- Object.defineProperty(parser, 'currentColumn', {
158
- enumerable: true,
159
- get() { return parser._parser.column + 1; }
160
- });
161
-
162
- parser.on("closetag", onClose.bind(null, handler, parser, options));
163
- parser.on("opentagstart", onStart.bind(null, parser, {
164
- handler: Array.isArray(handler) ? handler : [handler],
165
- options: options,
166
- namespaces: namespaces
167
- }));
168
-
169
- parser.on("processinginstruction", function (pi) {
170
- if (pi.name === 'xml') {
171
- // SAX parser handles XML declaration as Processing Instruction
172
- let declaration = {};
173
- for (let item of pi.body.split(' ')) {
174
- let [k, v] = item.split('=');
175
- declaration[k] = v.replaceAll('"', '').replaceAll("'", '');
176
- }
177
- parser.twig.tree = new Twig(parser, null);
178
- Object.defineProperty(parser.twig.tree, 'declaration', {
179
- value: declaration,
180
- writable: false,
181
- enumerable: true
182
- });
183
- } else if (parser.twig.tree.PI === undefined) {
184
- Object.defineProperty(parser.twig.tree, 'PI', {
185
- value: { target: pi.name, data: pi.body },
186
- writable: false,
187
- enumerable: true
188
- });
189
- }
190
- });
191
-
192
- parser.on("attribute", function (attr) {
193
- if (options.xmlns && (attr.uri ?? '') !== '' && attr.local !== undefined) {
194
- namespaces[attr.local] = attr.uri;
195
- if (parser.twig.current.name.includes(':')) {
196
- Object.defineProperty(parser.twig.current, 'namespace', {
197
- value: { local: attr.local, uri: attr.uri },
198
- writable: false,
199
- enumerable: true
200
- });
201
- } else {
202
- parser.twig.current.attribute(attr.name, attr.value);
203
- }
204
- } else {
205
- parser.twig.current.attribute(attr.name, attr.value);
206
- }
207
- });
208
- parser.on("cdata", function (str) {
209
- parser.twig.current.text = options.trim ? str.trim() : str;
210
- });
211
-
212
- parser.on('end', function () {
213
- parser.twig = { current: null, tree: null };
214
- parser.emit("finish");
215
- parser.emit("close");
216
- });
217
-
218
- } else if (EXPAT.includes(options.method)) {
219
- parser = require("node-expat").createParser();
220
- Object.defineProperty(parser, 'currentLine', {
221
- enumerable: true,
222
- get() { return parser.parser.getCurrentLineNumber(); }
223
- });
224
- Object.defineProperty(parser, 'currentColumn', {
225
- enumerable: true,
226
- get() { return parser.parser.getCurrentColumnNumber(); }
227
- });
228
-
229
- parser.on("endElement", onClose.bind(null, handler, parser, options));
230
- parser.on("startElement", onStart.bind(null, parser, {
231
- handler: Array.isArray(handler) ? handler : [handler],
232
- options: options,
233
- namespaces: namespaces
234
- }));
235
-
236
- parser.on('xmlDecl', function (version, encoding, standalone) {
237
- parser.twig.tree = new Twig(parser, null);
238
- let dec = {};
239
- if (version !== undefined) dec.version = version;
240
- if (encoding !== undefined) dec.encoding = encoding;
241
- if (standalone !== undefined) dec.standalone = standalone;
242
- Object.defineProperty(parser.twig.tree, 'declaration', {
243
- value: dec,
244
- writable: false,
245
- enumerable: true
246
- });
247
- });
248
-
249
- parser.on('processingInstruction', function (target, data) {
250
- parser.twig.tree.PI = { target: target, data: data };
251
- });
252
-
253
- parser.on('end', function () {
254
- parser.twig = { current: null, tree: null };
255
- parser.emit("finish");
256
- });
257
-
258
- } else {
259
- throw new UnsupportedParser(options.method);
260
- }
261
-
262
- Object.defineProperty(parser, 'twig', {
263
- enumerable: true,
264
- value: { current: null, tree: null },
265
- writable: true
266
- });
267
-
268
- Object.defineProperty(parser, 'method', {
269
- value: options.method,
270
- writable: false,
271
- enumerable: true
272
- });
273
-
274
- if (options.file != null) {
275
- Object.defineProperty(parser, 'file', {
276
- value: options.file,
277
- writable: false,
278
- enumerable: true
279
- });
280
- }
281
-
282
- // Common events
283
- parser.on('text', function (str) {
284
- if (parser.twig.current === null) return;
285
- parser.twig.current.text = options.trim ? str.trim() : str;
286
- });
287
-
288
- parser.on("comment", function (str) {
289
- if (parser.twig.current.hasOwnProperty('comment')) {
290
- if (typeof parser.twig.current.comment === 'string') {
291
- parser.twig.current.comment = [parser.twig.current.comment, str.trim()];
292
- } else {
293
- parser.twig.current.comment.push(str.trim());
294
- }
295
- } else {
296
- Object.defineProperty(parser.twig.current, 'comment', {
297
- value: str.trim(),
298
- writable: true,
299
- enumerable: true,
300
- configurable: true
301
- });
302
- }
303
- });
304
-
305
- parser.on('error', function (err) {
306
- console.error(`error at line [${parser.currentLine}], column [${parser.currentColumn}]`, err);
307
- if (options.resumeAfterError) {
308
- parser.underlyingParser.error = null;
309
- parser.underlyingParser.resume();
310
- }
311
- });
312
-
313
- return parser;
314
- }
315
-
316
-
317
- /**
318
- * Common Event hanlder for starting tag
319
- * @param {Parser} parser - The main parser object
320
- * @param {object} binds - Additional parameter object
321
- * @param {object|string} node - Node or Node name
322
- * @param {object} attrs - Node Attributes
323
- */
324
- function onStart(parser, binds, node, attrs) {
325
-
326
- const name = typeof node === 'string' ? node : node.name;
327
- const handler = binds.handler;
328
- const options = binds.options;
329
- let namespaces = binds.namespaces;
330
-
331
- let attrNS = {};
332
- if (options.xmlns && attrs !== undefined) {
333
- for (let key of Object.keys(attrs).filter(x => !(x.startsWith('xmlns:') && name.includes(':'))))
334
- attrNS[key] = attrs[key];
335
- }
336
-
337
- if (parser.twig.tree === null) {
338
- parser.twig.tree = new Twig(parser, name, parser.twig.current, options.xmlns ? attrNS : attrs);
339
- } else {
340
- if (parser.twig.current.isRoot && parser.twig.current.name === undefined) {
341
- parser.twig.current.setRoot(name);
342
- if (attrs !== undefined) {
343
- for (let [key, val] of Object.entries(options.xmlns ? attrNS : attrs))
344
- parser.twig.current.attribute(key, val);
345
- }
346
- } else {
347
- let elt = new Twig(parser, name, parser.twig.current, options.xmlns ? attrNS : attrs);
348
- if (options.partial) {
349
- for (let hndl of handler) {
350
- if (typeof hndl.tag === 'string' && name === hndl.tag) {
351
- elt.pin();
352
- break;
353
- } else if (Array.isArray(hndl.tag) && hndl.tag.includes(name)) {
354
- elt.pin();
355
- break;
356
- } else if (hndl.tag instanceof RegExp && hndl.tag.test(name)) {
357
- elt.pin();
358
- break;
359
- } else if (typeof hndl.tag === 'function' && hndl.tag(name, parser.twig.current ?? parser.twig.tree)) {
360
- elt.pin();
361
- break;
362
- }
363
- }
364
- }
365
- }
366
- }
367
-
368
- if (options.xmlns) {
369
- if (EXPAT.includes(options.method)) {
370
- for (let key of Object.keys(attrs).filter(x => x.startsWith('xmlns:')))
371
- namespaces[key.split(':')[1]] = attrs[key];
372
- }
373
- if (name.includes(':')) {
374
- let prefix = name.split(':')[0];
375
- if (namespaces[prefix] !== undefined) {
376
- Object.defineProperty(parser.twig.current, 'namespace', {
377
- value: { local: prefix, uri: namespaces[prefix] },
378
- writable: false,
379
- enumerable: true
380
- });
381
- }
382
- }
383
- }
384
- }
385
-
386
- /**
387
- * Common Event hanlder for closing tag. On closed elements it either calls the Handler function or emits the specified event.
388
- * @param {TwigHandler|TwigHandler[]} handler - Object or array of element specification and function to handle elements
389
- * @param {Parser} parser - The main parser object
390
- * @param {external:sax|external:node-expat} parser - SAXStream or node-expat Stream object
391
- * @param {ParserOptions} options - Object of optional options
392
- * @param {string} name - Event handler parameter
393
- */
394
- function onClose(handler, parser, options, name) {
395
- parser.twig.current.close();
396
- let purge = true;
397
-
398
- for (let hndl of Array.isArray(handler) ? handler : [handler]) {
399
- if (hndl.tag instanceof AnyHandler) {
400
- if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
401
- if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
402
- purge = false;
403
- } else if (hndl.tag instanceof RootHandler && parser.twig.current.isRoot) {
404
- if (typeof hndl.function === 'function') hndl.function(parser.twig.tree, parser);
405
- if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.tree);
406
- purge = false;
407
- } else if (Array.isArray(hndl.tag) && hndl.tag.includes(name)) {
408
- if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
409
- if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
410
- purge = false;
411
- } else if (typeof hndl.tag === 'string' && name === hndl.tag) {
412
- if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
413
- if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
414
- purge = false;
415
- } else if (hndl.tag instanceof RegExp && hndl.tag.test(name)) {
416
- if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
417
- if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
418
- purge = false;
419
- } else if (typeof hndl.tag === 'function' && hndl.tag(name, parser.twig.current ?? parser.twig.tree)) {
420
- if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
421
- if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
422
- purge = false;
423
- }
424
- }
425
-
426
- if (options.partial && purge && !parser.twig.current.pinned && !parser.twig.current.isRoot)
427
- parser.twig.parser.twig.current.purge();
428
- parser.twig.current = parser.twig.current.parent();
429
-
430
- }
431
-
432
- /**
433
- * Generic class modeling a XML Node
434
- * @class Twig
435
- */
436
- class Twig {
437
- /**
438
- * Optional condition to get attributes<br>
439
- * - If `undefined`, then all attributes are returned.<br>
440
- * - If `string` then the attribute name must be equal to the string
441
- * - If `RegExp` then the attribute name must match the Regular Expression
442
- * - If [AttributeConditionFilter](#AttributeConditionFilter) then the attribute must filter function
443
- * @typedef {string|RegExp|AttributeConditionFilter} AttributeCondition
444
- */
445
-
446
- /**
447
- * Custom filter function to get desired attributes
448
- * @typedef {function} AttributeConditionFilter
449
- * @param {string} name - Name of the attribute
450
- * @param {string|number} value - Value of the attribute
451
- */
452
-
453
- /**
454
- * XML Processing Instruction object, exist only on root
455
- * @typedef {object} #PI
456
- */
457
-
458
- /**
459
- * XML Declaration object, exist only on root
460
- * @typedef {object} #declaration
461
- */
462
-
463
- /**
464
- * XML namespace of element. Exist onl when parsed with `xmlns: true`
465
- * @typedef {object} #namespace
466
- */
467
-
468
- /**
469
- * Comment or array of comments inside the XML Elements
470
- * @typedef {string|string[]} #comment
471
- */
472
-
473
- /**
474
- * XML attribute `{ <attribute 1>: <value 1>, <attribute 2>: <value 2>, ... }`
475
- * @type {?object}
476
- */
477
- #attributes = {};
478
-
479
- /**
480
- * Content of XML Element
481
- * @type {?string|number}
482
- */
483
- #text = null;
484
-
485
- /**
486
- * The XML tag name
487
- * @type {string}
488
- */
489
- #name;
490
-
491
- /**
492
- * Child XML Elements
493
- * @type {Twig[]}
494
- */
495
- #children = [];
496
-
497
- /**
498
- * The parent object. Undefined on root element
499
- * @type {Twig | undefined}
500
- */
501
- #parent;
502
-
503
- /**
504
- * Determines whether twig is needed in partial load
505
- * @type {boolean}
506
- */
507
- #pinned = false;
508
-
509
- /**
510
- * Create a new Twig object
511
- * @param {Parser} parser - The main parser object
512
- * @param {?string} name - The name of the XML element
513
- * @param {Twig} [parent] - The parent object
514
- * @param {object} [attributes] - Attribute object
515
- * @param {string|number} [index] - Position name 'first', 'last' or the position in the current `children` array.<br>Defaults to 'last'
516
- */
517
- constructor(parser, name, parent, attributes, index) {
518
- if (index === undefined)
519
- parser.twig.current = this;
520
-
521
- if (name === null) {
522
- // Root element not available yet
523
- parser.twig.tree = this;
524
- } else {
525
- this.#name = name;
526
- if (attributes !== undefined)
527
- this.#attributes = attributes;
528
- if (parent === undefined) {
529
- // Root element
530
- parser.twig.tree = this;
531
- } else {
532
- this.#parent = parent;
533
- if (this.#parent.#pinned)
534
- this.#pinned = true;
535
- if (index === 'last' || index === undefined) {
536
- parent.#children.push(this);
537
- } else if (index === 'first') {
538
- parent.#children.unshift(this);
539
- } else if (typeof index === 'number') {
540
- parent.#children = parent.#children.slice(0, index).concat(this, parent.#children.slice(index));
541
- } else {
542
- parent.#children.push(this);
543
- }
544
-
545
- }
546
- }
547
- }
548
-
549
- /**
550
- * Purges the current, typically used after element has been processed.<br>The root object cannot be purged.
551
- */
552
- purge = function () {
553
- if (!this.isRoot)
554
- this.#parent.#children = this.#parent.#children.filter(x => !Object.is(this, x));
555
- };
556
-
557
- /**
558
- * Purges up to the elt element. This allows you to keep part of the tree in memory when you purge.<br>
559
- * The `elt` object is not purged. If you like to purge including `elt`, use `.purgeUpTo(elt.previous())`
560
- * @param {Twig} [elt] - Up to this element the tree will be purged.
561
- * If `undefined` then the current element is purged (i.e. `purge()`)
562
- */
563
- purgeUpTo = function (elt) {
564
- if (elt === undefined) {
565
- this.purge();
566
- } else {
567
- let toPurge = this;
568
- while (toPurge !== null && !Object.is(toPurge, elt)) {
569
- const prev = toPurge.previous();
570
- toPurge.purge();
571
- toPurge = prev;
572
- }
573
- }
574
- };
575
-
576
- /**
577
- * Escapes special XML characters. According W3C specification these are only `&, <, >, ", '` - this is a XML parser, not HTML!
578
- * @param {string} text - Input text to be escaped
579
- */
580
- escapeEntity = function (text) {
581
- return text
582
- .replaceAll("&", "&amp;")
583
- .replaceAll("<", "&lt;")
584
- .replaceAll(">", "&gt;")
585
- .replaceAll('"', "&quot;")
586
- .replaceAll("'", "&apos;");
587
- };
588
-
589
- /**
590
- * Sets the name of root element. In some cases the root is created before the XML-Root element is available<br>
591
- * Used internally!
592
- * @param {string} name - The element name
593
- * @private
594
- */
595
- setRoot(name) {
596
- if (this.isRoot)
597
- this.#name = name;
598
- }
599
-
600
- /**
601
- * Returns `true` if the element is empty, otherwise `false`.
602
- * An empty element has no text nor any child elements, however empty elements can have attributes.
603
- * @returns {boolean} true if empty element
604
- */
605
- get isEmpty() {
606
- return this.#text === null && this.#children.length == 0;
607
- }
608
-
609
- /**
610
- * Returns the level of the element. Root element has 0, children have 1, grand-children 2 and so on
611
- * @returns {number} The level of the element.
612
- */
613
- get level() {
614
- let ret = 0;
615
- let p = this.#parent;
616
- while (p !== undefined) {
617
- p = p.#parent;
618
- ret++;
619
- }
620
- return ret;
621
- }
622
-
623
- /**
624
- * Returns `true` if element is the root object
625
- * @returns {boolean} true if root element
626
- */
627
- get isRoot() {
628
- return this.#parent === undefined;
629
- }
630
-
631
- /**
632
- * Returns `true` if element has child elements
633
- * @returns {boolean} true if has child elements exists
634
- */
635
- get hasChildren() {
636
- return this.#children.length > 0;
637
- }
638
-
639
- /**
640
- * The position in `#children` array. For root object 0
641
- * @returns {number} Position of element in parent
642
- */
643
- get index() {
644
- return this.isRoot ? 0 : this.#parent.#children.indexOf(this);
645
- }
646
-
647
- /**
648
- * The X-Path position of the element
649
- * NOTE: Applies only to currently loaded elements.
650
- * @returns {string} X-Path
651
- */
652
- get path() {
653
- if (this.isRoot)
654
- return `/${this.#name}`;
655
-
656
- let ret = [];
657
- if (this.#parent.children(this.#name).length > 1) {
658
- let sameChildren = this.#parent.children(this.#name);
659
- ret.unshift(`${this.#name}[${sameChildren.indexOf(this) + 1}]`);
660
- } else {
661
- ret.unshift(this.#name);
662
- }
663
- if (!this.isRoot) {
664
- let parent = this.#parent;
665
- while (!parent.isRoot) {
666
- if (parent.#parent.children(parent.#name).length > 1) {
667
- let sameChildren = parent.#parent.children(parent.#name);
668
- ret.unshift(`${parent.#name}[${sameChildren.indexOf(parent) + 1}]`);
669
- } else {
670
- ret.unshift(parent.#name);
671
- }
672
- parent = parent.#parent;
673
- }
674
- }
675
- return '/' + ret.join('/');
676
- }
677
-
678
- /**
679
- * Returns the name of the element.
680
- * @returns {string} Element name
681
- */
682
- get name() {
683
- return this.#name;
684
- }
685
- /**
686
- * Returns the name of the element. Synonym for `twig.name`
687
- * @returns {string} Element name
688
- */
689
- get tag() {
690
- return this.#name;
691
- }
692
-
693
- /**
694
- * The text of the element. No matter if given as text or CDATA entity
695
- * @returns {string} Element text or empty string
696
- */
697
- get text() {
698
- return this.#text ?? '';
699
- }
700
-
701
- /**
702
- * Update the text of the element
703
- * @param {string|number|bigint|boolean} value - New text of the element
704
- * @throws {UnsupportedType} - If value is not a string, boolean or numeric type
705
- */
706
- set text(value) {
707
- if (this.#text === null) this.#text = '';
708
- if (typeof value === 'string')
709
- this.#text += value;
710
- else if (['number', 'bigint', 'boolean'].includes(typeof value))
711
- this.#text += value.toString();
712
- else
713
- throw new UnsupportedType(value);
714
- }
715
-
716
- /**
717
- * Pins the current element. Used for partial reading.
718
- */
719
- pin = function () {
720
- this.#pinned = true;
721
- };
722
-
723
- /**
724
- * Checks if element is pinned
725
- * @returns {boolean} `true` when the element is pinned
726
- */
727
- get pinned() {
728
- return this.#pinned;
729
- }
730
-
731
- /**
732
- * Closes the element
733
- */
734
- close = function () {
735
- Object.seal(this);
736
- };
737
-
738
- /**
739
- * XML-Twig for dummies :-)
740
- * @returns {string} The XML-Tree which is currently available in RAM - no valid XML Structure
741
- */
742
- debug = function () {
743
- return this.root().writer(true, true).output;
744
- };
745
-
746
- /**
747
- * Returns XML string of the element
748
- * @returns {string} The XML-Element as string
749
- */
750
- toString = function () {
751
- return this.writer(true).toString();
752
- };
753
-
754
- /**
755
- * Internal recursive function used by `writer()`
756
- * @param {external:XMLWriter} xw - The writer object
757
- * @param {Twig[]} childArray - Array of child elements
758
- */
759
- #addChild = function (xw, childArray, cur, debug) {
760
- for (let elt of childArray) {
761
- xw.startElement(elt.name);
762
- for (let [key, val] of Object.entries(elt.attributes))
763
- xw.writeAttribute(key, val);
764
- if (elt.text !== null)
765
- xw.text(elt.text);
766
- this.#addChild(xw, elt.children(), elt, debug);
767
- }
768
- if (!debug || Object.isSealed(cur)) xw.endElement();
769
- };
770
-
771
- /**
772
- * Creates xml-writer from current element
773
- * @param {?boolean|string|external:XMLWriter} par - `true` or intention character or an already created XMLWriter
774
- * @returns {external:XMLWriter}
775
- */
776
- writer = function (par, debug) {
777
- const XMLWriter = require('xml-writer');
778
- let xw = par instanceof XMLWriter ? par : new XMLWriter(par);
779
-
780
- xw.startElement(this.#name);
781
- for (let [key, val] of Object.entries(this.#attributes))
782
- xw.writeAttribute(key, val);
783
- if (this.#text !== null)
784
- xw.text(this.#text);
785
- this.#addChild(xw, this.#children, this, debug);
786
- if (!debug || Object.isSealed(this)) xw.endElement();
787
- return xw;
788
- };
789
-
790
- /**
791
- * Returns attribute value or `null` if not found.<br>
792
- * If more than one matches the condition, then it returns object as [attribute()](#attribute)
793
- * @param {AttributeCondition} [condition] - Optional condition to select attribute
794
- * @returns {?string|number|object} - The value of the attribute or `null` if the does not exist
795
- */
796
- attr = function (condition) {
797
- let attr = this.attribute(condition);
798
- if (attr === null)
799
- return null;
800
-
801
- return Object.keys(attr).length === 1 ? attr[Object.keys(attr)[0]] : attr;
802
- };
803
-
804
- /**
805
- * Returns all attributes of the element
806
- * @returns {object} All XML Attributes
807
- */
808
- get attributes() {
809
- return this.#attributes;
810
- }
811
-
812
- /**
813
- * Check if the attribute exist or not
814
- * @param {string} name - The name of the attribute
815
- * @returns {boolean} - Returns `true` if the attribute exists, else `false`
816
- */
817
- hasAttribute = function (name) {
818
- return this.#attributes[name] !== undefined;
819
- };
820
-
821
- /**
822
- * Retrieve or update XML attribute. For update, the condition must be a string, i.e. must match to one attribute only.
823
- * @param {AttributeCondition} [condition] - Optional condition to select attributes
824
- * @param {string|number|bigint|boolean} [value] - New value of the attribute.<br>If `undefined` then existing attributes is returned.
825
- * @returns {object} Attributes or `null` if no matching attribute found
826
- * @example attribute((name, val) => { return name === 'age' && val > 50})
827
- * attribute((name) => { return ['firstName', 'lastName'].includes(name) })
828
- * attribute('firstName')
829
- * attribute(/name/i)
830
- */
831
- attribute = function (condition, value) {
832
- if (value === undefined) {
833
- let attr;
834
- if (condition === undefined) {
835
- attr = this.#attributes;
836
- } else if (typeof condition === 'function') {
837
- attr = Object.fromEntries(Object.entries(this.#attributes).filter(([key, val]) => condition(key, val)));
838
- } else if (typeof condition === 'string') {
839
- attr = this.attribute(key => key === condition);
840
- } else if (condition instanceof RegExp) {
841
- attr = this.attribute(key => condition.test(key));
842
- } else if (condition instanceof Twig) {
843
- throw new UnsupportedCondition(condition, ['string', 'RegEx', 'function']);
844
- } else {
845
- return this.attribute();
846
- }
847
- return attr === null || Object.keys(attr).length == 0 ? null : attr;
848
- } else if (typeof condition === 'string') {
849
- if (typeof value === 'string')
850
- this.#attributes[condition] = value;
851
- else if (['number', 'bigint', 'boolean'].includes(typeof value))
852
- this.#attributes[condition] = value.toString();
853
- else
854
- throw new UnsupportedType(value);
855
- } else {
856
- console.warn('Condition must be a `string` if you like to update an attribute');
857
- }
858
- };
859
-
860
- /**
861
- * Delete the attribute
862
- * @param {string} name - The attribute name
863
- */
864
- deleteAttribute = function (name) {
865
- delete this.#attributes[name];
866
- };
867
-
868
- /**
869
- * Returns the root object
870
- * @returns {Twig} The root element of XML tree
871
- */
872
- root = function () {
873
- if (this.isRoot) {
874
- return this;
875
- } else {
876
- let ret = this.#parent;
877
- while (!ret.isRoot) {
878
- ret = ret.#parent;
879
- }
880
- return ret;
881
- }
882
- };
883
-
884
- /**
885
- * Returns the parent element or null if root element
886
- * @returns {Twig} The parament element
887
- */
888
- parent = function () {
889
- return this.isRoot ? null : this.#parent;
890
- };
891
-
892
- /**
893
- * @returns {Twig} - The current element
894
- */
895
- self = function () {
896
- return this;
897
- };
898
-
899
- /**
900
- * Common function to filter Twig elements from array
901
- * @param {Twig|Twig[]} elements - Array of elements you like to filter or a single element
902
- * @param {ElementCondition} [condition] - The filter condition
903
- * @returns {Twig[]} List of matching elements or empty array
904
- */
905
- filterElements(elements, condition) {
906
- if (!Array.isArray(elements))
907
- return this.filterElements([elements], condition);
908
-
909
- if (condition !== undefined) {
910
- if (typeof condition === 'string') {
911
- return elements.filter(x => x.name === condition);
912
- } else if (condition instanceof RegExp) {
913
- return elements.filter(x => condition.test(x.name));
914
- } else if (condition instanceof Twig) {
915
- return elements.filter(x => Object.is(x, condition));
916
- } else if (typeof condition === 'function') {
917
- return elements.filter(x => condition(x.name, x));
918
- }
919
- }
920
-
921
- return elements;
922
- }
923
-
924
- /**
925
- * Common function to filter Twig element
926
- * @param {Twig} element - Element you like to filter
927
- * @param {ElementCondition} [condition] - The filter condition
928
- * @returns {boolean} `true` if the condition matches
929
- */
930
- testElement(element, condition) {
931
- if (condition === undefined) {
932
- return true;
933
- } else if (typeof condition === 'string') {
934
- return element.name === condition;
935
- } else if (condition instanceof RegExp) {
936
- return condition.test(element.name);
937
- } else if (condition instanceof Twig) {
938
- return Object.is(element, condition);
939
- } else if (typeof condition === 'function') {
940
- return condition(element.name, element);
941
- }
942
- return false;
943
- }
944
-
945
- /**
946
- * All children, optionally matching `condition` of the current element or empty array
947
- * @param {ElementCondition} [condition] - Optional condition
948
- * @returns {Twig[]}
949
- */
950
- children = function (condition) {
951
- return this.filterElements(this.#children, condition);
952
- };
953
-
954
- /**
955
- * Returns the next matching element.
956
- * @param {ElementCondition} [condition] - Optional condition
957
- * @returns {?Twig} - The next element
958
- * @see https://www.w3.org/TR/xpath-datamodel-31/#document-order
959
- */
960
- next = function (condition) {
961
- if (this === null)
962
- return null;
963
-
964
- let elt;
965
- if (this.hasChildren) {
966
- elt = this.#children[0];
967
- } else {
968
- elt = this.nextSibling();
969
- if (elt === null) {
970
- elt = this.#parent;
971
- elt = elt.nextSibling();
972
- }
973
- }
974
- if (elt === null)
975
- return null;
976
-
977
- return this.testElement(elt, condition) ? elt : elt.next(condition);
978
- };
979
-
980
- /**
981
- * Returns the previous matching element.
982
- * @param {ElementCondition} [condition] - Optional condition
983
- * @returns {?Twig} - The previous element
984
- * @see https://www.w3.org/TR/xpath-datamodel-31/#document-order
985
- */
986
- previous = function (condition) {
987
- if (this === null || this.isRoot)
988
- return null;
989
-
990
- let elt = this.prevSibling();
991
- if (elt === null) {
992
- elt = this.parent();
993
- } else {
994
- elt = elt.descendantOrSelf();
995
- elt = elt[elt.length - 1];
996
- }
997
-
998
- return this.testElement(elt, condition) ? elt : elt.previous(condition);
999
- };
1000
-
1001
- /**
1002
- * Returns the first matching element. This is usually the root element
1003
- * @param {ElementCondition} [condition] - Optional condition
1004
- * @returns {?Twig} - The first element
1005
- */
1006
- first = function (condition) {
1007
- if (this === null)
1008
- return null;
1009
- return this.testElement(this.root(), condition) ? this.root() : this.root().next(condition);
1010
- };
1011
-
1012
- /**
1013
- * Returns the last matching element.
1014
- * @param {ElementCondition} [condition] - Optional condition
1015
- * @returns {?Twig} - The last element
1016
- */
1017
- last = function (condition) {
1018
- if (this === null)
1019
- return null;
1020
-
1021
- let elt = this.root();
1022
- if (this.root().hasChildren) {
1023
- elt = this.root().#children[this.root().#children.length - 1];
1024
- while (elt.hasChildren)
1025
- elt = elt.children()[elt.children().length - 1];
1026
- }
1027
-
1028
- return this.testElement(elt, condition) ? elt : elt.previous(condition);
1029
- };
1030
-
1031
- /**
1032
- * Check if the element is the first child of the parent
1033
- * @returns {boolean} `true` if the first child else `false`
1034
- */
1035
- get isFirstChild() {
1036
- if (this.isRoot) {
1037
- return false;
1038
- } else {
1039
- return this.index === 0;
1040
- }
1041
- }
1042
-
1043
- /**
1044
- * Check if the element is the last child of the parent
1045
- * @returns {boolean} `true` if the last child else `false`
1046
- */
1047
- get isLastChild() {
1048
- if (this.isRoot) {
1049
- return false;
1050
- } else {
1051
- return this.index === this.#parent.#children.length - 1;
1052
- }
1053
- }
1054
-
1055
- /**
1056
- * Returns descendants (children, grandchildren, etc.) of the current element
1057
- * @param {ElementCondition} [condition] - Optional condition
1058
- * @returns {Twig[]} - Array of descendants or empty array
1059
- */
1060
- descendant = function (condition) {
1061
- let elts = [];
1062
- for (let c of this.#children) {
1063
- elts.push(c);
1064
- elts = elts.concat(c.descendant());
1065
- }
1066
- return this.filterElements(elts, condition);
1067
- };
1068
-
1069
- /**
1070
- * Returns descendants (children, grandchildren, etc.) of the current element and the current element itself
1071
- * @param {ElementCondition} [condition] - Optional condition
1072
- * @returns {Twig[]} - Array of descendants or empty array
1073
- */
1074
- descendantOrSelf = function (condition) {
1075
- let elts = [this];
1076
- for (let c of this.#children) {
1077
- elts.push(c);
1078
- elts = elts.concat(c.descendant());
1079
- }
1080
- return this.filterElements(elts, condition);
1081
- };
1082
-
1083
- /**
1084
- * Returns ancestors (parent, grandparent, etc.) of the current element
1085
- * @param {ElementCondition} [condition] - Optional condition
1086
- * @returns {Twig[]} - Array of ancestors or empty array
1087
- */
1088
- ancestor = function (condition) {
1089
- let elts = [];
1090
- if (!this.isRoot) {
1091
- let parent = this.#parent;
1092
- elts.push(parent);
1093
- while (!parent.isRoot) {
1094
- parent = parent.#parent;
1095
- elts.push(parent);
1096
- }
1097
- }
1098
- return this.filterElements(elts, condition);
1099
- };
1100
-
1101
- /**
1102
- * Returns ancestors (parent, grandparent, etc.) of the current element and the current element itself
1103
- * @param {ElementCondition} [condition] - Optional condition
1104
- * @returns {Twig[]} - Array of ancestors or empty array
1105
- */
1106
- ancestorOrSelf = function (condition) {
1107
- let elts = [this];
1108
- if (!this.isRoot) {
1109
- let parent = this.#parent;
1110
- elts.push(parent);
1111
- while (!parent.isRoot) {
1112
- parent = parent.#parent;
1113
- elts.push(parent);
1114
- }
1115
- }
1116
- return this.filterElements(elts, condition);
1117
- };
1118
-
1119
- /**
1120
- * Returns all sibling element of the current element
1121
- * @param {ElementCondition} [condition] - Optional condition
1122
- * @returns {Twig[]} - Array of sibling or empty array
1123
- */
1124
- sibling = function (condition) {
1125
- let elts = [];
1126
- if (!this.isRoot) {
1127
- elts = this.#parent.#children.filter(x => !Object.is(x, this));
1128
- }
1129
- return this.filterElements(elts, condition);
1130
- };
1131
-
1132
- /**
1133
- * Returns all sibling element of the current element and the current element itself
1134
- * @param {ElementCondition} [condition] - Optional condition
1135
- * @returns {Twig[]} - Array of sibling or empty array
1136
- */
1137
- siblingOrSelf = function (condition) {
1138
- let elts = [this];
1139
- if (!this.isRoot) {
1140
- elts = this.#parent.#children;
1141
- }
1142
- return this.filterElements(elts, condition);
1143
- };
1144
-
1145
- /**
1146
- * Returns all following sibling element of the current element
1147
- * @param {ElementCondition} [condition] - Optional condition
1148
- * @returns {Twig[]} - Array of sibling or empty array
1149
- */
1150
- followingSibling = function (condition) {
1151
- let elts = [];
1152
- if (!this.isRoot) {
1153
- elts = this.#parent.#children.slice(this.index + 1);
1154
- }
1155
- return this.filterElements(elts, condition);
1156
- };
1157
-
1158
- /**
1159
- * Returns all preceding sibling element of the current element
1160
- * @param {ElementCondition} [condition] - Optional condition
1161
- * @returns {Twig[]} - Array of sibling or empty array
1162
- */
1163
- precedingSibling = function (condition) {
1164
- let elts = [];
1165
- if (!this.isRoot) {
1166
- elts = this.#parent.#children.slice(0, this.index);
1167
- }
1168
- return this.filterElements(elts, condition);
1169
- };
1170
-
1171
- /**
1172
- * Returns next sibling element of the current element
1173
- * @param {ElementCondition} [condition] - Optional condition
1174
- * @returns {?Twig} - The next sibling or `null`
1175
- */
1176
- nextSibling = function (condition) {
1177
- let elt;
1178
- if (!this.isRoot)
1179
- elt = this.#parent.#children[this.index + 1];
1180
- if (elt === undefined)
1181
- return null;
1182
-
1183
- return this.testElement(elt, condition) ? elt : elt.nextSibling(condition);
1184
- };
1185
-
1186
- /**
1187
- * Returns previous sibling element of the current element
1188
- * @param {ElementCondition} [condition] - Optional condition
1189
- * @returns {?Twig} - The previous sibling or `null`
1190
- */
1191
- prevSibling = function (condition) {
1192
- if (!this.isRoot && this.index > 0) {
1193
- let elt = this.#parent.#children[this.index - 1];
1194
- return this.testElement(elt, condition) ? elt : elt.prevSibling(condition);
1195
- } else {
1196
- return null;
1197
- }
1198
- };
1199
-
1200
- /**
1201
- * Find a specific element within current element. Same as `.descendant(condition)[0]`
1202
- * @param {ElementCondition} condition - Find condition
1203
- * @returns {?Twig} - First matching element or `null`
1204
- */
1205
- find = function (condition) {
1206
- let children = this.filterElements(this.#children, condition);
1207
- if (children.length > 0)
1208
- return children[0];
1209
-
1210
- for (let child of this.#children) {
1211
- let ret = child.find(condition);
1212
- if (ret !== null)
1213
- return ret;
1214
- }
1215
- return null;
1216
- };
1217
-
1218
- /**
1219
- * Add a new element in the current element
1220
- * @param {string} name - The tag name
1221
- * @param {?string} [text] - Text of the element
1222
- * @param {?object} [attributes] - Element attributes
1223
- * @param {name|number} [position] - Position name 'first', 'last' or the position in the `children`
1224
- * @returns {Twig} - The appended element
1225
- */
1226
- addElement = function (parser, name, text, attributes, position) {
1227
- let twig = new Twig(parser, name, this, attributes ?? {}, position ?? 'last');
1228
- twig.#text = text ?? null;
1229
- twig.close();
1230
- return twig;
1231
- };
1232
-
1233
- /**
1234
- * Deletes the current element from tree, same as `purge()`. The root object cannot be deleted.
1235
- */
1236
- delete = function () {
1237
- this.purge();
1238
- };
1239
-
1240
-
1241
- }
1242
-
1243
-
1244
- /**
1245
- * Generic error for non implemented feature
1246
- * @exception NotImplementedYet
1247
- */
1248
- class NotImplementedYet extends TypeError {
1249
- constructor() {
1250
- super(`Net yet implemented`);
1251
- }
1252
- }
1253
-
1254
-
1255
- /**
1256
- * Error for unsupported parser
1257
- * @exception UnsupportedParser
1258
- */
1259
- class UnsupportedParser extends TypeError {
1260
- /**
1261
- * @param {string} t Parser type
1262
- */
1263
- constructor(t) {
1264
- super(`Parser '${t}' is not supported. Use 'expat', 'sax' (default)`);
1265
- }
1266
- }
1267
-
1268
- /**
1269
- * Generic error for unsupported data type
1270
- * @exception UnsupportedType
1271
- */
1272
- class UnsupportedType extends TypeError {
1273
- /**
1274
- * @param {*} t Parameter which was used
1275
- */
1276
- constructor(t) {
1277
- super(`Type ${typeof t} is not supported in XML`);
1278
- }
1279
- }
1280
-
1281
- /**
1282
- * Generic error for unsupported condition
1283
- * @exception UnsupportedCondition
1284
- */
1285
- class UnsupportedCondition extends TypeError {
1286
- /**
1287
- * @param {*} condition The condition value
1288
- * @param {string[]} t List of supported data types
1289
- */
1290
- constructor(condition, t) {
1291
- super(`Condition '${JSON.stringify(condition)}' must be a ${t.map(x => `'${x}'`).join(' or ')}`);
1292
- }
1293
- }
1294
-
1295
-
1296
- module.exports = { createParser, Twig, Any, Root };
1
+ /**
2
+ * @version: 1.7.4
3
+ * @author: Wernfried Domscheit
4
+ * @copyright: Copyright (c) 2025 Wernfried Domscheit. All rights reserved.
5
+ * @website: https://www.npmjs.com/package/xml-twig
6
+ */
7
+
8
+ const SAX = 'sax';
9
+ const EXPAT = ['expat', 'node-expat'];
10
+
11
+ /**
12
+ * @external XMLWriter
13
+ * @see {@link https://www.npmjs.com/package/xml-writer|xml-writer}
14
+ */
15
+
16
+ /**
17
+ * @external sax
18
+ * @see {@link https://www.npmjs.com/package/sax|sax}
19
+ */
20
+
21
+ /**
22
+ * @external node-expat
23
+ * @see {@link https://www.npmjs.com/package/node-expat|node-expat}
24
+ */
25
+
26
+ /**
27
+ * @external libxmljs
28
+ * Though module looks promising, it is not implemented, because it does not support Streams.
29
+ * According to {@link https://github.com/libxmljs/libxmljs/issues/390|Stream Support} it was requested in 2016, i.e. 8 years ago.
30
+ * Apart from that, documentation is very sparse.
31
+ * @see {@link https://www.npmjs.com/package/libxmljs|libxmljs}
32
+ */
33
+
34
+ /*
35
+ * Other parsers I had a look at:
36
+ * {@link https://www.npmjs.com/package/sax-wasm|sax-wasm}: not a 'stream.Writable'
37
+ * {@link https://www.npmjs.com/package/@rubensworks/saxes|saxes}: not a 'stream.Writable'
38
+ * {@link https://www.npmjs.com/package/node-xml-stream|node-xml-stream}: Lacks comment and processinginstruction and maybe self closing tags
39
+ * {@link https://www.npmjs.com/package/node-xml-stream-parser|node-xml-stream-parser}: Lacks comment and processinginstruction
40
+ * {@link https://www.npmjs.com/package/saxes-stream|saxes-stream}: not a 'stream.Writable'
41
+ * {@link https://www.npmjs.com/package/xml-streamer|xml-streamer}: based on 'node-expat', does not add any benefit
42
+ */
43
+
44
+
45
+ class RootHandler { }
46
+ class AnyHandler { }
47
+
48
+
49
+ /**
50
+ * @constant {RootHandler} Root
51
+ * @type {RootHandler}
52
+ */
53
+ const Root = new RootHandler();
54
+
55
+ /**
56
+ * @constant {AnyHandler} Any
57
+ * @type {AnyHandler}
58
+ */
59
+ const Any = new AnyHandler();
60
+
61
+
62
+ /**
63
+ * Optional settings for the Twig parser
64
+ * @typedef ParserOptions
65
+ * @property {'sax' | 'expat'} [method] - The underlying parser. Either `'sax'`, `'expat'`.
66
+ * @property {boolean} [xmlns] - If `true`, then namespaces are accessible by `namespace` property.
67
+ * @property {boolean} [trim] - If `true`, then turn any whitespace into a single space. Text and comments are trimmed.
68
+ * @property {boolean} [resumeAfterError] - If `true` then parser continues reading after an error. Otherwise it throws exception.
69
+ * @property {boolean} [partial] - If `true` then unhandled elements are purged.
70
+ * @property {string} [file] - Optional. The name of file to be parsed. Just used for information and logging purpose.
71
+ * @example { method: 'expat', xmlns: true }
72
+ * @default { method: 'sax', xmlns: false, trim: true, resumeAfterError: false, partial: false }
73
+ */
74
+
75
+ /**
76
+ * Reference to handler functions for Twig objects.<br>
77
+ * Element can be specified as string, Regular Expression, custom function, `Twig.Root` or `Twig.Any`<br>
78
+ * You can specify a `function` or a `event` name
79
+ * @typedef TwigHandler
80
+ * @property {HandlerCondition} tag - Element specification
81
+ * @property {HandlerFunction} [function] - Definition of handler function, either anonymous or explicit function
82
+ * @property {string} [event] - Type of the event to be emitted
83
+ */
84
+
85
+ /**
86
+ * Condition to specify when handler shall be called<br>
87
+ * - If `string` then the element name must be equal to the string
88
+ * - If `string[]` then the element name must be included in string array
89
+ * - If `RegExp` then the element name must match the Regular Expression
90
+ * - If [HandlerConditionFilter](#HandlerConditionFilter) then function must return `true`
91
+ * - Use `Twig.Root` to call the handler on root element, i.e. when the end of document is reached
92
+ * - Use `Twig.Any` to call the handler on every element
93
+ * @typedef {string|string[]|RegExp|HandlerConditionFilter|Root|Any} HandlerCondition
94
+ */
95
+
96
+ /**
97
+ * Handler function for Twig objects, i.e. the way you like to use the XML element.
98
+ * @typedef {function} HandlerFunction
99
+ * @param {Twig} elt - The current Twig element on which the function was called.
100
+ */
101
+
102
+ /**
103
+ * Custom filter function to specify when handler shall be called
104
+ * @typedef {function} HandlerConditionFilter
105
+ * @param {string} name - Name of the element
106
+ * @returns {boolean} If the function returns `true`, then it is included in the filter
107
+ */
108
+
109
+ /**
110
+ * Optional condition to get elements<br>
111
+ * - If `undefined`, then all elements are returned.<br>
112
+ * - If `string` then the element name must be equal to the string
113
+ * - If `RegExp` then the element name must match the Regular Expression
114
+ * - If [ElementConditionFilter](#ElementConditionFilter) then function must return `true`
115
+ * - Use [Twig](#Twig) object to find a specific element
116
+ * @typedef {string|RegExp|ElementConditionFilter|Twig|undefined} ElementCondition
117
+ */
118
+
119
+ /**
120
+ * Custom filter function to select desired elements
121
+ * @typedef {function} ElementConditionFilter
122
+ * @param {string} name - Name of the element
123
+ * @param {Twig} elt - The Twig object
124
+ * @returns {boolean} If the function returns `true`, then it is included in the filter
125
+ */
126
+
127
+ /**
128
+ * @typedef Parser
129
+ * @property {number} [currentLine] - The currently processed line in the XML-File.
130
+ * @property {number} [currentColumn] - The currently processed column in the XML-File.
131
+ * @property {string} [file] - The name of file to be parsed. Just used for information and logging purpose.
132
+ * @property {object} [twig] - Object with XML tree and current XML element
133
+ * @property {string} [method] - The underlying parser. Either `'sax'`, `'expat'`.
134
+ * @returns {external:sax|external:node-expat} The parser Object
135
+ */
136
+
137
+ /**
138
+ * Create a new Twig parser
139
+ * @param {TwigHandler|TwigHandler[]} handler - Object or array of element specification and function to handle elements
140
+ * @param {ParserOptions} [options] - Object of optional options
141
+ * @throws {UnsupportedParser} - For an unsupported parser. Currently `expat` and `sax` (default) are supported.
142
+ * @returns {Parser} The parser Object
143
+ */
144
+ function createParser(handler, options = {}) {
145
+ options = Object.assign({ method: SAX, xmlns: false, trim: true, resumeAfterError: false, partial: false }, options);
146
+ let parser;
147
+ let namespaces = {};
148
+
149
+ const handlerCheck = Array.isArray(handler) ? handler : [handler];
150
+ if (handlerCheck.find(x => x.tag === undefined) != null || handlerCheck.find(x => x.tag.length == 0) != null)
151
+ throw new ReferenceError(`'handler.tag' is not defined`);
152
+ if (options.partial && handlerCheck.find(x => x.tag instanceof AnyHandler) != null)
153
+ console.warn(`Using option '{ partial: true }' and handler '{ tag: Any, function: ${any.function.toString()} }' does not make much sense`);
154
+
155
+ // `parser.on("...", err => {...}` does not work, because I need access to 'this'
156
+ if (options.method === SAX) {
157
+ // Set options to have the same behavior as in expat
158
+ parser = require("sax").createStream(true, { strictEntities: true, position: true, xmlns: options.xmlns, trim: options.trim });
159
+
160
+ Object.defineProperty(parser, 'currentLine', {
161
+ enumerable: true,
162
+ get() { return parser._parser.line + 1; }
163
+ });
164
+ Object.defineProperty(parser, 'currentColumn', {
165
+ enumerable: true,
166
+ get() { return parser._parser.column + 1; }
167
+ });
168
+
169
+ parser.on("closetag", onClose.bind(null, handler, parser, options));
170
+ parser.on("opentagstart", onStart.bind(null, parser, {
171
+ handler: Array.isArray(handler) ? handler : [handler],
172
+ options: options,
173
+ namespaces: namespaces
174
+ }));
175
+
176
+ parser.on("processinginstruction", function (pi) {
177
+ if (pi.name === 'xml') {
178
+ // SAX parser handles XML declaration as Processing Instruction
179
+ let declaration = {};
180
+ for (let item of pi.body.split(' ')) {
181
+ let [k, v] = item.split('=');
182
+ declaration[k] = v.replaceAll('"', '').replaceAll("'", '');
183
+ }
184
+ parser.twig.tree = new Twig(parser, null);
185
+ Object.defineProperty(parser.twig.tree, 'declaration', {
186
+ value: declaration,
187
+ writable: false,
188
+ enumerable: true
189
+ });
190
+ } else if (parser.twig.tree.PI === undefined) {
191
+ Object.defineProperty(parser.twig.tree, 'PI', {
192
+ value: { target: pi.name, data: pi.body },
193
+ writable: false,
194
+ enumerable: true
195
+ });
196
+ }
197
+ });
198
+
199
+ parser.on("attribute", function (attr) {
200
+ if (options.xmlns && (attr.uri ?? '') !== '' && attr.local !== undefined) {
201
+ namespaces[attr.local] = attr.uri;
202
+ if (parser.twig.current.name.includes(':')) {
203
+ Object.defineProperty(parser.twig.current, 'namespace', {
204
+ value: { local: attr.local, uri: attr.uri },
205
+ writable: false,
206
+ enumerable: true
207
+ });
208
+ } else {
209
+ parser.twig.current.attribute(attr.name, attr.value);
210
+ }
211
+ } else {
212
+ parser.twig.current.attribute(attr.name, attr.value);
213
+ }
214
+ });
215
+ parser.on("cdata", function (str) {
216
+ parser.twig.current.text = options.trim ? str.trim() : str;
217
+ });
218
+
219
+ parser.on('end', function () {
220
+ parser.twig = { current: null, tree: null };
221
+ parser.emit("finish");
222
+ parser.emit("close");
223
+ });
224
+
225
+ } else if (EXPAT.includes(options.method)) {
226
+ parser = require("node-expat").createParser();
227
+ Object.defineProperty(parser, 'currentLine', {
228
+ enumerable: true,
229
+ get() { return parser.parser.getCurrentLineNumber(); }
230
+ });
231
+ Object.defineProperty(parser, 'currentColumn', {
232
+ enumerable: true,
233
+ get() { return parser.parser.getCurrentColumnNumber(); }
234
+ });
235
+
236
+ parser.on("endElement", onClose.bind(null, handler, parser, options));
237
+ parser.on("startElement", onStart.bind(null, parser, {
238
+ handler: Array.isArray(handler) ? handler : [handler],
239
+ options: options,
240
+ namespaces: namespaces
241
+ }));
242
+
243
+ parser.on('xmlDecl', function (version, encoding, standalone) {
244
+ parser.twig.tree = new Twig(parser, null);
245
+ let dec = {};
246
+ if (version !== undefined) dec.version = version;
247
+ if (encoding !== undefined) dec.encoding = encoding;
248
+ if (standalone !== undefined) dec.standalone = standalone;
249
+ Object.defineProperty(parser.twig.tree, 'declaration', {
250
+ value: dec,
251
+ writable: false,
252
+ enumerable: true
253
+ });
254
+ });
255
+
256
+ parser.on('processingInstruction', function (target, data) {
257
+ parser.twig.tree.PI = { target: target, data: data };
258
+ });
259
+
260
+ parser.on('end', function () {
261
+ parser.twig = { current: null, tree: null };
262
+ parser.emit("finish");
263
+ });
264
+
265
+ } else {
266
+ throw new UnsupportedParser(options.method);
267
+ }
268
+
269
+ Object.defineProperty(parser, 'twig', {
270
+ enumerable: true,
271
+ value: { current: null, tree: null },
272
+ writable: true
273
+ });
274
+
275
+ Object.defineProperty(parser, 'method', {
276
+ value: options.method,
277
+ writable: false,
278
+ enumerable: true
279
+ });
280
+
281
+ if (options.file != null) {
282
+ Object.defineProperty(parser, 'file', {
283
+ value: options.file,
284
+ writable: false,
285
+ enumerable: true
286
+ });
287
+ }
288
+
289
+ // Common events
290
+ parser.on('text', function (str) {
291
+ if (parser.twig.current === null) return;
292
+ parser.twig.current.text = options.trim ? str.trim() : str;
293
+ });
294
+
295
+ parser.on("comment", function (str) {
296
+ if (parser.twig.current.hasOwnProperty('comment')) {
297
+ if (typeof parser.twig.current.comment === 'string') {
298
+ parser.twig.current.comment = [parser.twig.current.comment, str.trim()];
299
+ } else {
300
+ parser.twig.current.comment.push(str.trim());
301
+ }
302
+ } else {
303
+ Object.defineProperty(parser.twig.current, 'comment', {
304
+ value: str.trim(),
305
+ writable: true,
306
+ enumerable: true,
307
+ configurable: true
308
+ });
309
+ }
310
+ });
311
+
312
+ parser.on('error', function (err) {
313
+ console.error(`error at line [${parser.currentLine}], column [${parser.currentColumn}]`, err);
314
+ if (options.resumeAfterError) {
315
+ parser.underlyingParser.error = null;
316
+ parser.underlyingParser.resume();
317
+ }
318
+ });
319
+
320
+ return parser;
321
+ }
322
+
323
+
324
+ /**
325
+ * Common Event hanlder for starting tag
326
+ * @param {Parser} parser - The main parser object
327
+ * @param {object} binds - Additional parameter object
328
+ * @param {object|string} node - Node or Node name
329
+ * @param {object} attrs - Node Attributes
330
+ */
331
+ function onStart(parser, binds, node, attrs) {
332
+
333
+ const name = typeof node === 'string' ? node : node.name;
334
+ const handler = binds.handler;
335
+ const options = binds.options;
336
+ let namespaces = binds.namespaces;
337
+
338
+ let attrNS = {};
339
+ if (options.xmlns && attrs !== undefined) {
340
+ for (let key of Object.keys(attrs).filter(x => !(x.startsWith('xmlns:') && name.includes(':'))))
341
+ attrNS[key] = attrs[key];
342
+ }
343
+
344
+ if (parser.twig.tree === null) {
345
+ parser.twig.tree = new Twig(parser, name, parser.twig.current, options.xmlns ? attrNS : attrs);
346
+ } else {
347
+ if (parser.twig.current.isRoot && parser.twig.current.name === undefined) {
348
+ parser.twig.current.setRoot(name);
349
+ if (attrs !== undefined) {
350
+ for (let [key, val] of Object.entries(options.xmlns ? attrNS : attrs))
351
+ parser.twig.current.attribute(key, val);
352
+ }
353
+ } else {
354
+ let elt = new Twig(parser, name, parser.twig.current, options.xmlns ? attrNS : attrs);
355
+ if (options.partial) {
356
+ for (let hndl of handler) {
357
+ if (typeof hndl.tag === 'string' && name === hndl.tag) {
358
+ elt.pin();
359
+ break;
360
+ } else if (Array.isArray(hndl.tag) && hndl.tag.includes(name)) {
361
+ elt.pin();
362
+ break;
363
+ } else if (hndl.tag instanceof RegExp && hndl.tag.test(name)) {
364
+ elt.pin();
365
+ break;
366
+ } else if (typeof hndl.tag === 'function' && hndl.tag(name, parser.twig.current ?? parser.twig.tree)) {
367
+ elt.pin();
368
+ break;
369
+ }
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ if (options.xmlns) {
376
+ if (EXPAT.includes(options.method)) {
377
+ for (let key of Object.keys(attrs).filter(x => x.startsWith('xmlns:')))
378
+ namespaces[key.split(':')[1]] = attrs[key];
379
+ }
380
+ if (name.includes(':')) {
381
+ let prefix = name.split(':')[0];
382
+ if (namespaces[prefix] !== undefined) {
383
+ Object.defineProperty(parser.twig.current, 'namespace', {
384
+ value: { local: prefix, uri: namespaces[prefix] },
385
+ writable: false,
386
+ enumerable: true
387
+ });
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Common Event hanlder for closing tag. On closed elements it either calls the Handler function or emits the specified event.
395
+ * @param {TwigHandler|TwigHandler[]} handler - Object or array of element specification and function to handle elements
396
+ * @param {Parser} parser - The main parser object
397
+ * @param {external:sax|external:node-expat} parser - SAXStream or node-expat Stream object
398
+ * @param {ParserOptions} options - Object of optional options
399
+ * @param {string} name - Event handler parameter
400
+ */
401
+ function onClose(handler, parser, options, name) {
402
+ parser.twig.current.close();
403
+ let purge = true;
404
+
405
+ for (let hndl of Array.isArray(handler) ? handler : [handler]) {
406
+ if (hndl.tag instanceof AnyHandler) {
407
+ if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
408
+ if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
409
+ purge = false;
410
+ } else if (hndl.tag instanceof RootHandler && parser.twig.current.isRoot) {
411
+ if (typeof hndl.function === 'function') hndl.function(parser.twig.tree, parser);
412
+ if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.tree);
413
+ purge = false;
414
+ } else if (Array.isArray(hndl.tag) && hndl.tag.includes(name)) {
415
+ if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
416
+ if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
417
+ purge = false;
418
+ } else if (typeof hndl.tag === 'string' && name === hndl.tag) {
419
+ if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
420
+ if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
421
+ purge = false;
422
+ } else if (hndl.tag instanceof RegExp && hndl.tag.test(name)) {
423
+ if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
424
+ if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
425
+ purge = false;
426
+ } else if (typeof hndl.tag === 'function' && hndl.tag(name, parser.twig.current ?? parser.twig.tree)) {
427
+ if (typeof hndl.function === 'function') hndl.function(parser.twig.current ?? parser.twig.tree, parser);
428
+ if (typeof hndl.event === 'string') parser.emit(hndl.event, parser.twig.current ?? parser.twig.tree);
429
+ purge = false;
430
+ }
431
+ }
432
+
433
+ if (options.partial && purge && !parser.twig.current.pinned && !parser.twig.current.isRoot)
434
+ parser.twig.parser.twig.current.purge();
435
+ parser.twig.current = parser.twig.current.parent();
436
+
437
+ }
438
+
439
+ /**
440
+ * Generic class modeling a XML Node
441
+ * @class Twig
442
+ */
443
+ class Twig {
444
+ /**
445
+ * Optional condition to get attributes<br>
446
+ * - If `undefined`, then all attributes are returned.<br>
447
+ * - If `string` then the attribute name must be equal to the string
448
+ * - If `RegExp` then the attribute name must match the Regular Expression
449
+ * - If [AttributeConditionFilter](#AttributeConditionFilter) then the attribute must filter function
450
+ * @typedef {string|RegExp|AttributeConditionFilter} AttributeCondition
451
+ */
452
+
453
+ /**
454
+ * Custom filter function to get desired attributes
455
+ * @typedef {function} AttributeConditionFilter
456
+ * @param {string} name - Name of the attribute
457
+ * @param {string|number} value - Value of the attribute
458
+ */
459
+
460
+ /**
461
+ * XML Processing Instruction object, exist only on root
462
+ * @typedef {object} #PI
463
+ */
464
+
465
+ /**
466
+ * XML Declaration object, exist only on root
467
+ * @typedef {object} #declaration
468
+ */
469
+
470
+ /**
471
+ * XML namespace of element. Exist onl when parsed with `xmlns: true`
472
+ * @typedef {object} #namespace
473
+ */
474
+
475
+ /**
476
+ * Comment or array of comments inside the XML Elements
477
+ * @typedef {string|string[]} #comment
478
+ */
479
+
480
+ /**
481
+ * XML attribute `{ <attribute 1>: <value 1>, <attribute 2>: <value 2>, ... }`
482
+ * @type {?object}
483
+ */
484
+ #attributes = {};
485
+
486
+ /**
487
+ * Content of XML Element
488
+ * @type {?string|number}
489
+ */
490
+ #text = null;
491
+
492
+ /**
493
+ * The XML tag name
494
+ * @type {string}
495
+ */
496
+ #name;
497
+
498
+ /**
499
+ * Child XML Elements
500
+ * @type {Twig[]}
501
+ */
502
+ #children = [];
503
+
504
+ /**
505
+ * The parent object. Undefined on root element
506
+ * @type {Twig | undefined}
507
+ */
508
+ #parent;
509
+
510
+ /**
511
+ * Determines whether twig is needed in partial load
512
+ * @type {boolean}
513
+ */
514
+ #pinned = false;
515
+
516
+ /**
517
+ * Create a new Twig object
518
+ * @param {Parser} parser - The main parser object
519
+ * @param {?string} name - The name of the XML element
520
+ * @param {Twig} [parent] - The parent object
521
+ * @param {object} [attributes] - Attribute object
522
+ * @param {string|number} [index] - Position name 'first', 'last' or the position in the current `children` array.<br>Defaults to 'last'
523
+ */
524
+ constructor(parser, name, parent, attributes, index) {
525
+ if (index === undefined)
526
+ parser.twig.current = this;
527
+
528
+ if (name === null) {
529
+ // Root element not available yet
530
+ parser.twig.tree = this;
531
+ } else {
532
+ this.#name = name;
533
+ if (attributes !== undefined)
534
+ this.#attributes = attributes;
535
+ if (parent === undefined) {
536
+ // Root element
537
+ parser.twig.tree = this;
538
+ } else {
539
+ this.#parent = parent;
540
+ if (this.#parent.#pinned)
541
+ this.#pinned = true;
542
+ if (index === 'last' || index === undefined) {
543
+ parent.#children.push(this);
544
+ } else if (index === 'first') {
545
+ parent.#children.unshift(this);
546
+ } else if (typeof index === 'number') {
547
+ parent.#children = parent.#children.slice(0, index).concat(this, parent.#children.slice(index));
548
+ } else {
549
+ parent.#children.push(this);
550
+ }
551
+
552
+ }
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Purges the current, typically used after element has been processed.<br>The root object cannot be purged.
558
+ */
559
+ purge = function () {
560
+ if (!this.isRoot)
561
+ this.#parent.#children = this.#parent.#children.filter(x => !Object.is(this, x));
562
+ };
563
+
564
+ /**
565
+ * Purges up to the elt element. This allows you to keep part of the tree in memory when you purge.<br>
566
+ * The `elt` object is not purged. If you like to purge including `elt`, use `.purgeUpTo(elt.previous())`
567
+ * @param {Twig} [elt] - Up to this element the tree will be purged.
568
+ * If `undefined` then the current element is purged (i.e. `purge()`)
569
+ */
570
+ purgeUpTo = function (elt) {
571
+ if (elt === undefined) {
572
+ this.purge();
573
+ } else {
574
+ let toPurge = this;
575
+ while (toPurge !== null && !Object.is(toPurge, elt)) {
576
+ const prev = toPurge.previous();
577
+ toPurge.purge();
578
+ toPurge = prev;
579
+ }
580
+ }
581
+ };
582
+
583
+ /**
584
+ * Escapes special XML characters. According W3C specification these are only `&, <, >, ", '` - this is a XML parser, not HTML!
585
+ * @param {string} text - Input text to be escaped
586
+ */
587
+ escapeEntity = function (text) {
588
+ return text
589
+ .replaceAll("&", "&amp;")
590
+ .replaceAll("<", "&lt;")
591
+ .replaceAll(">", "&gt;")
592
+ .replaceAll('"', "&quot;")
593
+ .replaceAll("'", "&apos;");
594
+ };
595
+
596
+ /**
597
+ * Sets the name of root element. In some cases the root is created before the XML-Root element is available<br>
598
+ * Used internally!
599
+ * @param {string} name - The element name
600
+ * @private
601
+ */
602
+ setRoot(name) {
603
+ if (this.isRoot)
604
+ this.#name = name;
605
+ }
606
+
607
+ /**
608
+ * Returns `true` if the element is empty, otherwise `false`.
609
+ * An empty element has no text nor any child elements, however empty elements can have attributes.
610
+ * @returns {boolean} true if empty element
611
+ */
612
+ get isEmpty() {
613
+ return this.#text === null && this.#children.length == 0;
614
+ }
615
+
616
+ /**
617
+ * Returns the level of the element. Root element has 0, children have 1, grand-children 2 and so on
618
+ * @returns {number} The level of the element.
619
+ */
620
+ get level() {
621
+ let ret = 0;
622
+ let p = this.#parent;
623
+ while (p !== undefined) {
624
+ p = p.#parent;
625
+ ret++;
626
+ }
627
+ return ret;
628
+ }
629
+
630
+ /**
631
+ * Returns `true` if element is the root object
632
+ * @returns {boolean} true if root element
633
+ */
634
+ get isRoot() {
635
+ return this.#parent === undefined;
636
+ }
637
+
638
+ /**
639
+ * Returns `true` if element has child elements
640
+ * @returns {boolean} true if has child elements exists
641
+ */
642
+ get hasChildren() {
643
+ return this.#children.length > 0;
644
+ }
645
+
646
+ /**
647
+ * The position in `#children` array. For root object 0
648
+ * @returns {number} Position of element in parent
649
+ */
650
+ get index() {
651
+ return this.isRoot ? 0 : this.#parent.#children.indexOf(this);
652
+ }
653
+
654
+ /**
655
+ * The X-Path position of the element
656
+ * NOTE: Applies only to currently loaded elements.
657
+ * @returns {string} X-Path
658
+ */
659
+ get path() {
660
+ if (this.isRoot)
661
+ return `/${this.#name}`;
662
+
663
+ let ret = [];
664
+ if (this.#parent.children(this.#name).length > 1) {
665
+ let sameChildren = this.#parent.children(this.#name);
666
+ ret.unshift(`${this.#name}[${sameChildren.indexOf(this) + 1}]`);
667
+ } else {
668
+ ret.unshift(this.#name);
669
+ }
670
+ if (!this.isRoot) {
671
+ let parent = this.#parent;
672
+ while (!parent.isRoot) {
673
+ if (parent.#parent.children(parent.#name).length > 1) {
674
+ let sameChildren = parent.#parent.children(parent.#name);
675
+ ret.unshift(`${parent.#name}[${sameChildren.indexOf(parent) + 1}]`);
676
+ } else {
677
+ ret.unshift(parent.#name);
678
+ }
679
+ parent = parent.#parent;
680
+ }
681
+ }
682
+ return '/' + ret.join('/');
683
+ }
684
+
685
+ /**
686
+ * Returns the name of the element.
687
+ * @returns {string} Element name
688
+ */
689
+ get name() {
690
+ return this.#name;
691
+ }
692
+ /**
693
+ * Returns the name of the element. Synonym for `twig.name`
694
+ * @returns {string} Element name
695
+ */
696
+ get tag() {
697
+ return this.#name;
698
+ }
699
+
700
+ /**
701
+ * The text of the element. No matter if given as text or CDATA entity
702
+ * @returns {string} Element text or empty string
703
+ */
704
+ get text() {
705
+ return this.#text ?? '';
706
+ }
707
+
708
+ /**
709
+ * Update the text of the element
710
+ * @param {string|number|bigint|boolean} value - New text of the element
711
+ * @throws {UnsupportedType} - If value is not a string, boolean or numeric type
712
+ */
713
+ set text(value) {
714
+ if (this.#text === null) this.#text = '';
715
+ if (typeof value === 'string')
716
+ this.#text += value;
717
+ else if (['number', 'bigint', 'boolean'].includes(typeof value))
718
+ this.#text += value.toString();
719
+ else
720
+ throw new UnsupportedType(value);
721
+ }
722
+
723
+ /**
724
+ * Pins the current element. Used for partial reading.
725
+ */
726
+ pin = function () {
727
+ this.#pinned = true;
728
+ };
729
+
730
+ /**
731
+ * Checks if element is pinned
732
+ * @returns {boolean} `true` when the element is pinned
733
+ */
734
+ get pinned() {
735
+ return this.#pinned;
736
+ }
737
+
738
+ /**
739
+ * Closes the element
740
+ */
741
+ close = function () {
742
+ Object.seal(this);
743
+ };
744
+
745
+ /**
746
+ * XML-Twig for dummies :-)
747
+ * @returns {string} The XML-Tree which is currently available in RAM - no valid XML Structure
748
+ */
749
+ debug = function () {
750
+ return this.root().writer(true, true).output;
751
+ };
752
+
753
+ /**
754
+ * Returns XML string of the element
755
+ * @returns {string} The XML-Element as string
756
+ */
757
+ toString = function () {
758
+ return this.writer(true).toString();
759
+ };
760
+
761
+ /**
762
+ * Internal recursive function used by `writer()`
763
+ * @param {external:XMLWriter} xw - The writer object
764
+ * @param {Twig[]} childArray - Array of child elements
765
+ */
766
+ #addChild = function (xw, childArray, cur, debug) {
767
+ for (let elt of childArray) {
768
+ xw.startElement(elt.name);
769
+ for (let [key, val] of Object.entries(elt.attributes))
770
+ xw.writeAttribute(key, val);
771
+ if (elt.text !== null)
772
+ xw.text(elt.text);
773
+ this.#addChild(xw, elt.children(), elt, debug);
774
+ }
775
+ if (!debug || Object.isSealed(cur)) xw.endElement();
776
+ };
777
+
778
+ /**
779
+ * Creates xml-writer from current element
780
+ * @param {?boolean|string|external:XMLWriter} par - `true` or intention character or an already created XMLWriter
781
+ * @returns {external:XMLWriter}
782
+ */
783
+ writer = function (par, debug) {
784
+ const XMLWriter = require('xml-writer');
785
+ let xw = par instanceof XMLWriter ? par : new XMLWriter(par);
786
+
787
+ xw.startElement(this.#name);
788
+ for (let [key, val] of Object.entries(this.#attributes))
789
+ xw.writeAttribute(key, val);
790
+ if (this.#text !== null)
791
+ xw.text(this.#text);
792
+ this.#addChild(xw, this.#children, this, debug);
793
+ if (!debug || Object.isSealed(this)) xw.endElement();
794
+ return xw;
795
+ };
796
+
797
+ /**
798
+ * Returns attribute value or `null` if not found.<br>
799
+ * If more than one matches the condition, then it returns object as [attribute()](#attribute)
800
+ * @param {AttributeCondition} [condition] - Optional condition to select attribute
801
+ * @returns {?string|number|object} - The value of the attribute or `null` if the does not exist
802
+ */
803
+ attr = function (condition) {
804
+ let attr = this.attribute(condition);
805
+ if (attr === null)
806
+ return null;
807
+
808
+ return Object.keys(attr).length === 1 ? attr[Object.keys(attr)[0]] : attr;
809
+ };
810
+
811
+ /**
812
+ * Returns all attributes of the element
813
+ * @returns {object} All XML Attributes
814
+ */
815
+ get attributes() {
816
+ return this.#attributes;
817
+ }
818
+
819
+ /**
820
+ * Check if the attribute exist or not
821
+ * @param {string} name - The name of the attribute
822
+ * @returns {boolean} - Returns `true` if the attribute exists, else `false`
823
+ */
824
+ hasAttribute = function (name) {
825
+ return this.#attributes[name] !== undefined;
826
+ };
827
+
828
+ /**
829
+ * Retrieve or update XML attribute. For update, the condition must be a string, i.e. must match to one attribute only.
830
+ * @param {AttributeCondition} [condition] - Optional condition to select attributes
831
+ * @param {string|number|bigint|boolean} [value] - New value of the attribute.<br>If `undefined` then existing attributes is returned.
832
+ * @returns {object} Attributes or `null` if no matching attribute found
833
+ * @example attribute((name, val) => { return name === 'age' && val > 50})
834
+ * attribute((name) => { return ['firstName', 'lastName'].includes(name) })
835
+ * attribute('firstName')
836
+ * attribute(/name/i)
837
+ */
838
+ attribute = function (condition, value) {
839
+ if (value === undefined) {
840
+ let attr;
841
+ if (condition === undefined) {
842
+ attr = this.#attributes;
843
+ } else if (typeof condition === 'function') {
844
+ attr = Object.fromEntries(Object.entries(this.#attributes).filter(([key, val]) => condition(key, val)));
845
+ } else if (typeof condition === 'string') {
846
+ attr = this.attribute(key => key === condition);
847
+ } else if (condition instanceof RegExp) {
848
+ attr = this.attribute(key => condition.test(key));
849
+ } else if (condition instanceof Twig) {
850
+ throw new UnsupportedCondition(condition, ['string', 'RegEx', 'function']);
851
+ } else {
852
+ return this.attribute();
853
+ }
854
+ return attr === null || Object.keys(attr).length == 0 ? null : attr;
855
+ } else if (typeof condition === 'string') {
856
+ if (typeof value === 'string')
857
+ this.#attributes[condition] = value;
858
+ else if (['number', 'bigint', 'boolean'].includes(typeof value))
859
+ this.#attributes[condition] = value.toString();
860
+ else
861
+ throw new UnsupportedType(value);
862
+ } else {
863
+ console.warn('Condition must be a `string` if you like to update an attribute');
864
+ }
865
+ };
866
+
867
+ /**
868
+ * Delete the attribute
869
+ * @param {string} name - The attribute name
870
+ */
871
+ deleteAttribute = function (name) {
872
+ delete this.#attributes[name];
873
+ };
874
+
875
+ /**
876
+ * Returns the root object
877
+ * @returns {Twig} The root element of XML tree
878
+ */
879
+ root = function () {
880
+ if (this.isRoot) {
881
+ return this;
882
+ } else {
883
+ let ret = this.#parent;
884
+ while (!ret.isRoot) {
885
+ ret = ret.#parent;
886
+ }
887
+ return ret;
888
+ }
889
+ };
890
+
891
+ /**
892
+ * Returns the parent element or null if root element
893
+ * @returns {Twig} The parament element
894
+ */
895
+ parent = function () {
896
+ return this.isRoot ? null : this.#parent;
897
+ };
898
+
899
+ /**
900
+ * @returns {Twig} - The current element
901
+ */
902
+ self = function () {
903
+ return this;
904
+ };
905
+
906
+ /**
907
+ * Common function to filter Twig elements from array
908
+ * @param {Twig|Twig[]} elements - Array of elements you like to filter or a single element
909
+ * @param {ElementCondition} [condition] - The filter condition
910
+ * @returns {Twig[]} List of matching elements or empty array
911
+ */
912
+ filterElements(elements, condition) {
913
+ if (!Array.isArray(elements))
914
+ return this.filterElements([elements], condition);
915
+
916
+ if (condition !== undefined) {
917
+ if (typeof condition === 'string') {
918
+ return elements.filter(x => x.name === condition);
919
+ } else if (condition instanceof RegExp) {
920
+ return elements.filter(x => condition.test(x.name));
921
+ } else if (condition instanceof Twig) {
922
+ return elements.filter(x => Object.is(x, condition));
923
+ } else if (typeof condition === 'function') {
924
+ return elements.filter(x => condition(x.name, x));
925
+ }
926
+ }
927
+
928
+ return elements;
929
+ }
930
+
931
+ /**
932
+ * Common function to filter Twig element
933
+ * @param {Twig} element - Element you like to filter
934
+ * @param {ElementCondition} [condition] - The filter condition
935
+ * @returns {boolean} `true` if the condition matches
936
+ */
937
+ testElement(element, condition) {
938
+ if (condition === undefined) {
939
+ return true;
940
+ } else if (typeof condition === 'string') {
941
+ return element.name === condition;
942
+ } else if (condition instanceof RegExp) {
943
+ return condition.test(element.name);
944
+ } else if (condition instanceof Twig) {
945
+ return Object.is(element, condition);
946
+ } else if (typeof condition === 'function') {
947
+ return condition(element.name, element);
948
+ }
949
+ return false;
950
+ }
951
+
952
+ /**
953
+ * All children, optionally matching `condition` of the current element or empty array
954
+ * @param {ElementCondition} [condition] - Optional condition
955
+ * @returns {Twig[]}
956
+ */
957
+ children = function (condition) {
958
+ return this.filterElements(this.#children, condition);
959
+ };
960
+
961
+ /**
962
+ * Returns the next matching element.
963
+ * @param {ElementCondition} [condition] - Optional condition
964
+ * @returns {?Twig} - The next element
965
+ * @see https://www.w3.org/TR/xpath-datamodel-31/#document-order
966
+ */
967
+ next = function (condition) {
968
+ if (this === null)
969
+ return null;
970
+
971
+ let elt;
972
+ if (this.hasChildren) {
973
+ elt = this.#children[0];
974
+ } else {
975
+ elt = this.nextSibling();
976
+ if (elt === null) {
977
+ elt = this.#parent;
978
+ elt = elt.nextSibling();
979
+ }
980
+ }
981
+ if (elt === null)
982
+ return null;
983
+
984
+ return this.testElement(elt, condition) ? elt : elt.next(condition);
985
+ };
986
+
987
+ /**
988
+ * Returns the previous matching element.
989
+ * @param {ElementCondition} [condition] - Optional condition
990
+ * @returns {?Twig} - The previous element
991
+ * @see https://www.w3.org/TR/xpath-datamodel-31/#document-order
992
+ */
993
+ previous = function (condition) {
994
+ if (this === null || this.isRoot)
995
+ return null;
996
+
997
+ let elt = this.prevSibling();
998
+ if (elt === null) {
999
+ elt = this.parent();
1000
+ } else {
1001
+ elt = elt.descendantOrSelf();
1002
+ elt = elt[elt.length - 1];
1003
+ }
1004
+
1005
+ return this.testElement(elt, condition) ? elt : elt.previous(condition);
1006
+ };
1007
+
1008
+ /**
1009
+ * Returns the first matching element. This is usually the root element
1010
+ * @param {ElementCondition} [condition] - Optional condition
1011
+ * @returns {?Twig} - The first element
1012
+ */
1013
+ first = function (condition) {
1014
+ if (this === null)
1015
+ return null;
1016
+ return this.testElement(this.root(), condition) ? this.root() : this.root().next(condition);
1017
+ };
1018
+
1019
+ /**
1020
+ * Returns the last matching element.
1021
+ * @param {ElementCondition} [condition] - Optional condition
1022
+ * @returns {?Twig} - The last element
1023
+ */
1024
+ last = function (condition) {
1025
+ if (this === null)
1026
+ return null;
1027
+
1028
+ let elt = this.root();
1029
+ if (this.root().hasChildren) {
1030
+ elt = this.root().#children[this.root().#children.length - 1];
1031
+ while (elt.hasChildren)
1032
+ elt = elt.children()[elt.children().length - 1];
1033
+ }
1034
+
1035
+ return this.testElement(elt, condition) ? elt : elt.previous(condition);
1036
+ };
1037
+
1038
+ /**
1039
+ * Check if the element is the first child of the parent
1040
+ * @returns {boolean} `true` if the first child else `false`
1041
+ */
1042
+ get isFirstChild() {
1043
+ if (this.isRoot) {
1044
+ return false;
1045
+ } else {
1046
+ return this.index === 0;
1047
+ }
1048
+ }
1049
+
1050
+ /**
1051
+ * Check if the element is the last child of the parent
1052
+ * @returns {boolean} `true` if the last child else `false`
1053
+ */
1054
+ get isLastChild() {
1055
+ if (this.isRoot) {
1056
+ return false;
1057
+ } else {
1058
+ return this.index === this.#parent.#children.length - 1;
1059
+ }
1060
+ }
1061
+
1062
+ /**
1063
+ * Returns descendants (children, grandchildren, etc.) of the current element
1064
+ * @param {ElementCondition} [condition] - Optional condition
1065
+ * @returns {Twig[]} - Array of descendants or empty array
1066
+ */
1067
+ descendant = function (condition) {
1068
+ let elts = [];
1069
+ for (let c of this.#children) {
1070
+ elts.push(c);
1071
+ elts = elts.concat(c.descendant());
1072
+ }
1073
+ return this.filterElements(elts, condition);
1074
+ };
1075
+
1076
+ /**
1077
+ * Returns descendants (children, grandchildren, etc.) of the current element and the current element itself
1078
+ * @param {ElementCondition} [condition] - Optional condition
1079
+ * @returns {Twig[]} - Array of descendants or empty array
1080
+ */
1081
+ descendantOrSelf = function (condition) {
1082
+ let elts = [this];
1083
+ for (let c of this.#children) {
1084
+ elts.push(c);
1085
+ elts = elts.concat(c.descendant());
1086
+ }
1087
+ return this.filterElements(elts, condition);
1088
+ };
1089
+
1090
+ /**
1091
+ * Returns ancestors (parent, grandparent, etc.) of the current element
1092
+ * @param {ElementCondition} [condition] - Optional condition
1093
+ * @returns {Twig[]} - Array of ancestors or empty array
1094
+ */
1095
+ ancestor = function (condition) {
1096
+ let elts = [];
1097
+ if (!this.isRoot) {
1098
+ let parent = this.#parent;
1099
+ elts.push(parent);
1100
+ while (!parent.isRoot) {
1101
+ parent = parent.#parent;
1102
+ elts.push(parent);
1103
+ }
1104
+ }
1105
+ return this.filterElements(elts, condition);
1106
+ };
1107
+
1108
+ /**
1109
+ * Returns ancestors (parent, grandparent, etc.) of the current element and the current element itself
1110
+ * @param {ElementCondition} [condition] - Optional condition
1111
+ * @returns {Twig[]} - Array of ancestors or empty array
1112
+ */
1113
+ ancestorOrSelf = function (condition) {
1114
+ let elts = [this];
1115
+ if (!this.isRoot) {
1116
+ let parent = this.#parent;
1117
+ elts.push(parent);
1118
+ while (!parent.isRoot) {
1119
+ parent = parent.#parent;
1120
+ elts.push(parent);
1121
+ }
1122
+ }
1123
+ return this.filterElements(elts, condition);
1124
+ };
1125
+
1126
+ /**
1127
+ * Returns all sibling element of the current element
1128
+ * @param {ElementCondition} [condition] - Optional condition
1129
+ * @returns {Twig[]} - Array of sibling or empty array
1130
+ */
1131
+ sibling = function (condition) {
1132
+ let elts = [];
1133
+ if (!this.isRoot) {
1134
+ elts = this.#parent.#children.filter(x => !Object.is(x, this));
1135
+ }
1136
+ return this.filterElements(elts, condition);
1137
+ };
1138
+
1139
+ /**
1140
+ * Returns all sibling element of the current element and the current element itself
1141
+ * @param {ElementCondition} [condition] - Optional condition
1142
+ * @returns {Twig[]} - Array of sibling or empty array
1143
+ */
1144
+ siblingOrSelf = function (condition) {
1145
+ let elts = [this];
1146
+ if (!this.isRoot) {
1147
+ elts = this.#parent.#children;
1148
+ }
1149
+ return this.filterElements(elts, condition);
1150
+ };
1151
+
1152
+ /**
1153
+ * Returns all following sibling element of the current element
1154
+ * @param {ElementCondition} [condition] - Optional condition
1155
+ * @returns {Twig[]} - Array of sibling or empty array
1156
+ */
1157
+ followingSibling = function (condition) {
1158
+ let elts = [];
1159
+ if (!this.isRoot) {
1160
+ elts = this.#parent.#children.slice(this.index + 1);
1161
+ }
1162
+ return this.filterElements(elts, condition);
1163
+ };
1164
+
1165
+ /**
1166
+ * Returns all preceding sibling element of the current element
1167
+ * @param {ElementCondition} [condition] - Optional condition
1168
+ * @returns {Twig[]} - Array of sibling or empty array
1169
+ */
1170
+ precedingSibling = function (condition) {
1171
+ let elts = [];
1172
+ if (!this.isRoot) {
1173
+ elts = this.#parent.#children.slice(0, this.index);
1174
+ }
1175
+ return this.filterElements(elts, condition);
1176
+ };
1177
+
1178
+ /**
1179
+ * Returns next sibling element of the current element
1180
+ * @param {ElementCondition} [condition] - Optional condition
1181
+ * @returns {?Twig} - The next sibling or `null`
1182
+ */
1183
+ nextSibling = function (condition) {
1184
+ let elt;
1185
+ if (!this.isRoot)
1186
+ elt = this.#parent.#children[this.index + 1];
1187
+ if (elt === undefined)
1188
+ return null;
1189
+
1190
+ return this.testElement(elt, condition) ? elt : elt.nextSibling(condition);
1191
+ };
1192
+
1193
+ /**
1194
+ * Returns previous sibling element of the current element
1195
+ * @param {ElementCondition} [condition] - Optional condition
1196
+ * @returns {?Twig} - The previous sibling or `null`
1197
+ */
1198
+ prevSibling = function (condition) {
1199
+ if (!this.isRoot && this.index > 0) {
1200
+ let elt = this.#parent.#children[this.index - 1];
1201
+ return this.testElement(elt, condition) ? elt : elt.prevSibling(condition);
1202
+ } else {
1203
+ return null;
1204
+ }
1205
+ };
1206
+
1207
+ /**
1208
+ * Find a specific element within current element. Same as `.descendant(condition)[0]`
1209
+ * @param {ElementCondition} condition - Find condition
1210
+ * @returns {?Twig} - First matching element or `null`
1211
+ */
1212
+ find = function (condition) {
1213
+ let children = this.filterElements(this.#children, condition);
1214
+ if (children.length > 0)
1215
+ return children[0];
1216
+
1217
+ for (let child of this.#children) {
1218
+ let ret = child.find(condition);
1219
+ if (ret !== null)
1220
+ return ret;
1221
+ }
1222
+ return null;
1223
+ };
1224
+
1225
+ /**
1226
+ * Add a new element in the current element
1227
+ * @param {string} name - The tag name
1228
+ * @param {?string} [text] - Text of the element
1229
+ * @param {?object} [attributes] - Element attributes
1230
+ * @param {name|number} [position] - Position name 'first', 'last' or the position in the `children`
1231
+ * @returns {Twig} - The appended element
1232
+ */
1233
+ addElement = function (parser, name, text, attributes, position) {
1234
+ let twig = new Twig(parser, name, this, attributes ?? {}, position ?? 'last');
1235
+ twig.#text = text ?? null;
1236
+ twig.close();
1237
+ return twig;
1238
+ };
1239
+
1240
+ /**
1241
+ * Deletes the current element from tree, same as `purge()`. The root object cannot be deleted.
1242
+ */
1243
+ delete = function () {
1244
+ this.purge();
1245
+ };
1246
+
1247
+
1248
+ }
1249
+
1250
+
1251
+ /**
1252
+ * Generic error for non implemented feature
1253
+ * @exception NotImplementedYet
1254
+ */
1255
+ class NotImplementedYet extends TypeError {
1256
+ constructor() {
1257
+ super(`Net yet implemented`);
1258
+ }
1259
+ }
1260
+
1261
+
1262
+ /**
1263
+ * Error for unsupported parser
1264
+ * @exception UnsupportedParser
1265
+ */
1266
+ class UnsupportedParser extends TypeError {
1267
+ /**
1268
+ * @param {string} t Parser type
1269
+ */
1270
+ constructor(t) {
1271
+ super(`Parser '${t}' is not supported. Use 'expat', 'sax' (default)`);
1272
+ }
1273
+ }
1274
+
1275
+ /**
1276
+ * Generic error for unsupported data type
1277
+ * @exception UnsupportedType
1278
+ */
1279
+ class UnsupportedType extends TypeError {
1280
+ /**
1281
+ * @param {*} t Parameter which was used
1282
+ */
1283
+ constructor(t) {
1284
+ super(`Type ${typeof t} is not supported in XML`);
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * Generic error for unsupported condition
1290
+ * @exception UnsupportedCondition
1291
+ */
1292
+ class UnsupportedCondition extends TypeError {
1293
+ /**
1294
+ * @param {*} condition The condition value
1295
+ * @param {string[]} t List of supported data types
1296
+ */
1297
+ constructor(condition, t) {
1298
+ super(`Condition '${JSON.stringify(condition)}' must be a ${t.map(x => `'${x}'`).join(' or ')}`);
1299
+ }
1300
+ }
1301
+
1302
+
1303
+ module.exports = { createParser, Twig, Any, Root };