• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

NaturalIntelligence / fast-xml-parser / 24387099185

14 Apr 2026 07:41AM UTC coverage: 97.617% (-0.08%) from 97.694%
24387099185

push

github

amitguptagwl
use @nodable/entities to replace entities

1165 of 1214 branches covered (95.96%)

21 of 21 new or added lines in 4 files covered. (100.0%)

12 existing lines in 2 files now uncovered.

9298 of 9525 relevant lines covered (97.62%)

468518.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.71
/src/xmlparser/OrderedObjParser.js
1
'use strict';
5✔
2
///@ts-check
5✔
3

5✔
4
import { getAllMatches, isExist, DANGEROUS_PROPERTY_NAMES, criticalProperties } from '../util.js';
5✔
5
import xmlNode from './xmlNode.js';
5✔
6
import DocTypeReader from './DocTypeReader.js';
5✔
7
import toNumber from "strnum";
5✔
8
import getIgnoreAttributesFn from "../ignoreAttributes.js";
5✔
9
import { Expression, Matcher } from 'path-expression-matcher';
5✔
10
import { ExpressionSet } from 'path-expression-matcher';
5✔
11
import EntityReplacer, { COMMON_HTML, NUMERIC_ENTITIES } from '@nodable/entities';
5✔
12

5✔
13
// const regx =
5✔
14
//   '<((!\\[CDATA\\[([\\s\\S]*?)(]]>))|((NAME:)?(NAME))([^>]*)>|((\\/)(NAME)\\s*>))([^<]*)'
5✔
15
//   .replace(/NAME/g, util.nameRegexp);
5✔
16

5✔
17
//const tagsRegx = new RegExp("<(\\/?[\\w:\\-\._]+)([^>]*)>(\\s*"+cdataRegx+")*([^<]+)?","g");
5✔
18
//const tagsRegx = new RegExp("<(\\/?)((\\w*:)?([\\w:\\-\._]+))([^>]*)>([^<]*)("+cdataRegx+"([^<]*))*([^<]+)?","g");
5✔
19

5✔
20
// Helper functions for attribute and namespace handling
5✔
21

5✔
22
/**
5✔
23
 * Extract raw attributes (without prefix) from prefixed attribute map
5✔
24
 * @param {object} prefixedAttrs - Attributes with prefix from buildAttributesMap
5✔
25
 * @param {object} options - Parser options containing attributeNamePrefix
5✔
26
 * @returns {object} Raw attributes for matcher
5✔
27
 */
5✔
28
function extractRawAttributes(prefixedAttrs, options) {
760✔
29
  if (!prefixedAttrs) return {};
760!
30

760✔
31
  // Handle attributesGroupName option
760✔
32
  const attrs = options.attributesGroupName
760✔
33
    ? prefixedAttrs[options.attributesGroupName]
760✔
34
    : prefixedAttrs;
760✔
35

760✔
36
  if (!attrs) return {};
760!
37

760✔
38
  const rawAttrs = {};
760✔
39
  for (const key in attrs) {
760✔
40
    // Remove the attribute prefix to get raw name
1,445✔
41
    if (key.startsWith(options.attributeNamePrefix)) {
1,445✔
42
      const rawName = key.substring(options.attributeNamePrefix.length);
1,200✔
43
      rawAttrs[rawName] = attrs[key];
1,200✔
44
    } else {
1,445✔
45
      // Attribute without prefix (shouldn't normally happen, but be safe)
245✔
46
      rawAttrs[key] = attrs[key];
245✔
47
    }
245✔
48
  }
1,445✔
49
  return rawAttrs;
760✔
50
}
760✔
51

5✔
52
/**
5✔
53
 * Extract namespace from raw tag name
5✔
54
 * @param {string} rawTagName - Tag name possibly with namespace (e.g., "soap:Envelope")
5✔
55
 * @returns {string|undefined} Namespace or undefined
5✔
56
 */
5✔
57
function extractNamespace(rawTagName) {
6,380✔
58
  if (!rawTagName || typeof rawTagName !== 'string') return undefined;
6,380!
59

6,380✔
60
  const colonIndex = rawTagName.indexOf(':');
6,380✔
61
  if (colonIndex !== -1 && colonIndex > 0) {
6,380✔
62
    const ns = rawTagName.substring(0, colonIndex);
150✔
63
    // Don't treat xmlns as a namespace
150✔
64
    if (ns !== 'xmlns') {
150✔
65
      return ns;
150✔
66
    }
150✔
67
  }
150✔
68
  return undefined;
6,230✔
69
}
6,380✔
70

5✔
71
export default class OrderedObjParser {
5✔
72
  constructor(options) {
5✔
73
    this.options = options;
1,690✔
74
    this.currentNode = null;
1,690✔
75
    this.tagsNodeStack = [];
1,690✔
76
    this.parseXml = parseXml;
1,690✔
77
    this.parseTextData = parseTextData;
1,690✔
78
    this.resolveNameSpace = resolveNameSpace;
1,690✔
79
    this.buildAttributesMap = buildAttributesMap;
1,690✔
80
    this.isItStopNode = isItStopNode;
1,690✔
81
    this.replaceEntitiesValue = replaceEntitiesValue;
1,690✔
82
    this.readStopNodeData = readStopNodeData;
1,690✔
83
    this.saveTextToParentTag = saveTextToParentTag;
1,690✔
84
    this.addChild = addChild;
1,690✔
85
    this.ignoreAttributesFn = getIgnoreAttributesFn(this.options.ignoreAttributes)
1,690✔
86
    this.entityExpansionCount = 0;
1,690✔
87
    this.currentExpandedLength = 0;
1,690✔
88

1,690✔
89
    this.entityReplacer = new EntityReplacer({
1,690✔
90
      default: true,
1,690✔
91
      // amp:     true,
1,690✔
92
      system: this.options.htmlEntities ? { ...COMMON_HTML, ...NUMERIC_ENTITIES } : {},
1,690✔
93
      maxTotalExpansions: this.options.processEntities.maxTotalExpansions,
1,690✔
94
      maxExpandedLength: this.options.processEntities.maxExpandedLength,
1,690✔
95
      applyLimitsTo: "all",
1,690✔
96
      //postCheck: resolved => resolved
1,690✔
97
    });
1,690✔
98

1,690✔
99
    // Initialize path matcher for path-expression-matcher
1,690✔
100
    this.matcher = new Matcher();
1,690✔
101

1,690✔
102
    // Live read-only proxy of matcher — PEM creates and caches this internally.
1,690✔
103
    // All user callbacks receive this instead of the mutable matcher.
1,690✔
104
    this.readonlyMatcher = this.matcher.readOnly();
1,690✔
105

1,690✔
106
    // Flag to track if current node is a stop node (optimization)
1,690✔
107
    this.isCurrentNodeStopNode = false;
1,690✔
108

1,690✔
109
    // Pre-compile stopNodes expressions
1,690✔
110
    this.stopNodeExpressionsSet = new ExpressionSet();
1,690✔
111
    const stopNodesOpts = this.options.stopNodes;
1,690✔
112
    if (stopNodesOpts && stopNodesOpts.length > 0) {
1,690✔
113
      for (let i = 0; i < stopNodesOpts.length; i++) {
670✔
114
        const stopNodeExp = stopNodesOpts[i];
1,255✔
115
        if (typeof stopNodeExp === 'string') {
1,255✔
116
          // Convert string to Expression object
200✔
117
          this.stopNodeExpressionsSet.add(new Expression(stopNodeExp));
200✔
118
        } else if (stopNodeExp instanceof Expression) {
1,255✔
119
          // Already an Expression object
1,055✔
120
          this.stopNodeExpressionsSet.add(stopNodeExp);
1,055✔
121
        }
1,055✔
122
      }
1,255✔
123
      this.stopNodeExpressionsSet.seal();
670✔
124
    }
670✔
125
  }
1,690✔
126

5✔
127
}
5✔
128

5✔
129

5✔
130
/**
5✔
131
 * @param {string} val
5✔
132
 * @param {string} tagName
5✔
133
 * @param {string|Matcher} jPath - jPath string or Matcher instance based on options.jPath
5✔
134
 * @param {boolean} dontTrim
5✔
135
 * @param {boolean} hasAttributes
5✔
136
 * @param {boolean} isLeafNode
5✔
137
 * @param {boolean} escapeEntities
5✔
138
 */
5✔
139
function parseTextData(val, tagName, jPath, dontTrim, hasAttributes, isLeafNode, escapeEntities) {
6,695✔
140
  const options = this.options;
6,695✔
141
  if (val !== undefined) {
6,695✔
142
    if (options.trimValues && !dontTrim) {
6,695✔
143
      val = val.trim();
6,450✔
144
    }
6,450✔
145
    if (val.length > 0) {
6,695✔
146
      if (!escapeEntities) val = this.replaceEntitiesValue(val, tagName, jPath);
2,010✔
147

1,975✔
148
      // Pass jPath string or matcher based on options.jPath setting
1,975✔
149
      const jPathOrMatcher = options.jPath ? jPath.toString() : jPath;
2,010✔
150
      const newval = options.tagValueProcessor(tagName, val, jPathOrMatcher, hasAttributes, isLeafNode);
2,010✔
151
      if (newval === null || newval === undefined) {
2,010✔
152
        //don't parse
50✔
153
        return val;
50✔
154
      } else if (typeof newval !== typeof val || newval !== val) {
2,010✔
155
        //overwrite
20✔
156
        return newval;
20✔
157
      } else if (options.trimValues) {
1,925✔
158
        return parseValue(val, options.parseTagValue, options.numberParseOptions);
1,845✔
159
      } else {
1,905✔
160
        const trimmedVal = val.trim();
60✔
161
        if (trimmedVal === val) {
60✔
162
          return parseValue(val, options.parseTagValue, options.numberParseOptions);
15✔
163
        } else {
60✔
164
          return val;
45✔
165
        }
45✔
166
      }
60✔
167
    }
2,010✔
168
  }
6,695✔
169
}
6,695✔
170

5✔
171
function resolveNameSpace(tagname) {
2,880✔
172
  if (this.options.removeNSPrefix) {
2,880✔
173
    const tags = tagname.split(':');
220✔
174
    const prefix = tagname.charAt(0) === '/' ? '/' : '';
220!
175
    if (tags[0] === 'xmlns') {
220✔
176
      return '';
70✔
177
    }
70✔
178
    if (tags.length === 2) {
220✔
179
      tagname = prefix + tags[1];
60✔
180
    }
60✔
181
  }
220✔
182
  return tagname;
2,810✔
183
}
2,880✔
184

5✔
185
//TODO: change regex to capture NS
5✔
186
//const attrsRegx = new RegExp("([\\w\\-\\.\\:]+)\\s*=\\s*(['\"])((.|\n)*?)\\2","gm");
5✔
187
const attrsRegx = new RegExp('([^\\s=]+)\\s*(=\\s*([\'"])([\\s\\S]*?)\\3)?', 'gm');
5✔
188

5✔
189
function buildAttributesMap(attrStr, jPath, tagName) {
1,180✔
190
  const options = this.options;
1,180✔
191
  if (options.ignoreAttributes !== true && typeof attrStr === 'string') {
1,180✔
192
    // attrStr = attrStr.replace(/\r?\n/g, ' ');
990✔
193
    //attrStr = attrStr || attrStr.trim();
990✔
194

990✔
195
    const matches = getAllMatches(attrStr, attrsRegx);
990✔
196
    const len = matches.length; //don't make it inline
990✔
197
    const attrs = {};
990✔
198

990✔
199
    // Pre-process values once: trim + entity replacement
990✔
200
    // Reused in both matcher update and second pass
990✔
201
    const processedVals = new Array(len);
990✔
202
    let hasRawAttrs = false;
990✔
203
    const rawAttrsForMatcher = {};
990✔
204

990✔
205
    for (let i = 0; i < len; i++) {
990✔
206
      const attrName = this.resolveNameSpace(matches[i][1]);
1,440✔
207
      const oldVal = matches[i][4];
1,440✔
208

1,440✔
209
      if (attrName.length && oldVal !== undefined) {
1,440✔
210
        let val = oldVal;
1,315✔
211
        if (options.trimValues) val = val.trim();
1,315✔
212
        val = this.replaceEntitiesValue(val, tagName, this.readonlyMatcher);
1,315✔
213
        processedVals[i] = val;
1,315✔
214

1,315✔
215
        rawAttrsForMatcher[attrName] = val;
1,315✔
216
        hasRawAttrs = true;
1,315✔
217
      }
1,315✔
218
    }
1,440✔
219

990✔
220
    // Update matcher ONCE before second pass, if applicable
990✔
221
    if (hasRawAttrs && typeof jPath === 'object' && jPath.updateCurrent) {
990✔
222
      jPath.updateCurrent(rawAttrsForMatcher);
870✔
223
    }
870✔
224

990✔
225
    // Hoist toString() once — path doesn't change during attribute processing
990✔
226
    const jPathStr = options.jPath ? jPath.toString() : this.readonlyMatcher;
990✔
227

990✔
228
    // Second pass: apply processors, build final attrs
990✔
229
    let hasAttrs = false;
990✔
230
    for (let i = 0; i < len; i++) {
990✔
231
      const attrName = this.resolveNameSpace(matches[i][1]);
1,440✔
232

1,440✔
233
      if (this.ignoreAttributesFn(attrName, jPathStr)) continue;
1,440✔
234

1,385✔
235
      let aName = options.attributeNamePrefix + attrName;
1,385✔
236

1,385✔
237
      if (attrName.length) {
1,440✔
238
        if (options.transformAttributeName) {
1,350✔
239
          aName = options.transformAttributeName(aName);
20✔
240
        }
20✔
241
        aName = sanitizeName(aName, options);
1,350✔
242

1,350✔
243
        if (matches[i][4] !== undefined) {
1,350✔
244
          // Reuse already-processed value — no double entity replacement
1,245✔
245
          const oldVal = processedVals[i];
1,245✔
246

1,245✔
247
          const newVal = options.attributeValueProcessor(attrName, oldVal, jPathStr);
1,245✔
248
          if (newVal === null || newVal === undefined) {
1,245!
249
            attrs[aName] = oldVal;
×
250
          } else if (typeof newVal !== typeof oldVal || newVal !== oldVal) {
1,245✔
251
            attrs[aName] = newVal;
5✔
252
          } else {
1,245✔
253
            attrs[aName] = parseValue(oldVal, options.parseAttributeValue, options.numberParseOptions);
1,240✔
254
          }
1,240✔
255
          hasAttrs = true;
1,245✔
256
        } else if (options.allowBooleanAttributes) {
1,350✔
257
          attrs[aName] = true;
80✔
258
          hasAttrs = true;
80✔
259
        }
80✔
260
      }
1,350✔
261
    }
1,440✔
262

975✔
263
    if (!hasAttrs) return;
990✔
264

880✔
265
    if (options.attributesGroupName) {
990✔
266
      const attrCollection = {};
60✔
267
      attrCollection[options.attributesGroupName] = attrs;
60✔
268
      return attrCollection;
60✔
269
    }
60✔
270
    return attrs;
820✔
271
  }
820✔
272
}
1,180✔
273
const parseXml = function (xmlData) {
5✔
274
  xmlData = xmlData.replace(/\r\n?/g, "\n"); //TODO: remove this line
1,690✔
275
  const xmlObj = new xmlNode('!xml');
1,690✔
276
  let currentNode = xmlObj;
1,690✔
277
  let textData = "";
1,690✔
278

1,690✔
279
  // Reset matcher for new document
1,690✔
280
  this.matcher.reset();
1,690✔
281

1,690✔
282
  // Reset entity expansion counters for this document
1,690✔
283
  this.entityExpansionCount = 0;
1,690✔
284
  this.currentExpandedLength = 0;
1,690✔
285
  const options = this.options;
1,690✔
286
  const docTypeReader = new DocTypeReader(options.processEntities);
1,690✔
287
  const xmlLen = xmlData.length;
1,690✔
288
  for (let i = 0; i < xmlLen; i++) {//for each char in XML data
1,690✔
289
    const ch = xmlData[i];
325,615✔
290
    if (ch === '<') {
325,615✔
291
      // const nextIndex = i+1;
11,230✔
292
      // const _2ndChar = xmlData[nextIndex];
11,230✔
293
      const c1 = xmlData.charCodeAt(i + 1);
11,230✔
294
      if (c1 === 47) {//Closing Tag '/'
11,230✔
295
        const closeIndex = findClosingIndex(xmlData, ">", i, "Closing Tag is not closed.")
4,080✔
296
        let tagName = xmlData.substring(i + 2, closeIndex).trim();
4,080✔
297

4,080✔
298
        if (options.removeNSPrefix) {
4,080✔
299
          const colonIndex = tagName.indexOf(":");
115✔
300
          if (colonIndex !== -1) {
115✔
301
            tagName = tagName.substr(colonIndex + 1);
50✔
302
          }
50✔
303
        }
115✔
304

4,075✔
305
        tagName = transformTagName(options.transformTagName, tagName, "", options).tagName;
4,075✔
306

4,075✔
307
        if (currentNode) {
4,075✔
308
          textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
4,075✔
309
        }
4,075✔
310

4,040✔
311
        //check if last tag of nested tag was unpaired tag
4,040✔
312
        const lastTagName = this.matcher.getCurrentTag();
4,040✔
313
        if (tagName && options.unpairedTagsSet.has(tagName)) {
4,080✔
314
          throw new Error(`Unpaired tag can not be used as closing tag: </${tagName}>`);
5✔
315
        }
5✔
316
        if (lastTagName && options.unpairedTagsSet.has(lastTagName)) {
4,080!
317
          // Pop the unpaired tag
×
318
          this.matcher.pop();
×
319
          this.tagsNodeStack.pop();
×
320
        }
×
321
        // Pop the closing tag
4,035✔
322
        this.matcher.pop();
4,035✔
323
        this.isCurrentNodeStopNode = false; // Reset flag when closing tag
4,035✔
324

4,035✔
325
        currentNode = this.tagsNodeStack.pop();//avoid recursion, set the parent tag scope
4,035✔
326
        textData = "";
4,035✔
327
        i = closeIndex;
4,035✔
328
      } else if (c1 === 63) { //'?'
11,230✔
329

245✔
330
        let tagData = readTagExp(xmlData, i, false, "?>");
245✔
331
        if (!tagData) throw new Error("Pi Tag is not closed.");
245✔
332

235✔
333
        textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
235✔
334
        if ((options.ignoreDeclaration && tagData.tagName === "?xml") || options.ignorePiTags) {
245✔
335
          //do nothing
25✔
336
        } else {
245✔
337

210✔
338
          const childNode = new xmlNode(tagData.tagName);
210✔
339
          childNode.add(options.textNodeName, "");
210✔
340

210✔
341
          if (tagData.tagName !== tagData.tagExp && tagData.attrExpPresent) {
210✔
342
            childNode[":@"] = this.buildAttributesMap(tagData.tagExp, this.matcher, tagData.tagName);
200✔
343
          }
200✔
344
          this.addChild(currentNode, childNode, this.readonlyMatcher, i);
210✔
345
        }
210✔
346

235✔
347

235✔
348
        i = tagData.closeIndex + 1;
235✔
349
      } else if (c1 === 33
7,150✔
350
        && xmlData.charCodeAt(i + 2) === 45
6,905✔
351
        && xmlData.charCodeAt(i + 3) === 45) { //'!--'
6,905✔
352
        const endIndex = findClosingIndex(xmlData, "-->", i + 4, "Comment is not closed.")
60✔
353
        if (options.commentPropName) {
60✔
354
          const comment = xmlData.substring(i + 4, endIndex - 2);
25✔
355

25✔
356
          textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
25✔
357

25✔
358
          currentNode.add(options.commentPropName, [{ [options.textNodeName]: comment }]);
25✔
359
        }
25✔
360
        i = endIndex;
55✔
361
      } else if (c1 === 33
6,905✔
362
        && xmlData.charCodeAt(i + 2) === 68) { //'!D'
6,845✔
363
        const result = docTypeReader.readDocType(xmlData, i);
220✔
364
        this.entityReplacer.addInputEntities(result.entities);
220✔
365
        i = result.i;
220✔
366
      } else if (c1 === 33
6,845✔
367
        && xmlData.charCodeAt(i + 2) === 91) { // '!['
6,625✔
368
        const closeIndex = findClosingIndex(xmlData, "]]>", i, "CDATA is not closed.") - 2;
190✔
369
        const tagExp = xmlData.substring(i + 9, closeIndex);
190✔
370

190✔
371
        textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
190✔
372

190✔
373
        let val = this.parseTextData(tagExp, currentNode.tagname, this.readonlyMatcher, true, false, true, true);
190✔
374
        if (val == undefined) val = "";
190✔
375

185✔
376
        //cdata should be set even if it is 0 length string
185✔
377
        if (options.cdataPropName) {
190✔
378
          currentNode.add(options.cdataPropName, [{ [options.textNodeName]: tagExp }]);
35✔
379
        } else {
190✔
380
          currentNode.add(options.textNodeName, val);
150✔
381
        }
150✔
382

185✔
383
        i = closeIndex + 2;
185✔
384
      } else {//Opening tag
6,625✔
385
        let result = readTagExp(xmlData, i, options.removeNSPrefix);
6,435✔
386

6,435✔
387
        // Safety check: readTagExp can return undefined
6,435✔
388
        if (!result) {
6,435!
389
          // Log context for debugging
×
390
          const context = xmlData.substring(Math.max(0, i - 50), Math.min(xmlLen, i + 50));
×
391
          throw new Error(`readTagExp returned undefined at position ${i}. Context: "${context}"`);
×
392
        }
×
393

6,435✔
394
        let tagName = result.tagName;
6,435✔
395
        const rawTagName = result.rawTagName;
6,435✔
396
        let tagExp = result.tagExp;
6,435✔
397
        let attrExpPresent = result.attrExpPresent;
6,435✔
398
        let closeIndex = result.closeIndex;
6,435✔
399

6,435✔
400
        ({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options));
6,435✔
401

6,435✔
402
        if (options.strictReservedNames &&
6,435✔
403
          (tagName === options.commentPropName
6,375✔
404
            || tagName === options.cdataPropName
6,375✔
405
            || tagName === options.textNodeName
6,375✔
406
            || tagName === options.attributesGroupName
6,375✔
407
          )) {
6,435✔
408
          throw new Error(`Invalid tag name: ${tagName}`);
5✔
409
        }
5✔
410

6,380✔
411
        //save text as child node
6,380✔
412
        if (currentNode && textData) {
6,435✔
413
          if (currentNode.tagname !== '!xml') {
4,105✔
414
            //when nested tag is found
3,225✔
415
            textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher, false);
3,225✔
416
          }
3,225✔
417
        }
4,105✔
418

6,380✔
419
        //check if last tag was unpaired tag
6,380✔
420
        const lastTag = currentNode;
6,380✔
421
        if (lastTag && options.unpairedTagsSet.has(lastTag.tagname)) {
6,435!
422
          currentNode = this.tagsNodeStack.pop();
×
423
          this.matcher.pop();
×
424
        }
×
425

6,380✔
426
        // Clean up self-closing syntax BEFORE processing attributes
6,380✔
427
        // This is where tagExp gets the trailing / removed
6,380✔
428
        let isSelfClosing = false;
6,380✔
429
        if (tagExp.length > 0 && tagExp.lastIndexOf("/") === tagExp.length - 1) {
6,435✔
430
          isSelfClosing = true;
280✔
431
          if (tagName[tagName.length - 1] === "/") {
280✔
432
            tagName = tagName.substr(0, tagName.length - 1);
100✔
433
            tagExp = tagName;
100✔
434
          } else {
280✔
435
            tagExp = tagExp.substr(0, tagExp.length - 1);
180✔
436
          }
180✔
437

280✔
438
          // Re-check attrExpPresent after cleaning
280✔
439
          attrExpPresent = (tagName !== tagExp);
280✔
440
        }
280✔
441

6,380✔
442
        // Now process attributes with CLEAN tagExp (no trailing /)
6,380✔
443
        let prefixedAttrs = null;
6,380✔
444
        let rawAttrs = {};
6,380✔
445
        let namespace = undefined;
6,380✔
446

6,380✔
447
        // Extract namespace from rawTagName
6,380✔
448
        namespace = extractNamespace(rawTagName);
6,380✔
449

6,380✔
450
        // Push tag to matcher FIRST (with empty attrs for now) so callbacks see correct path
6,380✔
451
        if (tagName !== xmlObj.tagname) {
6,380✔
452
          this.matcher.push(tagName, {}, namespace);
6,380✔
453
        }
6,380✔
454

6,380✔
455
        // Now build attributes - callbacks will see correct matcher state
6,380✔
456
        if (tagName !== tagExp && attrExpPresent) {
6,435✔
457
          // Build attributes (returns prefixed attributes for the tree)
980✔
458
          // Note: buildAttributesMap now internally updates the matcher with raw attributes
980✔
459
          prefixedAttrs = this.buildAttributesMap(tagExp, this.matcher, tagName);
980✔
460

980✔
461
          if (prefixedAttrs) {
980✔
462
            // Extract raw attributes (without prefix) for our use
760✔
463
            rawAttrs = extractRawAttributes(prefixedAttrs, options);
760✔
464
          }
760✔
465
        }
980✔
466

6,365✔
467
        // Now check if this is a stop node (after attributes are set)
6,365✔
468
        if (tagName !== xmlObj.tagname) {
6,365✔
469
          this.isCurrentNodeStopNode = this.isItStopNode();
6,365✔
470
        }
6,365✔
471

6,365✔
472
        const startIndex = i;
6,365✔
473
        if (this.isCurrentNodeStopNode) {
6,435✔
474
          let tagContent = "";
1,225✔
475

1,225✔
476
          // For self-closing tags, content is empty
1,225✔
477
          if (isSelfClosing) {
1,225✔
478
            i = result.closeIndex;
10✔
479
          }
10✔
480
          //unpaired tag
1,215✔
481
          else if (options.unpairedTagsSet.has(tagName)) {
1,215✔
482
            i = result.closeIndex;
5✔
483
          }
5✔
484
          //normal tag
1,210✔
485
          else {
1,210✔
486
            //read until closing tag is found
1,210✔
487
            const result = this.readStopNodeData(xmlData, rawTagName, closeIndex + 1);
1,210✔
488
            if (!result) throw new Error(`Unexpected end of ${rawTagName}`);
1,210!
489
            i = result.i;
1,210✔
490
            tagContent = result.tagContent;
1,210✔
491
          }
1,210✔
492

1,225✔
493
          const childNode = new xmlNode(tagName);
1,225✔
494

1,225✔
495
          if (prefixedAttrs) {
1,225✔
496
            childNode[":@"] = prefixedAttrs;
20✔
497
          }
20✔
498

1,225✔
499
          // For stop nodes, store raw content as-is without any processing
1,225✔
500
          childNode.add(options.textNodeName, tagContent);
1,225✔
501

1,225✔
502
          this.matcher.pop(); // Pop the stop node tag
1,225✔
503
          this.isCurrentNodeStopNode = false; // Reset flag
1,225✔
504

1,225✔
505
          this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
1,225✔
506
        } else {
6,435✔
507
          //selfClosing tag
5,140✔
508
          if (isSelfClosing) {
5,140✔
509
            ({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options));
270✔
510

270✔
511
            const childNode = new xmlNode(tagName);
270✔
512
            if (prefixedAttrs) {
270✔
513
              childNode[":@"] = prefixedAttrs;
90✔
514
            }
90✔
515
            this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
270✔
516
            this.matcher.pop(); // Pop self-closing tag
270✔
517
            this.isCurrentNodeStopNode = false; // Reset flag
270✔
518
          }
270✔
519
          else if (options.unpairedTagsSet.has(tagName)) {//unpaired tag
4,870✔
520
            const childNode = new xmlNode(tagName);
210✔
521
            if (prefixedAttrs) {
210✔
522
              childNode[":@"] = prefixedAttrs;
90✔
523
            }
90✔
524
            this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
210✔
525
            this.matcher.pop(); // Pop unpaired tag
210✔
526
            this.isCurrentNodeStopNode = false; // Reset flag
210✔
527
            i = result.closeIndex;
210✔
528
            // Continue to next iteration without changing currentNode
210✔
529
            continue;
210✔
530
          }
210✔
531
          //opening tag
4,660✔
532
          else {
4,660✔
533
            const childNode = new xmlNode(tagName);
4,660✔
534
            if (this.tagsNodeStack.length > options.maxNestedTags) {
4,660✔
535
              throw new Error("Maximum nested tags exceeded");
5✔
536
            }
5✔
537
            this.tagsNodeStack.push(currentNode);
4,655✔
538

4,655✔
539
            if (prefixedAttrs) {
4,660✔
540
              childNode[":@"] = prefixedAttrs;
560✔
541
            }
560✔
542
            this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
4,655✔
543
            currentNode = childNode;
4,655✔
544
          }
4,655✔
545
          textData = "";
4,925✔
546
          i = closeIndex;
4,925✔
547
        }
4,925✔
548
      }
6,435✔
549
    } else {
325,615✔
550
      textData += xmlData[i];
314,385✔
551
    }
314,385✔
552
  }
325,615✔
553
  return xmlObj.child;
1,525✔
554
}
1,690✔
555

5✔
556
function addChild(currentNode, childNode, matcher, startIndex) {
6,570✔
557
  // unset startIndex if not requested
6,570✔
558
  if (!this.options.captureMetaData) startIndex = undefined;
6,570✔
559

6,570✔
560
  // Pass jPath string or matcher based on options.jPath setting
6,570✔
561
  const jPathOrMatcher = this.options.jPath ? matcher.toString() : matcher;
6,570✔
562
  const result = this.options.updateTag(childNode.tagname, jPathOrMatcher, childNode[":@"])
6,570✔
563
  if (result === false) {
6,570✔
564
    //do nothing
25✔
565
  } else if (typeof result === "string") {
6,570✔
566
    childNode.tagname = result
6,545✔
567
    currentNode.addChild(childNode, startIndex);
6,545✔
568
  } else {
6,545!
569
    currentNode.addChild(childNode, startIndex);
×
570
  }
×
571
}
6,570✔
572

5✔
573
/**
5✔
574
 * @param {object} val - Entity object with regex and val properties
5✔
575
 * @param {string} tagName - Tag name
5✔
576
 * @param {string|Matcher} jPath - jPath string or Matcher instance based on options.jPath
5✔
577
 */
5✔
578
function replaceEntitiesValue(val, tagName, jPath) {
3,160✔
579
  const entityConfig = this.options.processEntities;
3,160✔
580

3,160✔
581
  if (!entityConfig || !entityConfig.enabled) {
3,160✔
582
    return val;
60✔
583
  }
60✔
584

3,100✔
585
  // Check if tag is allowed to contain entities
3,100✔
586
  if (entityConfig.allowedTags) {
3,160✔
587
    const jPathOrMatcher = this.options.jPath ? jPath.toString() : jPath;
30!
588
    const allowed = Array.isArray(entityConfig.allowedTags)
30✔
589
      ? entityConfig.allowedTags.includes(tagName)
30✔
590
      : entityConfig.allowedTags(tagName, jPathOrMatcher);
30!
591

30✔
592
    if (!allowed) {
30✔
593
      return val;
10✔
594
    }
10✔
595
  }
30✔
596

3,090✔
597
  // Apply custom tag filter if provided
3,090✔
598
  if (entityConfig.tagFilter) {
3,160✔
599
    const jPathOrMatcher = this.options.jPath ? jPath.toString() : jPath;
25!
600
    if (!entityConfig.tagFilter(tagName, jPathOrMatcher)) {
25✔
601
      return val; // Skip based on custom filter
15✔
602
    }
15✔
603
  }
25✔
604

3,075✔
605
  return this.entityReplacer.replace(val);
3,075✔
606
}
3,160✔
607

5✔
608

5✔
609
function saveTextToParentTag(textData, parentNode, matcher, isLeafNode) {
7,745✔
610
  if (textData) { //store previously collected data as textNode
7,745✔
611
    if (isLeafNode === undefined) isLeafNode = parentNode.child.length === 0
6,510✔
612

6,510✔
613
    textData = this.parseTextData(textData,
6,510✔
614
      parentNode.tagname,
6,510✔
615
      matcher,
6,510✔
616
      false,
6,510✔
617
      parentNode[":@"] ? Object.keys(parentNode[":@"]).length !== 0 : false,
6,510!
618
      isLeafNode);
6,510✔
619

6,510✔
620
    if (textData !== undefined && textData !== "")
6,510✔
621
      parentNode.add(this.options.textNodeName, textData);
6,510✔
622
    textData = "";
6,475✔
623
  }
6,475✔
624
  return textData;
7,710✔
625
}
7,745✔
626

5✔
627
/**
5✔
628
 * @param {Array<Expression>} stopNodeExpressions - Array of compiled Expression objects
5✔
629
 * @param {Matcher} matcher - Current path matcher
5✔
630
 */
5✔
631
function isItStopNode() {
6,365✔
632
  if (this.stopNodeExpressionsSet.size === 0) return false;
6,365✔
633

2,220✔
634
  return this.matcher.matchesAny(this.stopNodeExpressionsSet);
2,220✔
635
}
6,365✔
636

5✔
637
/**
5✔
638
 * Returns the tag Expression and where it is ending handling single-double quotes situation
5✔
639
 * @param {string} xmlData 
5✔
640
 * @param {number} i starting index
5✔
641
 * @returns 
5✔
642
 */
5✔
643
function tagExpWithClosingIndex(xmlData, i, closingChar = ">") {
6,855✔
644
  let attrBoundary = 0;
6,855✔
645
  const chars = [];
6,855✔
646
  const len = xmlData.length;
6,855✔
647
  const closeCode0 = closingChar.charCodeAt(0);
6,855✔
648
  const closeCode1 = closingChar.length > 1 ? closingChar.charCodeAt(1) : -1;
6,855✔
649

6,855✔
650
  for (let index = i; index < len; index++) {
6,855✔
651
    const code = xmlData.charCodeAt(index);
67,695✔
652

67,695✔
653
    if (attrBoundary) {
67,695✔
654
      if (code === attrBoundary) attrBoundary = 0;
14,510✔
655
    } else if (code === 34 || code === 39) { // " or '
67,695✔
656
      attrBoundary = code;
1,580✔
657
    } else if (code === closeCode0) {
53,185✔
658
      if (closeCode1 !== -1) {
7,085✔
659
        if (xmlData.charCodeAt(index + 1) === closeCode1) {
480✔
660
          return { data: String.fromCharCode(...chars), index };
235✔
661
        }
235✔
662
      } else {
7,085✔
663
        return { data: String.fromCharCode(...chars), index };
6,605✔
664
      }
6,605✔
665
    } else if (code === 9) { // \t
51,605✔
666
      chars.push(32); // space
20✔
667
      continue;
20✔
668
    }
20✔
669

60,835✔
670
    chars.push(code);
60,835✔
671
  }
60,835✔
672
}
6,855✔
673

5✔
674
function findClosingIndex(xmlData, str, i, errMsg) {
4,340✔
675
  const closingIndex = xmlData.indexOf(str, i);
4,340✔
676
  if (closingIndex === -1) {
4,340✔
677
    throw new Error(errMsg)
15✔
678
  } else {
4,340✔
679
    return closingIndex + str.length - 1;
4,325✔
680
  }
4,325✔
681
}
4,340✔
682

5✔
683
function findClosingChar(xmlData, char, i, errMsg) {
1,370✔
684
  const closingIndex = xmlData.indexOf(char, i);
1,370✔
685
  if (closingIndex === -1) throw new Error(errMsg);
1,370!
686
  return closingIndex; // no offset needed
1,370✔
687
}
1,370✔
688

5✔
689
function readTagExp(xmlData, i, removeNSPrefix, closingChar = ">") {
6,855✔
690
  const result = tagExpWithClosingIndex(xmlData, i + 1, closingChar);
6,855✔
691
  if (!result) return;
6,855✔
692
  let tagExp = result.data;
6,840✔
693
  const closeIndex = result.index;
6,840✔
694
  const separatorIndex = tagExp.search(/\s/);
6,840✔
695
  let tagName = tagExp;
6,840✔
696
  let attrExpPresent = true;
6,840✔
697
  if (separatorIndex !== -1) {//separate tag name and attributes expression
6,855✔
698
    tagName = tagExp.substring(0, separatorIndex);
1,230✔
699
    tagExp = tagExp.substring(separatorIndex + 1).trimStart();
1,230✔
700
  }
1,230✔
701

6,840✔
702
  const rawTagName = tagName;
6,840✔
703
  if (removeNSPrefix) {
6,855✔
704
    const colonIndex = tagName.indexOf(":");
300✔
705
    if (colonIndex !== -1) {
300✔
706
      tagName = tagName.substr(colonIndex + 1);
65✔
707
      attrExpPresent = tagName !== result.data.substr(colonIndex + 1);
65✔
708
    }
65✔
709
  }
300✔
710

6,840✔
711
  return {
6,840✔
712
    tagName: tagName,
6,840✔
713
    tagExp: tagExp,
6,840✔
714
    closeIndex: closeIndex,
6,840✔
715
    attrExpPresent: attrExpPresent,
6,840✔
716
    rawTagName: rawTagName,
6,840✔
717
  }
6,840✔
718
}
6,855✔
719
/**
5✔
720
 * find paired tag for a stop node
5✔
721
 * @param {string} xmlData 
5✔
722
 * @param {string} tagName 
5✔
723
 * @param {number} i 
5✔
724
 */
5✔
725
function readStopNodeData(xmlData, tagName, i) {
1,210✔
726
  const startIndex = i;
1,210✔
727
  // Starting at 1 since we already have an open tag
1,210✔
728
  let openTagCount = 1;
1,210✔
729

1,210✔
730
  const xmllen = xmlData.length;
1,210✔
731
  for (; i < xmllen; i++) {
1,210✔
732
    if (xmlData[i] === "<") {
15,985✔
733
      const c1 = xmlData.charCodeAt(i + 1);
1,555✔
734
      if (c1 === 47) {//close tag '/'
1,555✔
735
        const closeIndex = findClosingChar(xmlData, ">", i, `${tagName} is not closed`);
1,370✔
736
        let closeTagName = xmlData.substring(i + 2, closeIndex).trim();
1,370✔
737
        if (closeTagName === tagName) {
1,370✔
738
          openTagCount--;
1,215✔
739
          if (openTagCount === 0) {
1,215✔
740
            return {
1,210✔
741
              tagContent: xmlData.substring(startIndex, i),
1,210✔
742
              i: closeIndex
1,210✔
743
            }
1,210✔
744
          }
1,210✔
745
        }
1,215✔
746
        i = closeIndex;
160✔
747
      } else if (c1 === 63) { //?
1,555!
748
        const closeIndex = findClosingIndex(xmlData, "?>", i + 1, "StopNode is not closed.")
×
749
        i = closeIndex;
×
750
      } else if (c1 === 33
185✔
751
        && xmlData.charCodeAt(i + 2) === 45
185✔
752
        && xmlData.charCodeAt(i + 3) === 45) { // '!--'
185✔
753
        const closeIndex = findClosingIndex(xmlData, "-->", i + 3, "StopNode is not closed.")
5✔
754
        i = closeIndex;
5✔
755
      } else if (c1 === 33
185✔
756
        && xmlData.charCodeAt(i + 2) === 91) { // '!['
180✔
757
        const closeIndex = findClosingIndex(xmlData, "]]>", i, "StopNode is not closed.") - 2;
5✔
758
        i = closeIndex;
5✔
759
      } else {
180✔
760
        const tagData = readTagExp(xmlData, i, '>')
175✔
761

175✔
762
        if (tagData) {
175✔
763
          const openTagName = tagData && tagData.tagName;
170✔
764
          if (openTagName === tagName && tagData.tagExp[tagData.tagExp.length - 1] !== "/") {
170✔
765
            openTagCount++;
5✔
766
          }
5✔
767
          i = tagData.closeIndex;
170✔
768
        }
170✔
769
      }
175✔
770
    }
1,555✔
771
  }//end for loop
1,210✔
772
}
1,210✔
773

5✔
774
function parseValue(val, shouldParse, options) {
3,100✔
775
  if (shouldParse && typeof val === 'string') {
3,100✔
776
    //console.log(options)
2,080✔
777
    const newval = val.trim();
2,080✔
778
    if (newval === 'true') return true;
2,080✔
779
    else if (newval === 'false') return false;
2,045✔
780
    else return toNumber(val, options);
2,030✔
781
  } else {
3,100✔
782
    if (isExist(val)) {
1,020✔
783
      return val;
1,020✔
784
    } else {
1,020!
785
      return '';
×
786
    }
×
787
  }
1,020✔
788
}
3,100✔
789

5✔
UNCOV
790
function fromCodePoint(str, base, prefix) {
×
UNCOV
791
  const codePoint = Number.parseInt(str, base);
×
UNCOV
792

×
UNCOV
793
  if (codePoint >= 0 && codePoint <= 0x10FFFF) {
×
UNCOV
794
    return String.fromCodePoint(codePoint);
×
UNCOV
795
  } else {
×
UNCOV
796
    return prefix + str + ";";
×
UNCOV
797
  }
×
UNCOV
798
}
×
799

5✔
800
function transformTagName(fn, tagName, tagExp, options) {
10,780✔
801
  if (fn) {
10,780✔
802
    const newTagName = fn(tagName);
160✔
803
    if (tagExp === tagName) {
160✔
804
      tagExp = newTagName
55✔
805
    }
55✔
806
    tagName = newTagName;
160✔
807
  }
160✔
808
  tagName = sanitizeName(tagName, options);
10,780✔
809
  return { tagName, tagExp };
10,780✔
810
}
10,780✔
811

5✔
812

5✔
813

5✔
814
function sanitizeName(name, options) {
12,130✔
815
  if (criticalProperties.includes(name)) {
12,130✔
816
    throw new Error(`[SECURITY] Invalid name: "${name}" is a reserved JavaScript keyword that could cause prototype pollution`);
30✔
817
  } else if (DANGEROUS_PROPERTY_NAMES.includes(name)) {
12,130✔
818
    return options.onDangerousProperty(name);
105✔
819
  }
105✔
820
  return name;
11,995✔
821
}
12,130✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc