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

NaturalIntelligence / fast-xml-parser / 24914900388

24 Apr 2026 10:32PM UTC coverage: 97.603% (-0.05%) from 97.655%
24914900388

push

github

amitguptagwl
update releas info

1182 of 1231 branches covered (96.02%)

1 of 1 new or added line in 1 file covered. (100.0%)

12 existing lines in 2 files now uncovered.

9487 of 9720 relevant lines covered (97.6%)

526505.76 hits per line

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

96.79
/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 { EntityDecoder, XML, CURRENCY, COMMON_HTML } 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) {
775✔
29
  if (!prefixedAttrs) return {};
775!
30

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

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

770✔
38
  const rawAttrs = {};
770✔
39
  for (const key in attrs) {
775✔
40
    // Remove the attribute prefix to get raw name
1,608✔
41
    if (key.startsWith(options.attributeNamePrefix)) {
1,608✔
42
      const rawName = key.substring(options.attributeNamePrefix.length);
1,267✔
43
      rawAttrs[rawName] = attrs[key];
1,267✔
44
    } else {
1,608✔
45
      // Attribute without prefix (shouldn't normally happen, but be safe)
341✔
46
      rawAttrs[key] = attrs[key];
341✔
47
    }
341✔
48
  }
1,608✔
49
  return rawAttrs;
770✔
50
}
775✔
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,415✔
58
  if (!rawTagName || typeof rawTagName !== 'string') return undefined;
6,415!
59

6,415✔
60
  const colonIndex = rawTagName.indexOf(':');
6,415✔
61
  if (colonIndex !== -1 && colonIndex > 0) {
6,415✔
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,265✔
69
}
6,415✔
70

5✔
71
export default class OrderedObjParser {
5✔
72
  constructor(options, externalEntities) {
5✔
73
    this.options = options;
1,725✔
74
    this.currentNode = null;
1,725✔
75
    this.tagsNodeStack = [];
1,725✔
76
    this.parseXml = parseXml;
1,725✔
77
    this.parseTextData = parseTextData;
1,725✔
78
    this.resolveNameSpace = resolveNameSpace;
1,725✔
79
    this.buildAttributesMap = buildAttributesMap;
1,725✔
80
    this.isItStopNode = isItStopNode;
1,725✔
81
    this.replaceEntitiesValue = replaceEntitiesValue;
1,725✔
82
    this.readStopNodeData = readStopNodeData;
1,725✔
83
    this.saveTextToParentTag = saveTextToParentTag;
1,725✔
84
    this.addChild = addChild;
1,725✔
85
    this.ignoreAttributesFn = getIgnoreAttributesFn(this.options.ignoreAttributes)
1,725✔
86
    this.entityExpansionCount = 0;
1,725✔
87
    this.currentExpandedLength = 0;
1,725✔
88
    let namedEntities = { ...XML };
1,725✔
89
    if (this.options.entityDecoder) {
1,725✔
90
      this.entityDecoder = this.options.entityDecoder
15✔
91
    } else {
1,725✔
92
      if (typeof this.options.htmlEntities === "object") namedEntities = this.options.htmlEntities;
1,710!
93
      else if (this.options.htmlEntities === true) namedEntities = { ...COMMON_HTML, ...CURRENCY };
1,710✔
94
      this.entityDecoder = new EntityDecoder({
1,710✔
95
        namedEntities: { ...namedEntities, ...externalEntities },
1,710✔
96
        numericAllowed: this.options.htmlEntities,
1,710✔
97
        limit: {
1,710✔
98
          maxTotalExpansions: this.options.processEntities.maxTotalExpansions,
1,710✔
99
          maxExpandedLength: this.options.processEntities.maxExpandedLength,
1,710✔
100
          applyLimitsTo: this.options.processEntities.appliesTo,
1,710✔
101
        }
1,710✔
102
        //postCheck: resolved => resolved
1,710✔
103
      });
1,710✔
104
    }
1,710✔
105

1,725✔
106
    // Initialize path matcher for path-expression-matcher
1,725✔
107
    this.matcher = new Matcher();
1,725✔
108

1,725✔
109
    // Live read-only proxy of matcher — PEM creates and caches this internally.
1,725✔
110
    // All user callbacks receive this instead of the mutable matcher.
1,725✔
111
    this.readonlyMatcher = this.matcher.readOnly();
1,725✔
112

1,725✔
113
    // Flag to track if current node is a stop node (optimization)
1,725✔
114
    this.isCurrentNodeStopNode = false;
1,725✔
115

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

5✔
134
}
5✔
135

5✔
136

5✔
137
/**
5✔
138
 * @param {string} val
5✔
139
 * @param {string} tagName
5✔
140
 * @param {string|Matcher} jPath - jPath string or Matcher instance based on options.jPath
5✔
141
 * @param {boolean} dontTrim
5✔
142
 * @param {boolean} hasAttributes
5✔
143
 * @param {boolean} isLeafNode
5✔
144
 * @param {boolean} escapeEntities
5✔
145
 */
5✔
146
function parseTextData(val, tagName, jPath, dontTrim, hasAttributes, isLeafNode, escapeEntities) {
6,715✔
147
  const options = this.options;
6,715✔
148
  if (val !== undefined) {
6,715✔
149
    if (options.trimValues && !dontTrim) {
6,715✔
150
      val = val.trim();
6,470✔
151
    }
6,470✔
152
    if (val.length > 0) {
6,715✔
153
      if (!escapeEntities) val = this.replaceEntitiesValue(val, tagName, jPath);
2,030✔
154

1,995✔
155
      // Pass jPath string or matcher based on options.jPath setting
1,995✔
156
      const jPathOrMatcher = options.jPath ? jPath.toString() : jPath;
2,030✔
157
      const newval = options.tagValueProcessor(tagName, val, jPathOrMatcher, hasAttributes, isLeafNode);
2,030✔
158
      if (newval === null || newval === undefined) {
2,030✔
159
        //don't parse
50✔
160
        return val;
50✔
161
      } else if (typeof newval !== typeof val || newval !== val) {
2,030✔
162
        //overwrite
20✔
163
        return newval;
20✔
164
      } else if (options.trimValues) {
1,945✔
165
        return parseValue(val, options.parseTagValue, options.numberParseOptions);
1,865✔
166
      } else {
1,925✔
167
        const trimmedVal = val.trim();
60✔
168
        if (trimmedVal === val) {
60✔
169
          return parseValue(val, options.parseTagValue, options.numberParseOptions);
15✔
170
        } else {
60✔
171
          return val;
45✔
172
        }
45✔
173
      }
60✔
174
    }
2,030✔
175
  }
6,715✔
176
}
6,715✔
177

5✔
178
function resolveNameSpace(tagname) {
10,003,270✔
179
  if (this.options.removeNSPrefix) {
10,003,270✔
180
    const tags = tagname.split(':');
220✔
181
    const prefix = tagname.charAt(0) === '/' ? '/' : '';
220!
182
    if (tags[0] === 'xmlns') {
220✔
183
      return '';
70✔
184
    }
70✔
185
    if (tags.length === 2) {
220✔
186
      tagname = prefix + tags[1];
60✔
187
    }
60✔
188
  }
220✔
189
  return tagname;
10,003,200✔
190
}
10,003,270✔
191

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

5✔
196
function buildAttributesMap(attrStr, jPath, tagName, force = false) {
1,255✔
197
  const options = this.options;
1,255✔
198
  if (force === true || (options.ignoreAttributes !== true && typeof attrStr === 'string')) {
1,255✔
199
    // attrStr = attrStr.replace(/\r?\n/g, ' ');
1,145✔
200
    //attrStr = attrStr || attrStr.trim();
1,145✔
201

1,145✔
202
    const matches = getAllMatches(attrStr, attrsRegx);
1,145✔
203
    const len = matches.length; //don't make it inline
1,145✔
204
    const attrs = {};
1,145✔
205

1,145✔
206
    // Pre-process values once: trim + entity replacement
1,145✔
207
    // Reused in both matcher update and second pass
1,145✔
208
    const processedVals = new Array(len);
1,145✔
209
    let hasRawAttrs = false;
1,145✔
210
    const rawAttrsForMatcher = {};
1,145✔
211

1,145✔
212
    for (let i = 0; i < len; i++) {
1,145✔
213
      const attrName = this.resolveNameSpace(matches[i][1]);
5,001,635✔
214
      const oldVal = matches[i][4];
5,001,635✔
215

5,001,635✔
216
      if (attrName.length && oldVal !== undefined) {
5,001,635✔
217
        let val = oldVal;
5,001,470✔
218
        if (options.trimValues) val = val.trim();
5,001,470✔
219
        val = this.replaceEntitiesValue(val, tagName, this.readonlyMatcher);
5,001,470✔
220
        processedVals[i] = val;
5,001,470✔
221

5,001,470✔
222
        rawAttrsForMatcher[attrName] = val;
5,001,470✔
223
        hasRawAttrs = true;
5,001,470✔
224
      }
5,001,470✔
225
    }
5,001,635✔
226

1,145✔
227
    // Update matcher ONCE before second pass, if applicable
1,145✔
228
    if (hasRawAttrs && typeof jPath === 'object' && jPath.updateCurrent) {
1,145✔
229
      jPath.updateCurrent(rawAttrsForMatcher);
1,005✔
230
    }
1,005✔
231

1,145✔
232
    // Hoist toString() once — path doesn't change during attribute processing
1,145✔
233
    const jPathStr = options.jPath ? jPath.toString() : this.readonlyMatcher;
1,145✔
234

1,145✔
235
    // Second pass: apply processors, build final attrs
1,145✔
236
    let hasAttrs = false;
1,145✔
237
    for (let i = 0; i < len; i++) {
1,145✔
238
      const attrName = this.resolveNameSpace(matches[i][1]);
5,001,635✔
239

5,001,635✔
240
      if (this.ignoreAttributesFn(attrName, jPathStr)) continue;
5,001,635✔
241

5,001,580✔
242
      let aName = options.attributeNamePrefix + attrName;
5,001,580✔
243

5,001,580✔
244
      if (attrName.length) {
5,001,635✔
245
        if (options.transformAttributeName) {
5,001,545✔
246
          aName = options.transformAttributeName(aName);
25✔
247
        }
25✔
248
        aName = sanitizeName(aName, options);
5,001,545✔
249

5,001,545✔
250
        if (matches[i][4] !== undefined) {
5,001,545✔
251
          // Reuse already-processed value — no double entity replacement
5,001,400✔
252
          const oldVal = processedVals[i];
5,001,400✔
253

5,001,400✔
254
          const newVal = options.attributeValueProcessor(attrName, oldVal, jPathStr);
5,001,400✔
255
          if (newVal === null || newVal === undefined) {
5,001,400!
256
            attrs[aName] = oldVal;
×
257
          } else if (typeof newVal !== typeof oldVal || newVal !== oldVal) {
5,001,400✔
258
            attrs[aName] = newVal;
5✔
259
          } else {
5,001,400✔
260
            attrs[aName] = parseValue(oldVal, options.parseAttributeValue, options.numberParseOptions);
5,001,395✔
261
          }
5,001,395✔
262
          hasAttrs = true;
5,001,400✔
263
        } else if (options.allowBooleanAttributes) {
5,001,545✔
264
          attrs[aName] = true;
110✔
265
          hasAttrs = true;
110✔
266
        }
110✔
267
      }
5,001,545✔
268
    }
5,001,635✔
269

1,130✔
270
    if (!hasAttrs) return;
1,145✔
271

1,025✔
272
    if (options.attributesGroupName && !options.preserveOrder) {
1,145✔
273
      const attrCollection = {};
65✔
274
      attrCollection[options.attributesGroupName] = attrs;
65✔
275
      return attrCollection;
65✔
276
    }
65✔
277
    return attrs;
960✔
278
  }
960✔
279
}
1,255✔
280
const parseXml = function (xmlData) {
5✔
281
  xmlData = xmlData.replace(/\r\n?/g, "\n"); //TODO: remove this line
1,725✔
282
  const xmlObj = new xmlNode('!xml');
1,725✔
283
  let currentNode = xmlObj;
1,725✔
284
  let textData = "";
1,725✔
285

1,725✔
286
  // Reset matcher for new document
1,725✔
287
  this.matcher.reset();
1,725✔
288
  this.entityDecoder.reset();
1,725✔
289

1,725✔
290
  // Reset entity expansion counters for this document
1,725✔
291
  this.entityExpansionCount = 0;
1,725✔
292
  this.currentExpandedLength = 0;
1,725✔
293
  const options = this.options;
1,725✔
294
  const docTypeReader = new DocTypeReader(options.processEntities);
1,725✔
295
  const xmlLen = xmlData.length;
1,725✔
296
  for (let i = 0; i < xmlLen; i++) {//for each char in XML data
1,725✔
297
    const ch = xmlData[i];
326,230✔
298
    if (ch === '<') {
326,230✔
299
      // const nextIndex = i+1;
11,310✔
300
      // const _2ndChar = xmlData[nextIndex];
11,310✔
301
      const c1 = xmlData.charCodeAt(i + 1);
11,310✔
302
      if (c1 === 47) {//Closing Tag '/'
11,310✔
303
        const closeIndex = findClosingIndex(xmlData, ">", i, "Closing Tag is not closed.")
4,100✔
304
        let tagName = xmlData.substring(i + 2, closeIndex).trim();
4,100✔
305

4,100✔
306
        if (options.removeNSPrefix) {
4,100✔
307
          const colonIndex = tagName.indexOf(":");
115✔
308
          if (colonIndex !== -1) {
115✔
309
            tagName = tagName.substr(colonIndex + 1);
50✔
310
          }
50✔
311
        }
115✔
312

4,095✔
313
        tagName = transformTagName(options.transformTagName, tagName, "", options).tagName;
4,095✔
314

4,095✔
315
        if (currentNode) {
4,095✔
316
          textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
4,095✔
317
        }
4,095✔
318

4,060✔
319
        //check if last tag of nested tag was unpaired tag
4,060✔
320
        const lastTagName = this.matcher.getCurrentTag();
4,060✔
321
        if (tagName && options.unpairedTagsSet.has(tagName)) {
4,100✔
322
          throw new Error(`Unpaired tag can not be used as closing tag: </${tagName}>`);
5✔
323
        }
5✔
324
        if (lastTagName && options.unpairedTagsSet.has(lastTagName)) {
4,100!
325
          // Pop the unpaired tag
×
326
          this.matcher.pop();
×
327
          this.tagsNodeStack.pop();
×
328
        }
×
329
        // Pop the closing tag
4,055✔
330
        this.matcher.pop();
4,055✔
331
        this.isCurrentNodeStopNode = false; // Reset flag when closing tag
4,055✔
332

4,055✔
333
        currentNode = this.tagsNodeStack.pop();//avoid recursion, set the parent tag scope
4,055✔
334
        textData = "";
4,055✔
335
        i = closeIndex;
4,055✔
336
      } else if (c1 === 63) { //'?'
11,310✔
337

270✔
338
        let tagData = readTagExp(xmlData, i, false, "?>");
270✔
339
        if (!tagData) throw new Error("Pi Tag is not closed.");
270✔
340

260✔
341
        textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
260✔
342
        const attsMap = this.buildAttributesMap(tagData.tagExp, this.matcher, tagData.tagName, true);
260✔
343
        if (attsMap) {
270✔
344
          const ver = attsMap[this.options.attributeNamePrefix + "version"];
250✔
345
          this.entityDecoder.setXmlVersion(Number(ver) || 1.0);
250✔
346
        }
250✔
347
        if ((options.ignoreDeclaration && tagData.tagName === "?xml") || options.ignorePiTags) {
270✔
348
          //do nothing
25✔
349
        } else {
270✔
350

235✔
351
          const childNode = new xmlNode(tagData.tagName);
235✔
352
          childNode.add(options.textNodeName, "");
235✔
353

235✔
354
          if (tagData.tagName !== tagData.tagExp && tagData.attrExpPresent && options.ignoreAttributes !== true) {
235✔
355
            childNode[":@"] = attsMap
135✔
356
          }
135✔
357
          this.addChild(currentNode, childNode, this.readonlyMatcher, i);
235✔
358
        }
235✔
359

260✔
360

260✔
361
        i = tagData.closeIndex + 1;
260✔
362
      } else if (c1 === 33
7,210✔
363
        && xmlData.charCodeAt(i + 2) === 45
6,940✔
364
        && xmlData.charCodeAt(i + 3) === 45) { //'!--'
6,940✔
365
        const endIndex = findClosingIndex(xmlData, "-->", i + 4, "Comment is not closed.")
60✔
366
        if (options.commentPropName) {
60✔
367
          const comment = xmlData.substring(i + 4, endIndex - 2);
25✔
368

25✔
369
          textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
25✔
370

25✔
371
          currentNode.add(options.commentPropName, [{ [options.textNodeName]: comment }]);
25✔
372
        }
25✔
373
        i = endIndex;
55✔
374
      } else if (c1 === 33
6,940✔
375
        && xmlData.charCodeAt(i + 2) === 68) { //'!D'
6,880✔
376
        const result = docTypeReader.readDocType(xmlData, i);
220✔
377
        this.entityDecoder.addInputEntities(result.entities);
220✔
378
        i = result.i;
220✔
379
      } else if (c1 === 33
6,880✔
380
        && xmlData.charCodeAt(i + 2) === 91) { // '!['
6,660✔
381
        const closeIndex = findClosingIndex(xmlData, "]]>", i, "CDATA is not closed.") - 2;
190✔
382
        const tagExp = xmlData.substring(i + 9, closeIndex);
190✔
383

190✔
384
        textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
190✔
385

190✔
386
        let val = this.parseTextData(tagExp, currentNode.tagname, this.readonlyMatcher, true, false, true, true);
190✔
387
        if (val == undefined) val = "";
190✔
388

185✔
389
        //cdata should be set even if it is 0 length string
185✔
390
        if (options.cdataPropName) {
190✔
391
          currentNode.add(options.cdataPropName, [{ [options.textNodeName]: tagExp }]);
35✔
392
        } else {
190✔
393
          currentNode.add(options.textNodeName, val);
150✔
394
        }
150✔
395

185✔
396
        i = closeIndex + 2;
185✔
397
      } else {//Opening tag
6,660✔
398
        let result = readTagExp(xmlData, i, options.removeNSPrefix);
6,470✔
399

6,470✔
400
        // Safety check: readTagExp can return undefined
6,470✔
401
        if (!result) {
6,470!
402
          // Log context for debugging
×
403
          const context = xmlData.substring(Math.max(0, i - 50), Math.min(xmlLen, i + 50));
×
404
          throw new Error(`readTagExp returned undefined at position ${i}. Context: "${context}"`);
×
405
        }
×
406

6,470✔
407
        let tagName = result.tagName;
6,470✔
408
        const rawTagName = result.rawTagName;
6,470✔
409
        let tagExp = result.tagExp;
6,470✔
410
        let attrExpPresent = result.attrExpPresent;
6,470✔
411
        let closeIndex = result.closeIndex;
6,470✔
412

6,470✔
413
        ({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options));
6,470✔
414

6,470✔
415
        if (options.strictReservedNames &&
6,470✔
416
          (tagName === options.commentPropName
6,410✔
417
            || tagName === options.cdataPropName
6,410✔
418
            || tagName === options.textNodeName
6,410✔
419
            || tagName === options.attributesGroupName
6,410✔
420
          )) {
6,470✔
421
          throw new Error(`Invalid tag name: ${tagName}`);
5✔
422
        }
5✔
423

6,415✔
424
        //save text as child node
6,415✔
425
        if (currentNode && textData) {
6,470✔
426
          if (currentNode.tagname !== '!xml') {
4,120✔
427
            //when nested tag is found
3,225✔
428
            textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher, false);
3,225✔
429
          }
3,225✔
430
        }
4,120✔
431

6,415✔
432
        //check if last tag was unpaired tag
6,415✔
433
        const lastTag = currentNode;
6,415✔
434
        if (lastTag && options.unpairedTagsSet.has(lastTag.tagname)) {
6,470!
435
          currentNode = this.tagsNodeStack.pop();
×
436
          this.matcher.pop();
×
437
        }
×
438

6,415✔
439
        // Clean up self-closing syntax BEFORE processing attributes
6,415✔
440
        // This is where tagExp gets the trailing / removed
6,415✔
441
        let isSelfClosing = false;
6,415✔
442
        if (tagExp.length > 0 && tagExp.lastIndexOf("/") === tagExp.length - 1) {
6,470✔
443
          isSelfClosing = true;
295✔
444
          if (tagName[tagName.length - 1] === "/") {
295✔
445
            tagName = tagName.substr(0, tagName.length - 1);
100✔
446
            tagExp = tagName;
100✔
447
          } else {
295✔
448
            tagExp = tagExp.substr(0, tagExp.length - 1);
195✔
449
          }
195✔
450

295✔
451
          // Re-check attrExpPresent after cleaning
295✔
452
          attrExpPresent = (tagName !== tagExp);
295✔
453
        }
295✔
454

6,415✔
455
        // Now process attributes with CLEAN tagExp (no trailing /)
6,415✔
456
        let prefixedAttrs = null;
6,415✔
457
        let rawAttrs = {};
6,415✔
458
        let namespace = undefined;
6,415✔
459

6,415✔
460
        // Extract namespace from rawTagName
6,415✔
461
        namespace = extractNamespace(rawTagName);
6,415✔
462

6,415✔
463
        // Push tag to matcher FIRST (with empty attrs for now) so callbacks see correct path
6,415✔
464
        if (tagName !== xmlObj.tagname) {
6,415✔
465
          this.matcher.push(tagName, {}, namespace);
6,415✔
466
        }
6,415✔
467

6,415✔
468
        // Now build attributes - callbacks will see correct matcher state
6,415✔
469
        if (tagName !== tagExp && attrExpPresent) {
6,470✔
470
          // Build attributes (returns prefixed attributes for the tree)
995✔
471
          // Note: buildAttributesMap now internally updates the matcher with raw attributes
995✔
472
          prefixedAttrs = this.buildAttributesMap(tagExp, this.matcher, tagName);
995✔
473

995✔
474
          if (prefixedAttrs) {
995✔
475
            // Extract raw attributes (without prefix) for our use
775✔
476
            //TODO: seems a performance overhead
775✔
477
            rawAttrs = extractRawAttributes(prefixedAttrs, options);
775✔
478
          }
775✔
479
        }
995✔
480

6,400✔
481
        // Now check if this is a stop node (after attributes are set)
6,400✔
482
        if (tagName !== xmlObj.tagname) {
6,400✔
483
          this.isCurrentNodeStopNode = this.isItStopNode();
6,400✔
484
        }
6,400✔
485

6,400✔
486
        const startIndex = i;
6,400✔
487
        if (this.isCurrentNodeStopNode) {
6,470✔
488
          let tagContent = "";
1,225✔
489

1,225✔
490
          // For self-closing tags, content is empty
1,225✔
491
          if (isSelfClosing) {
1,225✔
492
            i = result.closeIndex;
10✔
493
          }
10✔
494
          //unpaired tag
1,215✔
495
          else if (options.unpairedTagsSet.has(tagName)) {
1,215✔
496
            i = result.closeIndex;
5✔
497
          }
5✔
498
          //normal tag
1,210✔
499
          else {
1,210✔
500
            //read until closing tag is found
1,210✔
501
            const result = this.readStopNodeData(xmlData, rawTagName, closeIndex + 1);
1,210✔
502
            if (!result) throw new Error(`Unexpected end of ${rawTagName}`);
1,210!
503
            i = result.i;
1,210✔
504
            tagContent = result.tagContent;
1,210✔
505
          }
1,210✔
506

1,225✔
507
          const childNode = new xmlNode(tagName);
1,225✔
508

1,225✔
509
          if (prefixedAttrs) {
1,225✔
510
            childNode[":@"] = prefixedAttrs;
20✔
511
          }
20✔
512

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

1,225✔
516
          this.matcher.pop(); // Pop the stop node tag
1,225✔
517
          this.isCurrentNodeStopNode = false; // Reset flag
1,225✔
518

1,225✔
519
          this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
1,225✔
520
        } else {
6,470✔
521
          //selfClosing tag
5,175✔
522
          if (isSelfClosing) {
5,175✔
523
            ({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options));
285✔
524

285✔
525
            const childNode = new xmlNode(tagName);
285✔
526
            if (prefixedAttrs) {
285✔
527
              childNode[":@"] = prefixedAttrs;
105✔
528
            }
105✔
529
            this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
285✔
530
            this.matcher.pop(); // Pop self-closing tag
285✔
531
            this.isCurrentNodeStopNode = false; // Reset flag
285✔
532
          }
285✔
533
          else if (options.unpairedTagsSet.has(tagName)) {//unpaired tag
4,890✔
534
            const childNode = new xmlNode(tagName);
210✔
535
            if (prefixedAttrs) {
210✔
536
              childNode[":@"] = prefixedAttrs;
90✔
537
            }
90✔
538
            this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
210✔
539
            this.matcher.pop(); // Pop unpaired tag
210✔
540
            this.isCurrentNodeStopNode = false; // Reset flag
210✔
541
            i = result.closeIndex;
210✔
542
            // Continue to next iteration without changing currentNode
210✔
543
            continue;
210✔
544
          }
210✔
545
          //opening tag
4,680✔
546
          else {
4,680✔
547
            const childNode = new xmlNode(tagName);
4,680✔
548
            if (this.tagsNodeStack.length > options.maxNestedTags) {
4,680✔
549
              throw new Error("Maximum nested tags exceeded");
5✔
550
            }
5✔
551
            this.tagsNodeStack.push(currentNode);
4,675✔
552

4,675✔
553
            if (prefixedAttrs) {
4,680✔
554
              childNode[":@"] = prefixedAttrs;
560✔
555
            }
560✔
556
            this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
4,675✔
557
            currentNode = childNode;
4,675✔
558
          }
4,675✔
559
          textData = "";
4,960✔
560
          i = closeIndex;
4,960✔
561
        }
4,960✔
562
      }
6,470✔
563
    } else {
326,230✔
564
      textData += xmlData[i];
314,920✔
565
    }
314,920✔
566
  }
326,230✔
567
  return xmlObj.child;
1,560✔
568
}
1,725✔
569

5✔
570
function addChild(currentNode, childNode, matcher, startIndex) {
6,630✔
571
  // unset startIndex if not requested
6,630✔
572
  if (!this.options.captureMetaData) startIndex = undefined;
6,630✔
573

6,630✔
574
  // Pass jPath string or matcher based on options.jPath setting
6,630✔
575
  const jPathOrMatcher = this.options.jPath ? matcher.toString() : matcher;
6,630✔
576
  const result = this.options.updateTag(childNode.tagname, jPathOrMatcher, childNode[":@"])
6,630✔
577
  if (result === false) {
6,630✔
578
    //do nothing
25✔
579
  } else if (typeof result === "string") {
6,630✔
580
    childNode.tagname = result
6,605✔
581
    currentNode.addChild(childNode, startIndex);
6,605✔
582
  } else {
6,605!
583
    currentNode.addChild(childNode, startIndex);
×
584
  }
×
585
}
6,630✔
586

5✔
587
/**
5✔
588
 * @param {object} val - Entity object with regex and val properties
5✔
589
 * @param {string} tagName - Tag name
5✔
590
 * @param {string|Matcher} jPath - jPath string or Matcher instance based on options.jPath
5✔
591
 */
5✔
592
function replaceEntitiesValue(val, tagName, jPath) {
5,003,335✔
593
  const entityConfig = this.options.processEntities;
5,003,335✔
594

5,003,335✔
595
  if (!entityConfig || !entityConfig.enabled) {
5,003,335✔
596
    return val;
60✔
597
  }
60✔
598

5,003,275✔
599
  // Check if tag is allowed to contain entities
5,003,275✔
600
  if (entityConfig.allowedTags) {
5,003,335✔
601
    const jPathOrMatcher = this.options.jPath ? jPath.toString() : jPath;
30!
602
    const allowed = Array.isArray(entityConfig.allowedTags)
30✔
603
      ? entityConfig.allowedTags.includes(tagName)
30✔
604
      : entityConfig.allowedTags(tagName, jPathOrMatcher);
30!
605

30✔
606
    if (!allowed) {
30✔
607
      return val;
10✔
608
    }
10✔
609
  }
30✔
610

5,003,265✔
611
  // Apply custom tag filter if provided
5,003,265✔
612
  if (entityConfig.tagFilter) {
5,003,335✔
613
    const jPathOrMatcher = this.options.jPath ? jPath.toString() : jPath;
25!
614
    if (!entityConfig.tagFilter(tagName, jPathOrMatcher)) {
25✔
615
      return val; // Skip based on custom filter
15✔
616
    }
15✔
617
  }
25✔
618

5,003,250✔
619
  return this.entityDecoder.decode(val);
5,003,250✔
620
}
5,003,335✔
621

5✔
622

5✔
623
function saveTextToParentTag(textData, parentNode, matcher, isLeafNode) {
7,790✔
624
  if (textData) { //store previously collected data as textNode
7,790✔
625
    if (isLeafNode === undefined) isLeafNode = parentNode.child.length === 0
6,530✔
626

6,530✔
627
    textData = this.parseTextData(textData,
6,530✔
628
      parentNode.tagname,
6,530✔
629
      matcher,
6,530✔
630
      false,
6,530✔
631
      parentNode[":@"] ? Object.keys(parentNode[":@"]).length !== 0 : false,
6,530!
632
      isLeafNode);
6,530✔
633

6,530✔
634
    if (textData !== undefined && textData !== "")
6,530✔
635
      parentNode.add(this.options.textNodeName, textData);
6,530✔
636
    textData = "";
6,495✔
637
  }
6,495✔
638
  return textData;
7,755✔
639
}
7,790✔
640

5✔
641
/**
5✔
642
 * @param {Array<Expression>} stopNodeExpressions - Array of compiled Expression objects
5✔
643
 * @param {Matcher} matcher - Current path matcher
5✔
644
 */
5✔
645
function isItStopNode() {
6,400✔
646
  if (this.stopNodeExpressionsSet.size === 0) return false;
6,400✔
647

2,220✔
648
  return this.matcher.matchesAny(this.stopNodeExpressionsSet);
2,220✔
649
}
6,400✔
650

5✔
651
/**
5✔
652
 * Returns the tag Expression and where it is ending handling single-double quotes situation
5✔
653
 * @param {string} xmlData 
5✔
654
 * @param {number} i starting index
5✔
655
 * @returns 
5✔
656
 */
5✔
657
function tagExpWithClosingIndex(xmlData, i, closingChar = ">") {
6,915✔
658
  //TODO: ignore boolean attributes in tag expression
6,915✔
659
  //TODO: if ignore attributes, dont read full attribute expression but the end. But read for xml declaration
6,915✔
660
  let attrBoundary = 0;
6,915✔
661
  const len = xmlData.length;
6,915✔
662
  const closeCode0 = closingChar.charCodeAt(0);
6,915✔
663
  const closeCode1 = closingChar.length > 1 ? closingChar.charCodeAt(1) : -1;
6,915✔
664

6,915✔
665
  let result = '';
6,915✔
666
  let segmentStart = i;
6,915✔
667

6,915✔
668
  for (let index = i; index < len; index++) {
6,915✔
669
    const code = xmlData.charCodeAt(index);
30,068,805✔
670

30,068,805✔
671
    if (attrBoundary) {
30,068,805✔
672
      if (code === attrBoundary) attrBoundary = 0;
10,014,735✔
673
    } else if (code === 34 || code === 39) { // " or '
30,068,805✔
674
      attrBoundary = code;
5,001,630✔
675
    } else if (code === closeCode0) {
20,054,070✔
676
      if (closeCode1 !== -1) {
7,170✔
677
        if (xmlData.charCodeAt(index + 1) === closeCode1) {
530✔
678
          result += xmlData.substring(segmentStart, index);
260✔
679
          return { data: result, index };
260✔
680
        }
260✔
681
      } else {
7,170✔
682
        result += xmlData.substring(segmentStart, index);
6,640✔
683
        return { data: result, index };
6,640✔
684
      }
6,640✔
685
    } else if (code === 9 && !attrBoundary) { // \t - only replace with space outside attribute values
15,052,440✔
686
      // Flush accumulated segment, add space, start new segment
25✔
687
      result += xmlData.substring(segmentStart, index) + ' ';
25✔
688
      segmentStart = index + 1;
25✔
689
    }
25✔
690
  }
30,068,805✔
691
}
6,915✔
692

5✔
693
function findClosingIndex(xmlData, str, i, errMsg) {
4,360✔
694
  const closingIndex = xmlData.indexOf(str, i);
4,360✔
695
  if (closingIndex === -1) {
4,360✔
696
    throw new Error(errMsg)
15✔
697
  } else {
4,360✔
698
    return closingIndex + str.length - 1;
4,345✔
699
  }
4,345✔
700
}
4,360✔
701

5✔
702
function findClosingChar(xmlData, char, i, errMsg) {
1,370✔
703
  const closingIndex = xmlData.indexOf(char, i);
1,370✔
704
  if (closingIndex === -1) throw new Error(errMsg);
1,370!
705
  return closingIndex; // no offset needed
1,370✔
706
}
1,370✔
707

5✔
708
function readTagExp(xmlData, i, removeNSPrefix, closingChar = ">") {
6,915✔
709
  const result = tagExpWithClosingIndex(xmlData, i + 1, closingChar);
6,915✔
710
  if (!result) return;
6,915✔
711
  let tagExp = result.data;
6,900✔
712
  const closeIndex = result.index;
6,900✔
713
  const separatorIndex = tagExp.search(/\s/);
6,900✔
714
  let tagName = tagExp;
6,900✔
715
  let attrExpPresent = true;
6,900✔
716
  if (separatorIndex !== -1) {//separate tag name and attributes expression
6,915✔
717
    tagName = tagExp.substring(0, separatorIndex);
1,270✔
718
    tagExp = tagExp.substring(separatorIndex + 1).trimStart();
1,270✔
719
  }
1,270✔
720

6,900✔
721
  const rawTagName = tagName;
6,900✔
722
  if (removeNSPrefix) {
6,915✔
723
    const colonIndex = tagName.indexOf(":");
300✔
724
    if (colonIndex !== -1) {
300✔
725
      tagName = tagName.substr(colonIndex + 1);
65✔
726
      attrExpPresent = tagName !== result.data.substr(colonIndex + 1);
65✔
727
    }
65✔
728
  }
300✔
729

6,900✔
730
  return {
6,900✔
731
    tagName: tagName,
6,900✔
732
    tagExp: tagExp,
6,900✔
733
    closeIndex: closeIndex,
6,900✔
734
    attrExpPresent: attrExpPresent,
6,900✔
735
    rawTagName: rawTagName,
6,900✔
736
  }
6,900✔
737
}
6,915✔
738
/**
5✔
739
 * find paired tag for a stop node
5✔
740
 * @param {string} xmlData 
5✔
741
 * @param {string} tagName 
5✔
742
 * @param {number} i 
5✔
743
 */
5✔
744
function readStopNodeData(xmlData, tagName, i) {
1,210✔
745
  const startIndex = i;
1,210✔
746
  // Starting at 1 since we already have an open tag
1,210✔
747
  let openTagCount = 1;
1,210✔
748

1,210✔
749
  const xmllen = xmlData.length;
1,210✔
750
  for (; i < xmllen; i++) {
1,210✔
751
    if (xmlData[i] === "<") {
15,985✔
752
      const c1 = xmlData.charCodeAt(i + 1);
1,555✔
753
      if (c1 === 47) {//close tag '/'
1,555✔
754
        const closeIndex = findClosingChar(xmlData, ">", i, `${tagName} is not closed`);
1,370✔
755
        let closeTagName = xmlData.substring(i + 2, closeIndex).trim();
1,370✔
756
        if (closeTagName === tagName) {
1,370✔
757
          openTagCount--;
1,215✔
758
          if (openTagCount === 0) {
1,215✔
759
            return {
1,210✔
760
              tagContent: xmlData.substring(startIndex, i),
1,210✔
761
              i: closeIndex
1,210✔
762
            }
1,210✔
763
          }
1,210✔
764
        }
1,215✔
765
        i = closeIndex;
160✔
766
      } else if (c1 === 63) { //?
1,555!
UNCOV
767
        const closeIndex = findClosingIndex(xmlData, "?>", i + 1, "StopNode is not closed.")
×
UNCOV
768
        i = closeIndex;
×
769
      } else if (c1 === 33
185✔
770
        && xmlData.charCodeAt(i + 2) === 45
185✔
771
        && xmlData.charCodeAt(i + 3) === 45) { // '!--'
185✔
772
        const closeIndex = findClosingIndex(xmlData, "-->", i + 3, "StopNode is not closed.")
5✔
773
        i = closeIndex;
5✔
774
      } else if (c1 === 33
185✔
775
        && xmlData.charCodeAt(i + 2) === 91) { // '!['
180✔
776
        const closeIndex = findClosingIndex(xmlData, "]]>", i, "StopNode is not closed.") - 2;
5✔
777
        i = closeIndex;
5✔
778
      } else {
180✔
779
        const tagData = readTagExp(xmlData, i, '>')
175✔
780

175✔
781
        if (tagData) {
175✔
782
          const openTagName = tagData && tagData.tagName;
170✔
783
          if (openTagName === tagName && tagData.tagExp[tagData.tagExp.length - 1] !== "/") {
170✔
784
            openTagCount++;
5✔
785
          }
5✔
786
          i = tagData.closeIndex;
170✔
787
        }
170✔
788
      }
175✔
789
    }
1,555✔
790
  }//end for loop
1,210✔
791
}
1,210✔
792

5✔
793
function parseValue(val, shouldParse, options) {
5,003,275✔
794
  if (shouldParse && typeof val === 'string') {
5,003,275✔
795
    //console.log(options)
2,100✔
796
    const newval = val.trim();
2,100✔
797
    if (newval === 'true') return true;
2,100✔
798
    else if (newval === 'false') return false;
2,065✔
799
    else return toNumber(val, options);
2,050✔
800
  } else {
5,003,275✔
801
    if (isExist(val)) {
5,001,175✔
802
      return val;
5,001,175✔
803
    } else {
5,001,175!
UNCOV
804
      return '';
×
UNCOV
805
    }
×
806
  }
5,001,175✔
807
}
5,003,275✔
808

5✔
809
function fromCodePoint(str, base, prefix) {
×
810
  const codePoint = Number.parseInt(str, base);
×
811

×
812
  if (codePoint >= 0 && codePoint <= 0x10FFFF) {
×
813
    return String.fromCodePoint(codePoint);
×
814
  } else {
×
815
    return prefix + str + ";";
×
UNCOV
816
  }
×
UNCOV
817
}
×
818

5✔
819
function transformTagName(fn, tagName, tagExp, options) {
10,850✔
820
  if (fn) {
10,850✔
821
    const newTagName = fn(tagName);
160✔
822
    if (tagExp === tagName) {
160✔
823
      tagExp = newTagName
55✔
824
    }
55✔
825
    tagName = newTagName;
160✔
826
  }
160✔
827
  tagName = sanitizeName(tagName, options);
10,850✔
828
  return { tagName, tagExp };
10,850✔
829
}
10,850✔
830

5✔
831

5✔
832

5✔
833
function sanitizeName(name, options) {
5,012,395✔
834
  if (criticalProperties.includes(name)) {
5,012,395✔
835
    throw new Error(`[SECURITY] Invalid name: "${name}" is a reserved JavaScript keyword that could cause prototype pollution`);
30✔
836
  } else if (DANGEROUS_PROPERTY_NAMES.includes(name)) {
5,012,395✔
837
    return options.onDangerousProperty(name);
105✔
838
  }
105✔
839
  return name;
5,012,260✔
840
}
5,012,395✔
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