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

NaturalIntelligence / fast-xml-parser / 24545357089

17 Apr 2026 02:57AM UTC coverage: 97.649% (+0.009%) from 97.64%
24545357089

push

github

amitguptagwl
update docs

1138 of 1185 branches covered (96.03%)

9428 of 9655 relevant lines covered (97.65%)

277330.37 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3✔
134
}
3✔
135

3✔
136

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

1,197✔
155
      // Pass jPath string or matcher based on options.jPath setting
1,197✔
156
      const jPathOrMatcher = options.jPath ? jPath.toString() : jPath;
1,218✔
157
      const newval = options.tagValueProcessor(tagName, val, jPathOrMatcher, hasAttributes, isLeafNode);
1,218✔
158
      if (newval === null || newval === undefined) {
1,218✔
159
        //don't parse
30✔
160
        return val;
30✔
161
      } else if (typeof newval !== typeof val || newval !== val) {
1,218✔
162
        //overwrite
12✔
163
        return newval;
12✔
164
      } else if (options.trimValues) {
1,167✔
165
        return parseValue(val, options.parseTagValue, options.numberParseOptions);
1,119✔
166
      } else {
1,155✔
167
        const trimmedVal = val.trim();
36✔
168
        if (trimmedVal === val) {
36✔
169
          return parseValue(val, options.parseTagValue, options.numberParseOptions);
9✔
170
        } else {
36✔
171
          return val;
27✔
172
        }
27✔
173
      }
36✔
174
    }
1,218✔
175
  }
4,029✔
176
}
4,029✔
177

3✔
178
function resolveNameSpace(tagname) {
1,950✔
179
  if (this.options.removeNSPrefix) {
1,950✔
180
    const tags = tagname.split(':');
132✔
181
    const prefix = tagname.charAt(0) === '/' ? '/' : '';
132!
182
    if (tags[0] === 'xmlns') {
132✔
183
      return '';
42✔
184
    }
42✔
185
    if (tags.length === 2) {
132✔
186
      tagname = prefix + tags[1];
36✔
187
    }
36✔
188
  }
132✔
189
  return tagname;
1,908✔
190
}
1,950✔
191

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

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

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

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

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

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

876✔
222
        rawAttrsForMatcher[attrName] = val;
876✔
223
        hasRawAttrs = true;
876✔
224
      }
876✔
225
    }
975✔
226

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

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

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

975✔
240
      if (this.ignoreAttributesFn(attrName, jPathStr)) continue;
975✔
241

942✔
242
      let aName = options.attributeNamePrefix + attrName;
942✔
243

942✔
244
      if (attrName.length) {
975✔
245
        if (options.transformAttributeName) {
921✔
246
          aName = options.transformAttributeName(aName);
15✔
247
        }
15✔
248
        aName = sanitizeName(aName, options);
921✔
249

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

834✔
254
          const newVal = options.attributeValueProcessor(attrName, oldVal, jPathStr);
834✔
255
          if (newVal === null || newVal === undefined) {
834!
256
            attrs[aName] = oldVal;
×
257
          } else if (typeof newVal !== typeof oldVal || newVal !== oldVal) {
834✔
258
            attrs[aName] = newVal;
3✔
259
          } else {
834✔
260
            attrs[aName] = parseValue(oldVal, options.parseAttributeValue, options.numberParseOptions);
831✔
261
          }
831✔
262
          hasAttrs = true;
834✔
263
        } else if (options.allowBooleanAttributes) {
921✔
264
          attrs[aName] = true;
66✔
265
          hasAttrs = true;
66✔
266
        }
66✔
267
      }
921✔
268
    }
975✔
269

669✔
270
    if (!hasAttrs) return;
678✔
271

606✔
272
    if (options.attributesGroupName) {
678✔
273
      const attrCollection = {};
39✔
274
      attrCollection[options.attributesGroupName] = attrs;
39✔
275
      return attrCollection;
39✔
276
    }
39✔
277
    return attrs;
567✔
278
  }
567✔
279
}
744✔
280
const parseXml = function (xmlData) {
3✔
281
  xmlData = xmlData.replace(/\r\n?/g, "\n"); //TODO: remove this line
1,026✔
282
  const xmlObj = new xmlNode('!xml');
1,026✔
283
  let currentNode = xmlObj;
1,026✔
284
  let textData = "";
1,026✔
285

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

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

2,460✔
306
        if (options.removeNSPrefix) {
2,460✔
307
          const colonIndex = tagName.indexOf(":");
69✔
308
          if (colonIndex !== -1) {
69✔
309
            tagName = tagName.substr(colonIndex + 1);
30✔
310
          }
30✔
311
        }
69✔
312

2,457✔
313
        tagName = transformTagName(options.transformTagName, tagName, "", options).tagName;
2,457✔
314

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

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

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

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

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

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

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

156✔
360

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

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

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

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

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

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

111✔
396
        i = closeIndex + 2;
111✔
397
      } else {//Opening tag
3,987✔
398
        let result = readTagExp(xmlData, i, options.removeNSPrefix);
3,873✔
399

3,873✔
400
        // Safety check: readTagExp can return undefined
3,873✔
401
        if (!result) {
3,873!
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

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

3,873✔
413
        ({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options));
3,873✔
414

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

3,840✔
424
        //save text as child node
3,840✔
425
        if (currentNode && textData) {
3,873✔
426
          if (currentNode.tagname !== '!xml') {
2,472✔
427
            //when nested tag is found
1,935✔
428
            textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher, false);
1,935✔
429
          }
1,935✔
430
        }
2,472✔
431

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

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

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

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

3,840✔
460
        // Extract namespace from rawTagName
3,840✔
461
        namespace = extractNamespace(rawTagName);
3,840✔
462

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

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

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

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

3,831✔
486
        const startIndex = i;
3,831✔
487
        if (this.isCurrentNodeStopNode) {
3,873✔
488
          let tagContent = "";
735✔
489

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

735✔
507
          const childNode = new xmlNode(tagName);
735✔
508

735✔
509
          if (prefixedAttrs) {
735✔
510
            childNode[":@"] = prefixedAttrs;
12✔
511
          }
12✔
512

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

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

735✔
519
          this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
735✔
520
        } else {
3,873✔
521
          //selfClosing tag
3,096✔
522
          if (isSelfClosing) {
3,096✔
523
            ({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options));
162✔
524

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

2,805✔
553
            if (prefixedAttrs) {
2,808✔
554
              childNode[":@"] = prefixedAttrs;
336✔
555
            }
336✔
556
            this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
2,805✔
557
            currentNode = childNode;
2,805✔
558
          }
2,805✔
559
          textData = "";
2,967✔
560
          i = closeIndex;
2,967✔
561
        }
2,967✔
562
      }
3,873✔
563
    } else {
195,729✔
564
      textData += xmlData[i];
188,952✔
565
    }
188,952✔
566
  }
195,729✔
567
  return xmlObj.child;
927✔
568
}
1,026✔
569

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

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

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

1,995✔
595
  if (!entityConfig || !entityConfig.enabled) {
1,995✔
596
    return val;
36✔
597
  }
36✔
598

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

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

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

1,944✔
619
  return this.entityDecoder.decode(val);
1,944✔
620
}
1,995✔
621

3✔
622

3✔
623
function saveTextToParentTag(textData, parentNode, matcher, isLeafNode) {
4,674✔
624
  if (textData) { //store previously collected data as textNode
4,674✔
625
    if (isLeafNode === undefined) isLeafNode = parentNode.child.length === 0
3,918✔
626

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

3,918✔
634
    if (textData !== undefined && textData !== "")
3,918✔
635
      parentNode.add(this.options.textNodeName, textData);
3,918✔
636
    textData = "";
3,897✔
637
  }
3,897✔
638
  return textData;
4,653✔
639
}
4,674✔
640

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

1,332✔
648
  return this.matcher.matchesAny(this.stopNodeExpressionsSet);
1,332✔
649
}
3,831✔
650

3✔
651
/**
3✔
652
 * Returns the tag Expression and where it is ending handling single-double quotes situation
3✔
653
 * @param {string} xmlData 
3✔
654
 * @param {number} i starting index
3✔
655
 * @returns 
3✔
656
 */
3✔
657
function tagExpWithClosingIndex(xmlData, i, closingChar = ">") {
4,140✔
658
  let attrBoundary = 0;
4,140✔
659
  const chars = [];
4,140✔
660
  const len = xmlData.length;
4,140✔
661
  const closeCode0 = closingChar.charCodeAt(0);
4,140✔
662
  const closeCode1 = closingChar.length > 1 ? closingChar.charCodeAt(1) : -1;
4,140✔
663

4,140✔
664
  for (let index = i; index < len; index++) {
4,140✔
665
    const code = xmlData.charCodeAt(index);
41,121✔
666

41,121✔
667
    if (attrBoundary) {
41,121✔
668
      if (code === attrBoundary) attrBoundary = 0;
8,820✔
669
    } else if (code === 34 || code === 39) { // " or '
41,121✔
670
      attrBoundary = code;
972✔
671
    } else if (code === closeCode0) {
32,301✔
672
      if (closeCode1 !== -1) {
4,293✔
673
        if (xmlData.charCodeAt(index + 1) === closeCode1) {
318✔
674
          return { data: String.fromCharCode(...chars), index };
156✔
675
        }
156✔
676
      } else {
4,293✔
677
        return { data: String.fromCharCode(...chars), index };
3,975✔
678
      }
3,975✔
679
    } else if (code === 9) { // \t
31,329✔
680
      chars.push(32); // space
12✔
681
      continue;
12✔
682
    }
12✔
683

36,978✔
684
    chars.push(code);
36,978✔
685
  }
36,978✔
686
}
4,140✔
687

3✔
688
function findClosingIndex(xmlData, str, i, errMsg) {
2,616✔
689
  const closingIndex = xmlData.indexOf(str, i);
2,616✔
690
  if (closingIndex === -1) {
2,616✔
691
    throw new Error(errMsg)
9✔
692
  } else {
2,616✔
693
    return closingIndex + str.length - 1;
2,607✔
694
  }
2,607✔
695
}
2,616✔
696

3✔
697
function findClosingChar(xmlData, char, i, errMsg) {
822✔
698
  const closingIndex = xmlData.indexOf(char, i);
822✔
699
  if (closingIndex === -1) throw new Error(errMsg);
822!
700
  return closingIndex; // no offset needed
822✔
701
}
822✔
702

3✔
703
function readTagExp(xmlData, i, removeNSPrefix, closingChar = ">") {
4,140✔
704
  const result = tagExpWithClosingIndex(xmlData, i + 1, closingChar);
4,140✔
705
  if (!result) return;
4,140✔
706
  let tagExp = result.data;
4,131✔
707
  const closeIndex = result.index;
4,131✔
708
  const separatorIndex = tagExp.search(/\s/);
4,131✔
709
  let tagName = tagExp;
4,131✔
710
  let attrExpPresent = true;
4,131✔
711
  if (separatorIndex !== -1) {//separate tag name and attributes expression
4,140✔
712
    tagName = tagExp.substring(0, separatorIndex);
753✔
713
    tagExp = tagExp.substring(separatorIndex + 1).trimStart();
753✔
714
  }
753✔
715

4,131✔
716
  const rawTagName = tagName;
4,131✔
717
  if (removeNSPrefix) {
4,140✔
718
    const colonIndex = tagName.indexOf(":");
180✔
719
    if (colonIndex !== -1) {
180✔
720
      tagName = tagName.substr(colonIndex + 1);
39✔
721
      attrExpPresent = tagName !== result.data.substr(colonIndex + 1);
39✔
722
    }
39✔
723
  }
180✔
724

4,131✔
725
  return {
4,131✔
726
    tagName: tagName,
4,131✔
727
    tagExp: tagExp,
4,131✔
728
    closeIndex: closeIndex,
4,131✔
729
    attrExpPresent: attrExpPresent,
4,131✔
730
    rawTagName: rawTagName,
4,131✔
731
  }
4,131✔
732
}
4,140✔
733
/**
3✔
734
 * find paired tag for a stop node
3✔
735
 * @param {string} xmlData 
3✔
736
 * @param {string} tagName 
3✔
737
 * @param {number} i 
3✔
738
 */
3✔
739
function readStopNodeData(xmlData, tagName, i) {
726✔
740
  const startIndex = i;
726✔
741
  // Starting at 1 since we already have an open tag
726✔
742
  let openTagCount = 1;
726✔
743

726✔
744
  const xmllen = xmlData.length;
726✔
745
  for (; i < xmllen; i++) {
726✔
746
    if (xmlData[i] === "<") {
9,591✔
747
      const c1 = xmlData.charCodeAt(i + 1);
933✔
748
      if (c1 === 47) {//close tag '/'
933✔
749
        const closeIndex = findClosingChar(xmlData, ">", i, `${tagName} is not closed`);
822✔
750
        let closeTagName = xmlData.substring(i + 2, closeIndex).trim();
822✔
751
        if (closeTagName === tagName) {
822✔
752
          openTagCount--;
729✔
753
          if (openTagCount === 0) {
729✔
754
            return {
726✔
755
              tagContent: xmlData.substring(startIndex, i),
726✔
756
              i: closeIndex
726✔
757
            }
726✔
758
          }
726✔
759
        }
729✔
760
        i = closeIndex;
96✔
761
      } else if (c1 === 63) { //?
933!
762
        const closeIndex = findClosingIndex(xmlData, "?>", i + 1, "StopNode is not closed.")
×
763
        i = closeIndex;
×
764
      } else if (c1 === 33
111✔
765
        && xmlData.charCodeAt(i + 2) === 45
111✔
766
        && xmlData.charCodeAt(i + 3) === 45) { // '!--'
111✔
767
        const closeIndex = findClosingIndex(xmlData, "-->", i + 3, "StopNode is not closed.")
3✔
768
        i = closeIndex;
3✔
769
      } else if (c1 === 33
111✔
770
        && xmlData.charCodeAt(i + 2) === 91) { // '!['
108✔
771
        const closeIndex = findClosingIndex(xmlData, "]]>", i, "StopNode is not closed.") - 2;
3✔
772
        i = closeIndex;
3✔
773
      } else {
108✔
774
        const tagData = readTagExp(xmlData, i, '>')
105✔
775

105✔
776
        if (tagData) {
105✔
777
          const openTagName = tagData && tagData.tagName;
102✔
778
          if (openTagName === tagName && tagData.tagExp[tagData.tagExp.length - 1] !== "/") {
102✔
779
            openTagCount++;
3✔
780
          }
3✔
781
          i = tagData.closeIndex;
102✔
782
        }
102✔
783
      }
105✔
784
    }
933✔
785
  }//end for loop
726✔
786
}
726✔
787

3✔
788
function parseValue(val, shouldParse, options) {
1,959✔
789
  if (shouldParse && typeof val === 'string') {
1,959✔
790
    //console.log(options)
1,260✔
791
    const newval = val.trim();
1,260✔
792
    if (newval === 'true') return true;
1,260✔
793
    else if (newval === 'false') return false;
1,239✔
794
    else return toNumber(val, options);
1,230✔
795
  } else {
1,959✔
796
    if (isExist(val)) {
699✔
797
      return val;
699✔
798
    } else {
699!
799
      return '';
×
800
    }
×
801
  }
699✔
802
}
1,959✔
803

3✔
804
function fromCodePoint(str, base, prefix) {
×
805
  const codePoint = Number.parseInt(str, base);
×
806

×
807
  if (codePoint >= 0 && codePoint <= 0x10FFFF) {
×
808
    return String.fromCodePoint(codePoint);
×
809
  } else {
×
810
    return prefix + str + ";";
×
811
  }
×
812
}
×
813

3✔
814
function transformTagName(fn, tagName, tagExp, options) {
6,492✔
815
  if (fn) {
6,492✔
816
    const newTagName = fn(tagName);
96✔
817
    if (tagExp === tagName) {
96✔
818
      tagExp = newTagName
33✔
819
    }
33✔
820
    tagName = newTagName;
96✔
821
  }
96✔
822
  tagName = sanitizeName(tagName, options);
6,492✔
823
  return { tagName, tagExp };
6,492✔
824
}
6,492✔
825

3✔
826

3✔
827

3✔
828
function sanitizeName(name, options) {
7,413✔
829
  if (criticalProperties.includes(name)) {
7,413✔
830
    throw new Error(`[SECURITY] Invalid name: "${name}" is a reserved JavaScript keyword that could cause prototype pollution`);
18✔
831
  } else if (DANGEROUS_PROPERTY_NAMES.includes(name)) {
7,413✔
832
    return options.onDangerousProperty(name);
63✔
833
  }
63✔
834
  return name;
7,332✔
835
}
7,413✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc