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

NaturalIntelligence / fast-xml-parser / 23936898168

03 Apr 2026 05:50AM UTC coverage: 97.694% (+0.005%) from 97.689%
23936898168

push

github

amitguptagwl
reduce multilevel property reference

- this.options to options
- move htmlEntity flag to build time

1155 of 1203 branches covered (96.01%)

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

22 existing lines in 1 file now uncovered.

9407 of 9629 relevant lines covered (97.69%)

463466.31 hits per line

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

97.62
/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

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

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

5✔
18
// Helper functions for attribute and namespace handling
5✔
19

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

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

760✔
34
  if (!attrs) return {};
760!
35

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

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

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

5✔
69
export default class OrderedObjParser {
5✔
70
  constructor(options) {
5✔
71
    this.options = options;
1,690✔
72
    this.currentNode = null;
1,690✔
73
    this.tagsNodeStack = [];
1,690✔
74
    this.docTypeEntities = {};
1,690✔
75
    this.lastEntities = {
1,690✔
76
      "apos": { regex: /&(apos|#39|#x27);/g, val: "'" },
1,690✔
77
      "gt": { regex: /&(gt|#62|#x3E);/g, val: ">" },
1,690✔
78
      "lt": { regex: /&(lt|#60|#x3C);/g, val: "<" },
1,690✔
79
      "quot": { regex: /&(quot|#34|#x22);/g, val: "\"" },
1,690✔
80
    };
1,690✔
81
    this.ampEntity = { regex: /&(amp|#38|#x26);/g, val: "&" };
1,690✔
82
    this.htmlEntities = {
1,690✔
83
      "space": { regex: /&(nbsp|#160);/g, val: " " },
1,690✔
84
      // "lt" : { regex: /&(lt|#60);/g, val: "<" },
1,690✔
85
      // "gt" : { regex: /&(gt|#62);/g, val: ">" },
1,690✔
86
      // "amp" : { regex: /&(amp|#38);/g, val: "&" },
1,690✔
87
      // "quot" : { regex: /&(quot|#34);/g, val: "\"" },
1,690✔
88
      // "apos" : { regex: /&(apos|#39);/g, val: "'" },
1,690✔
89
      "cent": { regex: /&(cent|#162);/g, val: "¢" },
1,690✔
90
      "pound": { regex: /&(pound|#163);/g, val: "£" },
1,690✔
91
      "yen": { regex: /&(yen|#165);/g, val: "Â¥" },
1,690✔
92
      "euro": { regex: /&(euro|#8364);/g, val: "€" },
1,690✔
93
      "copyright": { regex: /&(copy|#169);/g, val: "©" },
1,690✔
94
      "reg": { regex: /&(reg|#174);/g, val: "®" },
1,690✔
95
      "inr": { regex: /&(inr|#8377);/g, val: "₹" },
1,690✔
96
      "num_dec": { regex: /&#([0-9]{1,7});/g, val: (_, str) => fromCodePoint(str, 10, "&#") },
1,690✔
97
      "num_hex": { regex: /&#x([0-9a-fA-F]{1,6});/g, val: (_, str) => fromCodePoint(str, 16, "&#x") },
1,690✔
98
    };
1,690✔
99
    this.addExternalEntities = addExternalEntities;
1,690✔
100
    this.parseXml = parseXml;
1,690✔
101
    this.parseTextData = parseTextData;
1,690✔
102
    this.resolveNameSpace = resolveNameSpace;
1,690✔
103
    this.buildAttributesMap = buildAttributesMap;
1,690✔
104
    this.isItStopNode = isItStopNode;
1,690✔
105
    this.replaceEntitiesValue = replaceEntitiesValue;
1,690✔
106
    this.readStopNodeData = readStopNodeData;
1,690✔
107
    this.saveTextToParentTag = saveTextToParentTag;
1,690✔
108
    this.addChild = addChild;
1,690✔
109
    this.ignoreAttributesFn = getIgnoreAttributesFn(this.options.ignoreAttributes)
1,690✔
110
    this.entityExpansionCount = 0;
1,690✔
111
    this.currentExpandedLength = 0;
1,690✔
112

1,690✔
113
    // Initialize path matcher for path-expression-matcher
1,690✔
114
    this.matcher = new Matcher();
1,690✔
115

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

1,690✔
120
    // Flag to track if current node is a stop node (optimization)
1,690✔
121
    this.isCurrentNodeStopNode = false;
1,690✔
122

1,690✔
123
    // Pre-compile stopNodes expressions
1,690✔
124
    const stopNodesOpts = this.options.stopNodes;
1,690✔
125
    if (stopNodesOpts && stopNodesOpts.length > 0) {
1,690✔
126
      this.stopNodeExpressions = [];
670✔
127
      for (let i = 0; i < stopNodesOpts.length; i++) {
670✔
128
        const stopNodeExp = stopNodesOpts[i];
1,255✔
129
        if (typeof stopNodeExp === 'string') {
1,255✔
130
          // Convert string to Expression object
200✔
131
          this.stopNodeExpressions.push(new Expression(stopNodeExp));
200✔
132
        } else if (stopNodeExp instanceof Expression) {
1,255✔
133
          // Already an Expression object
1,055✔
134
          this.stopNodeExpressions.push(stopNodeExp);
1,055✔
135
        }
1,055✔
136
      }
1,255✔
137
    }
670✔
138
  }
1,690✔
139

5✔
140
}
5✔
141

5✔
142
function addExternalEntities(externalEntities) {
1,690✔
143
  const entKeys = Object.keys(externalEntities);
1,690✔
144
  for (let i = 0; i < entKeys.length; i++) {
1,690✔
145
    const ent = entKeys[i];
15✔
146
    const escaped = ent.replace(/[.\-+*:]/g, '\\.');
15✔
147
    this.lastEntities[ent] = {
15✔
148
      regex: new RegExp("&" + escaped + ";", "g"),
15✔
149
      val: externalEntities[ent]
15✔
150
    }
15✔
151
  }
15✔
152
}
1,690✔
153

5✔
154
/**
5✔
155
 * @param {string} val
5✔
156
 * @param {string} tagName
5✔
157
 * @param {string|Matcher} jPath - jPath string or Matcher instance based on options.jPath
5✔
158
 * @param {boolean} dontTrim
5✔
159
 * @param {boolean} hasAttributes
5✔
160
 * @param {boolean} isLeafNode
5✔
161
 * @param {boolean} escapeEntities
5✔
162
 */
5✔
163
function parseTextData(val, tagName, jPath, dontTrim, hasAttributes, isLeafNode, escapeEntities) {
6,695✔
164
  const options = this.options;
6,695✔
165
  if (val !== undefined) {
6,695✔
166
    if (options.trimValues && !dontTrim) {
6,695✔
167
      val = val.trim();
6,450✔
168
    }
6,450✔
169
    if (val.length > 0) {
6,695✔
170
      if (!escapeEntities) val = this.replaceEntitiesValue(val, tagName, jPath);
2,010✔
171

1,975✔
172
      // Pass jPath string or matcher based on options.jPath setting
1,975✔
173
      const jPathOrMatcher = options.jPath ? jPath.toString() : jPath;
2,010✔
174
      const newval = options.tagValueProcessor(tagName, val, jPathOrMatcher, hasAttributes, isLeafNode);
2,010✔
175
      if (newval === null || newval === undefined) {
2,010✔
176
        //don't parse
50✔
177
        return val;
50✔
178
      } else if (typeof newval !== typeof val || newval !== val) {
2,010✔
179
        //overwrite
20✔
180
        return newval;
20✔
181
      } else if (options.trimValues) {
1,925✔
182
        return parseValue(val, options.parseTagValue, options.numberParseOptions);
1,845✔
183
      } else {
1,905✔
184
        const trimmedVal = val.trim();
60✔
185
        if (trimmedVal === val) {
60✔
186
          return parseValue(val, options.parseTagValue, options.numberParseOptions);
15✔
187
        } else {
60✔
188
          return val;
45✔
189
        }
45✔
190
      }
60✔
191
    }
2,010✔
192
  }
6,695✔
193
}
6,695✔
194

5✔
195
function resolveNameSpace(tagname) {
2,880✔
196
  if (this.options.removeNSPrefix) {
2,880✔
197
    const tags = tagname.split(':');
220✔
198
    const prefix = tagname.charAt(0) === '/' ? '/' : '';
220!
199
    if (tags[0] === 'xmlns') {
220✔
200
      return '';
70✔
201
    }
70✔
202
    if (tags.length === 2) {
220✔
203
      tagname = prefix + tags[1];
60✔
204
    }
60✔
205
  }
220✔
206
  return tagname;
2,810✔
207
}
2,880✔
208

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

5✔
213
function buildAttributesMap(attrStr, jPath, tagName) {
1,180✔
214
  const options = this.options;
1,180✔
215
  if (options.ignoreAttributes !== true && typeof attrStr === 'string') {
1,180✔
216
    // attrStr = attrStr.replace(/\r?\n/g, ' ');
990✔
217
    //attrStr = attrStr || attrStr.trim();
990✔
218

990✔
219
    const matches = getAllMatches(attrStr, attrsRegx);
990✔
220
    const len = matches.length; //don't make it inline
990✔
221
    const attrs = {};
990✔
222

990✔
223
    // Pre-process values once: trim + entity replacement
990✔
224
    // Reused in both matcher update and second pass
990✔
225
    const processedVals = new Array(len);
990✔
226
    let hasRawAttrs = false;
990✔
227
    const rawAttrsForMatcher = {};
990✔
228

990✔
229
    for (let i = 0; i < len; i++) {
990✔
230
      const attrName = this.resolveNameSpace(matches[i][1]);
1,440✔
231
      const oldVal = matches[i][4];
1,440✔
232

1,440✔
233
      if (attrName.length && oldVal !== undefined) {
1,440✔
234
        let val = oldVal;
1,315✔
235
        if (options.trimValues) val = val.trim();
1,315✔
236
        val = this.replaceEntitiesValue(val, tagName, this.readonlyMatcher);
1,315✔
237
        processedVals[i] = val;
1,315✔
238

1,315✔
239
        rawAttrsForMatcher[attrName] = val;
1,315✔
240
        hasRawAttrs = true;
1,315✔
241
      }
1,315✔
242
    }
1,440✔
243

990✔
244
    // Update matcher ONCE before second pass, if applicable
990✔
245
    if (hasRawAttrs && typeof jPath === 'object' && jPath.updateCurrent) {
990✔
246
      jPath.updateCurrent(rawAttrsForMatcher);
870✔
247
    }
870✔
248

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

990✔
252
    // Second pass: apply processors, build final attrs
990✔
253
    let hasAttrs = false;
990✔
254
    for (let i = 0; i < len; i++) {
990✔
255
      const attrName = this.resolveNameSpace(matches[i][1]);
1,440✔
256

1,440✔
257
      if (this.ignoreAttributesFn(attrName, jPathStr)) continue;
1,440✔
258

1,385✔
259
      let aName = options.attributeNamePrefix + attrName;
1,385✔
260

1,385✔
261
      if (attrName.length) {
1,440✔
262
        if (options.transformAttributeName) {
1,350✔
263
          aName = options.transformAttributeName(aName);
20✔
264
        }
20✔
265
        aName = sanitizeName(aName, options);
1,350✔
266

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

1,245✔
271
          const newVal = options.attributeValueProcessor(attrName, oldVal, jPathStr);
1,245✔
272
          if (newVal === null || newVal === undefined) {
1,245!
UNCOV
273
            attrs[aName] = oldVal;
×
274
          } else if (typeof newVal !== typeof oldVal || newVal !== oldVal) {
1,245✔
275
            attrs[aName] = newVal;
5✔
276
          } else {
1,245✔
277
            attrs[aName] = parseValue(oldVal, options.parseAttributeValue, options.numberParseOptions);
1,240✔
278
          }
1,240✔
279
          hasAttrs = true;
1,245✔
280
        } else if (options.allowBooleanAttributes) {
1,350✔
281
          attrs[aName] = true;
80✔
282
          hasAttrs = true;
80✔
283
        }
80✔
284
      }
1,350✔
285
    }
1,440✔
286

975✔
287
    if (!hasAttrs) return;
990✔
288

880✔
289
    if (options.attributesGroupName) {
990✔
290
      const attrCollection = {};
60✔
291
      attrCollection[options.attributesGroupName] = attrs;
60✔
292
      return attrCollection;
60✔
293
    }
60✔
294
    return attrs;
820✔
295
  }
820✔
296
}
1,180✔
297
const parseXml = function (xmlData) {
5✔
298
  xmlData = xmlData.replace(/\r\n?/g, "\n"); //TODO: remove this line
1,690✔
299
  const xmlObj = new xmlNode('!xml');
1,690✔
300
  let currentNode = xmlObj;
1,690✔
301
  let textData = "";
1,690✔
302

1,690✔
303
  // Reset matcher for new document
1,690✔
304
  this.matcher.reset();
1,690✔
305

1,690✔
306
  // Reset entity expansion counters for this document
1,690✔
307
  this.entityExpansionCount = 0;
1,690✔
308
  this.currentExpandedLength = 0;
1,690✔
309
  this.docTypeEntitiesKeys = [];
1,690✔
310
  this.lastEntitiesKeys = Object.keys(this.lastEntities);
1,690✔
311
  this.htmlEntitiesKeys = this.options.htmlEntities ? Object.keys(this.htmlEntities) : [];
1,690✔
312
  const options = this.options;
1,690✔
313
  const docTypeReader = new DocTypeReader(options.processEntities);
1,690✔
314
  const xmlLen = xmlData.length;
1,690✔
315
  for (let i = 0; i < xmlLen; i++) {//for each char in XML data
1,690✔
316
    const ch = xmlData[i];
325,615✔
317
    if (ch === '<') {
325,615✔
318
      // const nextIndex = i+1;
11,230✔
319
      // const _2ndChar = xmlData[nextIndex];
11,230✔
320
      const c1 = xmlData.charCodeAt(i + 1);
11,230✔
321
      if (c1 === 47) {//Closing Tag '/'
11,230✔
322
        const closeIndex = findClosingIndex(xmlData, ">", i, "Closing Tag is not closed.")
4,080✔
323
        let tagName = xmlData.substring(i + 2, closeIndex).trim();
4,080✔
324

4,080✔
325
        if (options.removeNSPrefix) {
4,080✔
326
          const colonIndex = tagName.indexOf(":");
115✔
327
          if (colonIndex !== -1) {
115✔
328
            tagName = tagName.substr(colonIndex + 1);
50✔
329
          }
50✔
330
        }
115✔
331

4,075✔
332
        tagName = transformTagName(options.transformTagName, tagName, "", options).tagName;
4,075✔
333

4,075✔
334
        if (currentNode) {
4,075✔
335
          textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
4,075✔
336
        }
4,075✔
337

4,040✔
338
        //check if last tag of nested tag was unpaired tag
4,040✔
339
        const lastTagName = this.matcher.getCurrentTag();
4,040✔
340
        if (tagName && options.unpairedTagsSet.has(tagName)) {
4,080✔
341
          throw new Error(`Unpaired tag can not be used as closing tag: </${tagName}>`);
5✔
342
        }
5✔
343
        if (lastTagName && options.unpairedTagsSet.has(lastTagName)) {
4,080!
UNCOV
344
          // Pop the unpaired tag
×
UNCOV
345
          this.matcher.pop();
×
UNCOV
346
          this.tagsNodeStack.pop();
×
UNCOV
347
        }
×
348
        // Pop the closing tag
4,035✔
349
        this.matcher.pop();
4,035✔
350
        this.isCurrentNodeStopNode = false; // Reset flag when closing tag
4,035✔
351

4,035✔
352
        currentNode = this.tagsNodeStack.pop();//avoid recursion, set the parent tag scope
4,035✔
353
        textData = "";
4,035✔
354
        i = closeIndex;
4,035✔
355
      } else if (c1 === 63) { //'?'
11,230✔
356

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

235✔
360
        textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
235✔
361
        if ((options.ignoreDeclaration && tagData.tagName === "?xml") || options.ignorePiTags) {
245✔
362
          //do nothing
25✔
363
        } else {
245✔
364

210✔
365
          const childNode = new xmlNode(tagData.tagName);
210✔
366
          childNode.add(options.textNodeName, "");
210✔
367

210✔
368
          if (tagData.tagName !== tagData.tagExp && tagData.attrExpPresent) {
210✔
369
            childNode[":@"] = this.buildAttributesMap(tagData.tagExp, this.matcher, tagData.tagName);
200✔
370
          }
200✔
371
          this.addChild(currentNode, childNode, this.readonlyMatcher, i);
210✔
372
        }
210✔
373

235✔
374

235✔
375
        i = tagData.closeIndex + 1;
235✔
376
      } else if (c1 === 33
7,150✔
377
        && xmlData.charCodeAt(i + 2) === 45
6,905✔
378
        && xmlData.charCodeAt(i + 3) === 45) { //'!--'
6,905✔
379
        const endIndex = findClosingIndex(xmlData, "-->", i + 4, "Comment is not closed.")
60✔
380
        if (options.commentPropName) {
60✔
381
          const comment = xmlData.substring(i + 4, endIndex - 2);
25✔
382

25✔
383
          textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
25✔
384

25✔
385
          currentNode.add(options.commentPropName, [{ [options.textNodeName]: comment }]);
25✔
386
        }
25✔
387
        i = endIndex;
55✔
388
      } else if (c1 === 33
6,905✔
389
        && xmlData.charCodeAt(i + 2) === 68) { //'!D'
6,845✔
390
        const result = docTypeReader.readDocType(xmlData, i);
220✔
391
        this.docTypeEntities = result.entities;
220✔
392
        this.docTypeEntitiesKeys = Object.keys(this.docTypeEntities) || []
220!
393
        i = result.i;
220✔
394
      } else if (c1 === 33
6,845✔
395
        && xmlData.charCodeAt(i + 2) === 91) { // '!['
6,625✔
396
        const closeIndex = findClosingIndex(xmlData, "]]>", i, "CDATA is not closed.") - 2;
190✔
397
        const tagExp = xmlData.substring(i + 9, closeIndex);
190✔
398

190✔
399
        textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
190✔
400

190✔
401
        let val = this.parseTextData(tagExp, currentNode.tagname, this.readonlyMatcher, true, false, true, true);
190✔
402
        if (val == undefined) val = "";
190✔
403

185✔
404
        //cdata should be set even if it is 0 length string
185✔
405
        if (options.cdataPropName) {
190✔
406
          currentNode.add(options.cdataPropName, [{ [options.textNodeName]: tagExp }]);
35✔
407
        } else {
190✔
408
          currentNode.add(options.textNodeName, val);
150✔
409
        }
150✔
410

185✔
411
        i = closeIndex + 2;
185✔
412
      } else {//Opening tag
6,625✔
413
        let result = readTagExp(xmlData, i, options.removeNSPrefix);
6,435✔
414

6,435✔
415
        // Safety check: readTagExp can return undefined
6,435✔
416
        if (!result) {
6,435!
UNCOV
417
          // Log context for debugging
×
UNCOV
418
          const context = xmlData.substring(Math.max(0, i - 50), Math.min(xmlLen, i + 50));
×
UNCOV
419
          throw new Error(`readTagExp returned undefined at position ${i}. Context: "${context}"`);
×
UNCOV
420
        }
×
421

6,435✔
422
        let tagName = result.tagName;
6,435✔
423
        const rawTagName = result.rawTagName;
6,435✔
424
        let tagExp = result.tagExp;
6,435✔
425
        let attrExpPresent = result.attrExpPresent;
6,435✔
426
        let closeIndex = result.closeIndex;
6,435✔
427

6,435✔
428
        ({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options));
6,435✔
429

6,435✔
430
        if (options.strictReservedNames &&
6,435✔
431
          (tagName === options.commentPropName
6,375✔
432
            || tagName === options.cdataPropName
6,375✔
433
            || tagName === options.textNodeName
6,375✔
434
            || tagName === options.attributesGroupName
6,375✔
435
          )) {
6,435✔
436
          throw new Error(`Invalid tag name: ${tagName}`);
5✔
437
        }
5✔
438

6,380✔
439
        //save text as child node
6,380✔
440
        if (currentNode && textData) {
6,435✔
441
          if (currentNode.tagname !== '!xml') {
4,105✔
442
            //when nested tag is found
3,225✔
443
            textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher, false);
3,225✔
444
          }
3,225✔
445
        }
4,105✔
446

6,380✔
447
        //check if last tag was unpaired tag
6,380✔
448
        const lastTag = currentNode;
6,380✔
449
        if (lastTag && options.unpairedTagsSet.has(lastTag.tagname)) {
6,435!
UNCOV
450
          currentNode = this.tagsNodeStack.pop();
×
UNCOV
451
          this.matcher.pop();
×
UNCOV
452
        }
×
453

6,380✔
454
        // Clean up self-closing syntax BEFORE processing attributes
6,380✔
455
        // This is where tagExp gets the trailing / removed
6,380✔
456
        let isSelfClosing = false;
6,380✔
457
        if (tagExp.length > 0 && tagExp.lastIndexOf("/") === tagExp.length - 1) {
6,435✔
458
          isSelfClosing = true;
280✔
459
          if (tagName[tagName.length - 1] === "/") {
280✔
460
            tagName = tagName.substr(0, tagName.length - 1);
100✔
461
            tagExp = tagName;
100✔
462
          } else {
280✔
463
            tagExp = tagExp.substr(0, tagExp.length - 1);
180✔
464
          }
180✔
465

280✔
466
          // Re-check attrExpPresent after cleaning
280✔
467
          attrExpPresent = (tagName !== tagExp);
280✔
468
        }
280✔
469

6,380✔
470
        // Now process attributes with CLEAN tagExp (no trailing /)
6,380✔
471
        let prefixedAttrs = null;
6,380✔
472
        let rawAttrs = {};
6,380✔
473
        let namespace = undefined;
6,380✔
474

6,380✔
475
        // Extract namespace from rawTagName
6,380✔
476
        namespace = extractNamespace(rawTagName);
6,380✔
477

6,380✔
478
        // Push tag to matcher FIRST (with empty attrs for now) so callbacks see correct path
6,380✔
479
        if (tagName !== xmlObj.tagname) {
6,380✔
480
          this.matcher.push(tagName, {}, namespace);
6,380✔
481
        }
6,380✔
482

6,380✔
483
        // Now build attributes - callbacks will see correct matcher state
6,380✔
484
        if (tagName !== tagExp && attrExpPresent) {
6,435✔
485
          // Build attributes (returns prefixed attributes for the tree)
980✔
486
          // Note: buildAttributesMap now internally updates the matcher with raw attributes
980✔
487
          prefixedAttrs = this.buildAttributesMap(tagExp, this.matcher, tagName);
980✔
488

980✔
489
          if (prefixedAttrs) {
980✔
490
            // Extract raw attributes (without prefix) for our use
760✔
491
            rawAttrs = extractRawAttributes(prefixedAttrs, options);
760✔
492
          }
760✔
493
        }
980✔
494

6,365✔
495
        // Now check if this is a stop node (after attributes are set)
6,365✔
496
        if (tagName !== xmlObj.tagname) {
6,365✔
497
          this.isCurrentNodeStopNode = this.isItStopNode(this.stopNodeExpressions, this.matcher);
6,365✔
498
        }
6,365✔
499

6,365✔
500
        const startIndex = i;
6,365✔
501
        if (this.isCurrentNodeStopNode) {
6,435✔
502
          let tagContent = "";
1,225✔
503

1,225✔
504
          // For self-closing tags, content is empty
1,225✔
505
          if (isSelfClosing) {
1,225✔
506
            i = result.closeIndex;
10✔
507
          }
10✔
508
          //unpaired tag
1,215✔
509
          else if (options.unpairedTagsSet.has(tagName)) {
1,215✔
510
            i = result.closeIndex;
5✔
511
          }
5✔
512
          //normal tag
1,210✔
513
          else {
1,210✔
514
            //read until closing tag is found
1,210✔
515
            const result = this.readStopNodeData(xmlData, rawTagName, closeIndex + 1);
1,210✔
516
            if (!result) throw new Error(`Unexpected end of ${rawTagName}`);
1,210!
517
            i = result.i;
1,210✔
518
            tagContent = result.tagContent;
1,210✔
519
          }
1,210✔
520

1,225✔
521
          const childNode = new xmlNode(tagName);
1,225✔
522

1,225✔
523
          if (prefixedAttrs) {
1,225✔
524
            childNode[":@"] = prefixedAttrs;
20✔
525
          }
20✔
526

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

1,225✔
530
          this.matcher.pop(); // Pop the stop node tag
1,225✔
531
          this.isCurrentNodeStopNode = false; // Reset flag
1,225✔
532

1,225✔
533
          this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
1,225✔
534
        } else {
6,435✔
535
          //selfClosing tag
5,140✔
536
          if (isSelfClosing) {
5,140✔
537
            ({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options));
270✔
538

270✔
539
            const childNode = new xmlNode(tagName);
270✔
540
            if (prefixedAttrs) {
270✔
541
              childNode[":@"] = prefixedAttrs;
90✔
542
            }
90✔
543
            this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
270✔
544
            this.matcher.pop(); // Pop self-closing tag
270✔
545
            this.isCurrentNodeStopNode = false; // Reset flag
270✔
546
          }
270✔
547
          else if (options.unpairedTagsSet.has(tagName)) {//unpaired tag
4,870✔
548
            const childNode = new xmlNode(tagName);
210✔
549
            if (prefixedAttrs) {
210✔
550
              childNode[":@"] = prefixedAttrs;
90✔
551
            }
90✔
552
            this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
210✔
553
            this.matcher.pop(); // Pop unpaired tag
210✔
554
            this.isCurrentNodeStopNode = false; // Reset flag
210✔
555
            i = result.closeIndex;
210✔
556
            // Continue to next iteration without changing currentNode
210✔
557
            continue;
210✔
558
          }
210✔
559
          //opening tag
4,660✔
560
          else {
4,660✔
561
            const childNode = new xmlNode(tagName);
4,660✔
562
            if (this.tagsNodeStack.length > options.maxNestedTags) {
4,660✔
563
              throw new Error("Maximum nested tags exceeded");
5✔
564
            }
5✔
565
            this.tagsNodeStack.push(currentNode);
4,655✔
566

4,655✔
567
            if (prefixedAttrs) {
4,660✔
568
              childNode[":@"] = prefixedAttrs;
560✔
569
            }
560✔
570
            this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
4,655✔
571
            currentNode = childNode;
4,655✔
572
          }
4,655✔
573
          textData = "";
4,925✔
574
          i = closeIndex;
4,925✔
575
        }
4,925✔
576
      }
6,435✔
577
    } else {
325,615✔
578
      textData += xmlData[i];
314,385✔
579
    }
314,385✔
580
  }
325,615✔
581
  return xmlObj.child;
1,525✔
582
}
1,690✔
583

5✔
584
function addChild(currentNode, childNode, matcher, startIndex) {
6,570✔
585
  // unset startIndex if not requested
6,570✔
586
  if (!this.options.captureMetaData) startIndex = undefined;
6,570✔
587

6,570✔
588
  // Pass jPath string or matcher based on options.jPath setting
6,570✔
589
  const jPathOrMatcher = this.options.jPath ? matcher.toString() : matcher;
6,570✔
590
  const result = this.options.updateTag(childNode.tagname, jPathOrMatcher, childNode[":@"])
6,570✔
591
  if (result === false) {
6,570✔
592
    //do nothing
25✔
593
  } else if (typeof result === "string") {
6,570✔
594
    childNode.tagname = result
6,545✔
595
    currentNode.addChild(childNode, startIndex);
6,545✔
596
  } else {
6,545!
UNCOV
597
    currentNode.addChild(childNode, startIndex);
×
UNCOV
598
  }
×
599
}
6,570✔
600

5✔
601
/**
5✔
602
 * @param {object} val - Entity object with regex and val properties
5✔
603
 * @param {string} tagName - Tag name
5✔
604
 * @param {string|Matcher} jPath - jPath string or Matcher instance based on options.jPath
5✔
605
 */
5✔
606
function replaceEntitiesValue(val, tagName, jPath) {
3,160✔
607
  const entityConfig = this.options.processEntities;
3,160✔
608

3,160✔
609
  if (!entityConfig || !entityConfig.enabled) {
3,160✔
610
    return val;
60✔
611
  }
60✔
612

3,100✔
613
  // Check if tag is allowed to contain entities
3,100✔
614
  if (entityConfig.allowedTags) {
3,160✔
615
    const jPathOrMatcher = this.options.jPath ? jPath.toString() : jPath;
30!
616
    const allowed = Array.isArray(entityConfig.allowedTags)
30✔
617
      ? entityConfig.allowedTags.includes(tagName)
30✔
618
      : entityConfig.allowedTags(tagName, jPathOrMatcher);
30!
619

30✔
620
    if (!allowed) {
30✔
621
      return val;
10✔
622
    }
10✔
623
  }
30✔
624

3,090✔
625
  // Apply custom tag filter if provided
3,090✔
626
  if (entityConfig.tagFilter) {
3,160✔
627
    const jPathOrMatcher = this.options.jPath ? jPath.toString() : jPath;
25!
628
    if (!entityConfig.tagFilter(tagName, jPathOrMatcher)) {
25✔
629
      return val; // Skip based on custom filter
15✔
630
    }
15✔
631
  }
25✔
632

3,075✔
633
  // Replace DOCTYPE entities
3,075✔
634
  for (const entityName of this.docTypeEntitiesKeys) {
3,160✔
635
    const entity = this.docTypeEntities[entityName];
405✔
636
    const matches = val.match(entity.regx);
405✔
637

405✔
638
    if (matches) {
405✔
639
      // Track expansions
215✔
640
      this.entityExpansionCount += matches.length;
215✔
641

215✔
642
      // Check expansion limit
215✔
643
      if (entityConfig.maxTotalExpansions &&
215✔
644
        this.entityExpansionCount > entityConfig.maxTotalExpansions) {
215✔
645
        throw new Error(
15✔
646
          `Entity expansion limit exceeded: ${this.entityExpansionCount} > ${entityConfig.maxTotalExpansions}`
15✔
647
        );
15✔
648
      }
15✔
649

200✔
650
      // Store length before replacement
200✔
651
      const lengthBefore = val.length;
200✔
652
      val = val.replace(entity.regx, entity.val);
200✔
653

200✔
654
      // Check expanded length immediately after replacement
200✔
655
      if (entityConfig.maxExpandedLength) {
200✔
656
        this.currentExpandedLength += (val.length - lengthBefore);
200✔
657

200✔
658
        if (this.currentExpandedLength > entityConfig.maxExpandedLength) {
200✔
659
          throw new Error(
15✔
660
            `Total expanded content size exceeded: ${this.currentExpandedLength} > ${entityConfig.maxExpandedLength}`
15✔
661
          );
15✔
662
        }
15✔
663
      }
200✔
664
    }
215✔
665
  }
405✔
666
  if (val.indexOf('&') === -1) return val;
3,160✔
667
  // Replace standard entities
125✔
668
  for (const entityName of this.lastEntitiesKeys) {
3,160✔
669
    const entity = this.lastEntities[entityName];
505✔
670
    const matches = val.match(entity.regex);
505✔
671
    if (matches) {
505✔
672
      this.entityExpansionCount += matches.length;
85✔
673
      if (entityConfig.maxTotalExpansions &&
85✔
674
        this.entityExpansionCount > entityConfig.maxTotalExpansions) {
85!
UNCOV
675
        throw new Error(
×
UNCOV
676
          `Entity expansion limit exceeded: ${this.entityExpansionCount} > ${entityConfig.maxTotalExpansions}`
×
UNCOV
677
        );
×
UNCOV
678
      }
×
679
    }
85✔
680
    val = val.replace(entity.regex, entity.val);
505✔
681
  }
505✔
682
  if (val.indexOf('&') === -1) return val;
3,160✔
683

95✔
684
  // Replace HTML entities if enabled
95✔
685
  for (const entityName of this.htmlEntitiesKeys) {
3,160✔
686
    const entity = this.htmlEntities[entityName];
445✔
687
    const matches = val.match(entity.regex);
445✔
688
    if (matches) {
445✔
689
      //console.log(matches);
50✔
690
      this.entityExpansionCount += matches.length;
50✔
691
      if (entityConfig.maxTotalExpansions &&
50✔
692
        this.entityExpansionCount > entityConfig.maxTotalExpansions) {
50✔
693
        throw new Error(
5✔
694
          `Entity expansion limit exceeded: ${this.entityExpansionCount} > ${entityConfig.maxTotalExpansions}`
5✔
695
        );
5✔
696
      }
5✔
697
    }
50✔
698
    val = val.replace(entity.regex, entity.val);
440✔
699
  }
440✔
700

90✔
701
  // Replace ampersand entity last
90✔
702
  val = val.replace(this.ampEntity.regex, this.ampEntity.val);
90✔
703

90✔
704
  return val;
90✔
705
}
3,160✔
706

5✔
707

5✔
708
function saveTextToParentTag(textData, parentNode, matcher, isLeafNode) {
7,745✔
709
  if (textData) { //store previously collected data as textNode
7,745✔
710
    if (isLeafNode === undefined) isLeafNode = parentNode.child.length === 0
6,510✔
711

6,510✔
712
    textData = this.parseTextData(textData,
6,510✔
713
      parentNode.tagname,
6,510✔
714
      matcher,
6,510✔
715
      false,
6,510✔
716
      parentNode[":@"] ? Object.keys(parentNode[":@"]).length !== 0 : false,
6,510!
717
      isLeafNode);
6,510✔
718

6,510✔
719
    if (textData !== undefined && textData !== "")
6,510✔
720
      parentNode.add(this.options.textNodeName, textData);
6,510✔
721
    textData = "";
6,475✔
722
  }
6,475✔
723
  return textData;
7,710✔
724
}
7,745✔
725

5✔
726
/**
5✔
727
 * @param {Array<Expression>} stopNodeExpressions - Array of compiled Expression objects
5✔
728
 * @param {Matcher} matcher - Current path matcher
5✔
729
 */
5✔
730
function isItStopNode(stopNodeExpressions, matcher) {
6,365✔
731
  if (!stopNodeExpressions || stopNodeExpressions.length === 0) return false;
6,365✔
732

2,220✔
733
  for (let i = 0; i < stopNodeExpressions.length; i++) {
6,365✔
734
    if (matcher.matches(stopNodeExpressions[i])) {
3,505✔
735
      return true;
1,225✔
736
    }
1,225✔
737
  }
3,505✔
738
  return false;
995✔
739
}
6,365✔
740

5✔
741
/**
5✔
742
 * Returns the tag Expression and where it is ending handling single-double quotes situation
5✔
743
 * @param {string} xmlData 
5✔
744
 * @param {number} i starting index
5✔
745
 * @returns 
5✔
746
 */
5✔
747
function tagExpWithClosingIndex(xmlData, i, closingChar = ">") {
6,855✔
748
  let attrBoundary = 0;
6,855✔
749
  const chars = [];
6,855✔
750
  const len = xmlData.length;
6,855✔
751
  const closeCode0 = closingChar.charCodeAt(0);
6,855✔
752
  const closeCode1 = closingChar.length > 1 ? closingChar.charCodeAt(1) : -1;
6,855✔
753

6,855✔
754
  for (let index = i; index < len; index++) {
6,855✔
755
    const code = xmlData.charCodeAt(index);
67,695✔
756

67,695✔
757
    if (attrBoundary) {
67,695✔
758
      if (code === attrBoundary) attrBoundary = 0;
14,510✔
759
    } else if (code === 34 || code === 39) { // " or '
67,695✔
760
      attrBoundary = code;
1,580✔
761
    } else if (code === closeCode0) {
53,185✔
762
      if (closeCode1 !== -1) {
7,085✔
763
        if (xmlData.charCodeAt(index + 1) === closeCode1) {
480✔
764
          return { data: String.fromCharCode(...chars), index };
235✔
765
        }
235✔
766
      } else {
7,085✔
767
        return { data: String.fromCharCode(...chars), index };
6,605✔
768
      }
6,605✔
769
    } else if (code === 9) { // \t
51,605✔
770
      chars.push(32); // space
20✔
771
      continue;
20✔
772
    }
20✔
773

60,835✔
774
    chars.push(code);
60,835✔
775
  }
60,835✔
776
}
6,855✔
777

5✔
778
function findClosingIndex(xmlData, str, i, errMsg) {
4,340✔
779
  const closingIndex = xmlData.indexOf(str, i);
4,340✔
780
  if (closingIndex === -1) {
4,340✔
781
    throw new Error(errMsg)
15✔
782
  } else {
4,340✔
783
    return closingIndex + str.length - 1;
4,325✔
784
  }
4,325✔
785
}
4,340✔
786

5✔
787
function findClosingChar(xmlData, char, i, errMsg) {
1,370✔
788
  const closingIndex = xmlData.indexOf(char, i);
1,370✔
789
  if (closingIndex === -1) throw new Error(errMsg);
1,370!
790
  return closingIndex; // no offset needed
1,370✔
791
}
1,370✔
792

5✔
793
function readTagExp(xmlData, i, removeNSPrefix, closingChar = ">") {
6,855✔
794
  const result = tagExpWithClosingIndex(xmlData, i + 1, closingChar);
6,855✔
795
  if (!result) return;
6,855✔
796
  let tagExp = result.data;
6,840✔
797
  const closeIndex = result.index;
6,840✔
798
  const separatorIndex = tagExp.search(/\s/);
6,840✔
799
  let tagName = tagExp;
6,840✔
800
  let attrExpPresent = true;
6,840✔
801
  if (separatorIndex !== -1) {//separate tag name and attributes expression
6,855✔
802
    tagName = tagExp.substring(0, separatorIndex);
1,230✔
803
    tagExp = tagExp.substring(separatorIndex + 1).trimStart();
1,230✔
804
  }
1,230✔
805

6,840✔
806
  const rawTagName = tagName;
6,840✔
807
  if (removeNSPrefix) {
6,855✔
808
    const colonIndex = tagName.indexOf(":");
300✔
809
    if (colonIndex !== -1) {
300✔
810
      tagName = tagName.substr(colonIndex + 1);
65✔
811
      attrExpPresent = tagName !== result.data.substr(colonIndex + 1);
65✔
812
    }
65✔
813
  }
300✔
814

6,840✔
815
  return {
6,840✔
816
    tagName: tagName,
6,840✔
817
    tagExp: tagExp,
6,840✔
818
    closeIndex: closeIndex,
6,840✔
819
    attrExpPresent: attrExpPresent,
6,840✔
820
    rawTagName: rawTagName,
6,840✔
821
  }
6,840✔
822
}
6,855✔
823
/**
5✔
824
 * find paired tag for a stop node
5✔
825
 * @param {string} xmlData 
5✔
826
 * @param {string} tagName 
5✔
827
 * @param {number} i 
5✔
828
 */
5✔
829
function readStopNodeData(xmlData, tagName, i) {
1,210✔
830
  const startIndex = i;
1,210✔
831
  // Starting at 1 since we already have an open tag
1,210✔
832
  let openTagCount = 1;
1,210✔
833

1,210✔
834
  const xmllen = xmlData.length;
1,210✔
835
  for (; i < xmllen; i++) {
1,210✔
836
    if (xmlData[i] === "<") {
15,985✔
837
      const c1 = xmlData.charCodeAt(i + 1);
1,555✔
838
      if (c1 === 47) {//close tag '/'
1,555✔
839
        const closeIndex = findClosingChar(xmlData, ">", i, `${tagName} is not closed`);
1,370✔
840
        let closeTagName = xmlData.substring(i + 2, closeIndex).trim();
1,370✔
841
        if (closeTagName === tagName) {
1,370✔
842
          openTagCount--;
1,215✔
843
          if (openTagCount === 0) {
1,215✔
844
            return {
1,210✔
845
              tagContent: xmlData.substring(startIndex, i),
1,210✔
846
              i: closeIndex
1,210✔
847
            }
1,210✔
848
          }
1,210✔
849
        }
1,215✔
850
        i = closeIndex;
160✔
851
      } else if (c1 === 63) { //?
1,555!
UNCOV
852
        const closeIndex = findClosingIndex(xmlData, "?>", i + 1, "StopNode is not closed.")
×
UNCOV
853
        i = closeIndex;
×
854
      } else if (c1 === 33
185✔
855
        && xmlData.charCodeAt(i + 2) === 45
185✔
856
        && xmlData.charCodeAt(i + 3) === 45) { // '!--'
185✔
857
        const closeIndex = findClosingIndex(xmlData, "-->", i + 3, "StopNode is not closed.")
5✔
858
        i = closeIndex;
5✔
859
      } else if (c1 === 33
185✔
860
        && xmlData.charCodeAt(i + 2) === 91) { // '!['
180✔
861
        const closeIndex = findClosingIndex(xmlData, "]]>", i, "StopNode is not closed.") - 2;
5✔
862
        i = closeIndex;
5✔
863
      } else {
180✔
864
        const tagData = readTagExp(xmlData, i, '>')
175✔
865

175✔
866
        if (tagData) {
175✔
867
          const openTagName = tagData && tagData.tagName;
170✔
868
          if (openTagName === tagName && tagData.tagExp[tagData.tagExp.length - 1] !== "/") {
170✔
869
            openTagCount++;
5✔
870
          }
5✔
871
          i = tagData.closeIndex;
170✔
872
        }
170✔
873
      }
175✔
874
    }
1,555✔
875
  }//end for loop
1,210✔
876
}
1,210✔
877

5✔
878
function parseValue(val, shouldParse, options) {
3,100✔
879
  if (shouldParse && typeof val === 'string') {
3,100✔
880
    //console.log(options)
2,080✔
881
    const newval = val.trim();
2,080✔
882
    if (newval === 'true') return true;
2,080✔
883
    else if (newval === 'false') return false;
2,045✔
884
    else return toNumber(val, options);
2,030✔
885
  } else {
3,100✔
886
    if (isExist(val)) {
1,020✔
887
      return val;
1,020✔
888
    } else {
1,020!
UNCOV
889
      return '';
×
UNCOV
890
    }
×
891
  }
1,020✔
892
}
3,100✔
893

5✔
894
function fromCodePoint(str, base, prefix) {
50✔
895
  const codePoint = Number.parseInt(str, base);
50✔
896

50✔
897
  if (codePoint >= 0 && codePoint <= 0x10FFFF) {
50✔
898
    return String.fromCodePoint(codePoint);
40✔
899
  } else {
50✔
900
    return prefix + str + ";";
10✔
901
  }
10✔
902
}
50✔
903

5✔
904
function transformTagName(fn, tagName, tagExp, options) {
10,780✔
905
  if (fn) {
10,780✔
906
    const newTagName = fn(tagName);
160✔
907
    if (tagExp === tagName) {
160✔
908
      tagExp = newTagName
55✔
909
    }
55✔
910
    tagName = newTagName;
160✔
911
  }
160✔
912
  tagName = sanitizeName(tagName, options);
10,780✔
913
  return { tagName, tagExp };
10,780✔
914
}
10,780✔
915

5✔
916

5✔
917

5✔
918
function sanitizeName(name, options) {
12,130✔
919
  if (criticalProperties.includes(name)) {
12,130✔
920
    throw new Error(`[SECURITY] Invalid name: "${name}" is a reserved JavaScript keyword that could cause prototype pollution`);
30✔
921
  } else if (DANGEROUS_PROPERTY_NAMES.includes(name)) {
12,130✔
922
    return options.onDangerousProperty(name);
105✔
923
  }
105✔
924
  return name;
11,995✔
925
}
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