xml-twig 1.7.11 → 1.7.13

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.
Files changed (3) hide show
  1. package/doc/twig.md +1647 -1695
  2. package/package.json +4 -4
  3. package/twig.js +1303 -1303
package/twig.js CHANGED
@@ -1,1303 +1,1303 @@
1
- /**
2
- * @version: 1.7.11
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 };
1
+ /**
2
+ * @version: 1.7.13
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 };