835 lines
28 KiB
JavaScript
835 lines
28 KiB
JavaScript
'use strict';
|
|
///@ts-check
|
|
|
|
import { getAllMatches, isExist, DANGEROUS_PROPERTY_NAMES, criticalProperties } from '../util.js';
|
|
import xmlNode from './xmlNode.js';
|
|
import DocTypeReader from './DocTypeReader.js';
|
|
import toNumber from "strnum";
|
|
import getIgnoreAttributesFn from "../ignoreAttributes.js";
|
|
import { Expression, Matcher } from 'path-expression-matcher';
|
|
import { ExpressionSet } from 'path-expression-matcher';
|
|
import { EntityDecoder, XML, CURRENCY, COMMON_HTML } from '@nodable/entities';
|
|
|
|
// const regx =
|
|
// '<((!\\[CDATA\\[([\\s\\S]*?)(]]>))|((NAME:)?(NAME))([^>]*)>|((\\/)(NAME)\\s*>))([^<]*)'
|
|
// .replace(/NAME/g, util.nameRegexp);
|
|
|
|
//const tagsRegx = new RegExp("<(\\/?[\\w:\\-\._]+)([^>]*)>(\\s*"+cdataRegx+")*([^<]+)?","g");
|
|
//const tagsRegx = new RegExp("<(\\/?)((\\w*:)?([\\w:\\-\._]+))([^>]*)>([^<]*)("+cdataRegx+"([^<]*))*([^<]+)?","g");
|
|
|
|
// Helper functions for attribute and namespace handling
|
|
|
|
/**
|
|
* Extract raw attributes (without prefix) from prefixed attribute map
|
|
* @param {object} prefixedAttrs - Attributes with prefix from buildAttributesMap
|
|
* @param {object} options - Parser options containing attributeNamePrefix
|
|
* @returns {object} Raw attributes for matcher
|
|
*/
|
|
function extractRawAttributes(prefixedAttrs, options) {
|
|
if (!prefixedAttrs) return {};
|
|
|
|
// Handle attributesGroupName option
|
|
const attrs = options.attributesGroupName
|
|
? prefixedAttrs[options.attributesGroupName]
|
|
: prefixedAttrs;
|
|
|
|
if (!attrs) return {};
|
|
|
|
const rawAttrs = {};
|
|
for (const key in attrs) {
|
|
// Remove the attribute prefix to get raw name
|
|
if (key.startsWith(options.attributeNamePrefix)) {
|
|
const rawName = key.substring(options.attributeNamePrefix.length);
|
|
rawAttrs[rawName] = attrs[key];
|
|
} else {
|
|
// Attribute without prefix (shouldn't normally happen, but be safe)
|
|
rawAttrs[key] = attrs[key];
|
|
}
|
|
}
|
|
return rawAttrs;
|
|
}
|
|
|
|
/**
|
|
* Extract namespace from raw tag name
|
|
* @param {string} rawTagName - Tag name possibly with namespace (e.g., "soap:Envelope")
|
|
* @returns {string|undefined} Namespace or undefined
|
|
*/
|
|
function extractNamespace(rawTagName) {
|
|
if (!rawTagName || typeof rawTagName !== 'string') return undefined;
|
|
|
|
const colonIndex = rawTagName.indexOf(':');
|
|
if (colonIndex !== -1 && colonIndex > 0) {
|
|
const ns = rawTagName.substring(0, colonIndex);
|
|
// Don't treat xmlns as a namespace
|
|
if (ns !== 'xmlns') {
|
|
return ns;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export default class OrderedObjParser {
|
|
constructor(options) {
|
|
this.options = options;
|
|
this.currentNode = null;
|
|
this.tagsNodeStack = [];
|
|
this.parseXml = parseXml;
|
|
this.parseTextData = parseTextData;
|
|
this.resolveNameSpace = resolveNameSpace;
|
|
this.buildAttributesMap = buildAttributesMap;
|
|
this.isItStopNode = isItStopNode;
|
|
this.replaceEntitiesValue = replaceEntitiesValue;
|
|
this.readStopNodeData = readStopNodeData;
|
|
this.saveTextToParentTag = saveTextToParentTag;
|
|
this.addChild = addChild;
|
|
this.ignoreAttributesFn = getIgnoreAttributesFn(this.options.ignoreAttributes)
|
|
this.entityExpansionCount = 0;
|
|
this.currentExpandedLength = 0;
|
|
let namedEntities = { ...XML };
|
|
if (this.options.entityDecoder) {
|
|
this.entityDecoder = this.options.entityDecoder
|
|
} else {
|
|
if (typeof this.options.htmlEntities === "object") namedEntities = this.options.htmlEntities;
|
|
else if (this.options.htmlEntities === true) namedEntities = { ...COMMON_HTML, ...CURRENCY };
|
|
this.entityDecoder = new EntityDecoder({
|
|
namedEntities: namedEntities,
|
|
numericAllowed: this.options.htmlEntities,
|
|
limit: {
|
|
maxTotalExpansions: this.options.processEntities.maxTotalExpansions,
|
|
maxExpandedLength: this.options.processEntities.maxExpandedLength,
|
|
applyLimitsTo: this.options.processEntities.appliesTo,
|
|
}
|
|
//postCheck: resolved => resolved
|
|
});
|
|
}
|
|
|
|
// Initialize path matcher for path-expression-matcher
|
|
this.matcher = new Matcher();
|
|
|
|
// Live read-only proxy of matcher — PEM creates and caches this internally.
|
|
// All user callbacks receive this instead of the mutable matcher.
|
|
this.readonlyMatcher = this.matcher.readOnly();
|
|
|
|
// Flag to track if current node is a stop node (optimization)
|
|
this.isCurrentNodeStopNode = false;
|
|
|
|
// Pre-compile stopNodes expressions
|
|
this.stopNodeExpressionsSet = new ExpressionSet();
|
|
const stopNodesOpts = this.options.stopNodes;
|
|
if (stopNodesOpts && stopNodesOpts.length > 0) {
|
|
for (let i = 0; i < stopNodesOpts.length; i++) {
|
|
const stopNodeExp = stopNodesOpts[i];
|
|
if (typeof stopNodeExp === 'string') {
|
|
// Convert string to Expression object
|
|
this.stopNodeExpressionsSet.add(new Expression(stopNodeExp));
|
|
} else if (stopNodeExp instanceof Expression) {
|
|
// Already an Expression object
|
|
this.stopNodeExpressionsSet.add(stopNodeExp);
|
|
}
|
|
}
|
|
this.stopNodeExpressionsSet.seal();
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
* @param {string} val
|
|
* @param {string} tagName
|
|
* @param {string|Matcher} jPath - jPath string or Matcher instance based on options.jPath
|
|
* @param {boolean} dontTrim
|
|
* @param {boolean} hasAttributes
|
|
* @param {boolean} isLeafNode
|
|
* @param {boolean} escapeEntities
|
|
*/
|
|
function parseTextData(val, tagName, jPath, dontTrim, hasAttributes, isLeafNode, escapeEntities) {
|
|
const options = this.options;
|
|
if (val !== undefined) {
|
|
if (options.trimValues && !dontTrim) {
|
|
val = val.trim();
|
|
}
|
|
if (val.length > 0) {
|
|
if (!escapeEntities) val = this.replaceEntitiesValue(val, tagName, jPath);
|
|
|
|
// Pass jPath string or matcher based on options.jPath setting
|
|
const jPathOrMatcher = options.jPath ? jPath.toString() : jPath;
|
|
const newval = options.tagValueProcessor(tagName, val, jPathOrMatcher, hasAttributes, isLeafNode);
|
|
if (newval === null || newval === undefined) {
|
|
//don't parse
|
|
return val;
|
|
} else if (typeof newval !== typeof val || newval !== val) {
|
|
//overwrite
|
|
return newval;
|
|
} else if (options.trimValues) {
|
|
return parseValue(val, options.parseTagValue, options.numberParseOptions);
|
|
} else {
|
|
const trimmedVal = val.trim();
|
|
if (trimmedVal === val) {
|
|
return parseValue(val, options.parseTagValue, options.numberParseOptions);
|
|
} else {
|
|
return val;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveNameSpace(tagname) {
|
|
if (this.options.removeNSPrefix) {
|
|
const tags = tagname.split(':');
|
|
const prefix = tagname.charAt(0) === '/' ? '/' : '';
|
|
if (tags[0] === 'xmlns') {
|
|
return '';
|
|
}
|
|
if (tags.length === 2) {
|
|
tagname = prefix + tags[1];
|
|
}
|
|
}
|
|
return tagname;
|
|
}
|
|
|
|
//TODO: change regex to capture NS
|
|
//const attrsRegx = new RegExp("([\\w\\-\\.\\:]+)\\s*=\\s*(['\"])((.|\n)*?)\\2","gm");
|
|
const attrsRegx = new RegExp('([^\\s=]+)\\s*(=\\s*([\'"])([\\s\\S]*?)\\3)?', 'gm');
|
|
|
|
function buildAttributesMap(attrStr, jPath, tagName, force = false) {
|
|
const options = this.options;
|
|
if (force === true || (options.ignoreAttributes !== true && typeof attrStr === 'string')) {
|
|
// attrStr = attrStr.replace(/\r?\n/g, ' ');
|
|
//attrStr = attrStr || attrStr.trim();
|
|
|
|
const matches = getAllMatches(attrStr, attrsRegx);
|
|
const len = matches.length; //don't make it inline
|
|
const attrs = {};
|
|
|
|
// Pre-process values once: trim + entity replacement
|
|
// Reused in both matcher update and second pass
|
|
const processedVals = new Array(len);
|
|
let hasRawAttrs = false;
|
|
const rawAttrsForMatcher = {};
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
const attrName = this.resolveNameSpace(matches[i][1]);
|
|
const oldVal = matches[i][4];
|
|
|
|
if (attrName.length && oldVal !== undefined) {
|
|
let val = oldVal;
|
|
if (options.trimValues) val = val.trim();
|
|
val = this.replaceEntitiesValue(val, tagName, this.readonlyMatcher);
|
|
processedVals[i] = val;
|
|
|
|
rawAttrsForMatcher[attrName] = val;
|
|
hasRawAttrs = true;
|
|
}
|
|
}
|
|
|
|
// Update matcher ONCE before second pass, if applicable
|
|
if (hasRawAttrs && typeof jPath === 'object' && jPath.updateCurrent) {
|
|
jPath.updateCurrent(rawAttrsForMatcher);
|
|
}
|
|
|
|
// Hoist toString() once — path doesn't change during attribute processing
|
|
const jPathStr = options.jPath ? jPath.toString() : this.readonlyMatcher;
|
|
|
|
// Second pass: apply processors, build final attrs
|
|
let hasAttrs = false;
|
|
for (let i = 0; i < len; i++) {
|
|
const attrName = this.resolveNameSpace(matches[i][1]);
|
|
|
|
if (this.ignoreAttributesFn(attrName, jPathStr)) continue;
|
|
|
|
let aName = options.attributeNamePrefix + attrName;
|
|
|
|
if (attrName.length) {
|
|
if (options.transformAttributeName) {
|
|
aName = options.transformAttributeName(aName);
|
|
}
|
|
aName = sanitizeName(aName, options);
|
|
|
|
if (matches[i][4] !== undefined) {
|
|
// Reuse already-processed value — no double entity replacement
|
|
const oldVal = processedVals[i];
|
|
|
|
const newVal = options.attributeValueProcessor(attrName, oldVal, jPathStr);
|
|
if (newVal === null || newVal === undefined) {
|
|
attrs[aName] = oldVal;
|
|
} else if (typeof newVal !== typeof oldVal || newVal !== oldVal) {
|
|
attrs[aName] = newVal;
|
|
} else {
|
|
attrs[aName] = parseValue(oldVal, options.parseAttributeValue, options.numberParseOptions);
|
|
}
|
|
hasAttrs = true;
|
|
} else if (options.allowBooleanAttributes) {
|
|
attrs[aName] = true;
|
|
hasAttrs = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hasAttrs) return;
|
|
|
|
if (options.attributesGroupName) {
|
|
const attrCollection = {};
|
|
attrCollection[options.attributesGroupName] = attrs;
|
|
return attrCollection;
|
|
}
|
|
return attrs;
|
|
}
|
|
}
|
|
const parseXml = function (xmlData) {
|
|
xmlData = xmlData.replace(/\r\n?/g, "\n"); //TODO: remove this line
|
|
const xmlObj = new xmlNode('!xml');
|
|
let currentNode = xmlObj;
|
|
let textData = "";
|
|
|
|
// Reset matcher for new document
|
|
this.matcher.reset();
|
|
this.entityDecoder.reset();
|
|
|
|
// Reset entity expansion counters for this document
|
|
this.entityExpansionCount = 0;
|
|
this.currentExpandedLength = 0;
|
|
const options = this.options;
|
|
const docTypeReader = new DocTypeReader(options.processEntities);
|
|
const xmlLen = xmlData.length;
|
|
for (let i = 0; i < xmlLen; i++) {//for each char in XML data
|
|
const ch = xmlData[i];
|
|
if (ch === '<') {
|
|
// const nextIndex = i+1;
|
|
// const _2ndChar = xmlData[nextIndex];
|
|
const c1 = xmlData.charCodeAt(i + 1);
|
|
if (c1 === 47) {//Closing Tag '/'
|
|
const closeIndex = findClosingIndex(xmlData, ">", i, "Closing Tag is not closed.")
|
|
let tagName = xmlData.substring(i + 2, closeIndex).trim();
|
|
|
|
if (options.removeNSPrefix) {
|
|
const colonIndex = tagName.indexOf(":");
|
|
if (colonIndex !== -1) {
|
|
tagName = tagName.substr(colonIndex + 1);
|
|
}
|
|
}
|
|
|
|
tagName = transformTagName(options.transformTagName, tagName, "", options).tagName;
|
|
|
|
if (currentNode) {
|
|
textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
|
|
}
|
|
|
|
//check if last tag of nested tag was unpaired tag
|
|
const lastTagName = this.matcher.getCurrentTag();
|
|
if (tagName && options.unpairedTagsSet.has(tagName)) {
|
|
throw new Error(`Unpaired tag can not be used as closing tag: </${tagName}>`);
|
|
}
|
|
if (lastTagName && options.unpairedTagsSet.has(lastTagName)) {
|
|
// Pop the unpaired tag
|
|
this.matcher.pop();
|
|
this.tagsNodeStack.pop();
|
|
}
|
|
// Pop the closing tag
|
|
this.matcher.pop();
|
|
this.isCurrentNodeStopNode = false; // Reset flag when closing tag
|
|
|
|
currentNode = this.tagsNodeStack.pop();//avoid recursion, set the parent tag scope
|
|
textData = "";
|
|
i = closeIndex;
|
|
} else if (c1 === 63) { //'?'
|
|
|
|
let tagData = readTagExp(xmlData, i, false, "?>");
|
|
if (!tagData) throw new Error("Pi Tag is not closed.");
|
|
|
|
textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
|
|
const attsMap = this.buildAttributesMap(tagData.tagExp, this.matcher, tagData.tagName, true);
|
|
if (attsMap) {
|
|
const ver = attsMap[this.options.attributeNamePrefix + "version"];
|
|
this.entityDecoder.setXmlVersion(Number(ver) || 1.0);
|
|
}
|
|
if ((options.ignoreDeclaration && tagData.tagName === "?xml") || options.ignorePiTags) {
|
|
//do nothing
|
|
} else {
|
|
|
|
const childNode = new xmlNode(tagData.tagName);
|
|
childNode.add(options.textNodeName, "");
|
|
|
|
if (tagData.tagName !== tagData.tagExp && tagData.attrExpPresent && options.ignoreAttributes !== true) {
|
|
childNode[":@"] = attsMap
|
|
}
|
|
this.addChild(currentNode, childNode, this.readonlyMatcher, i);
|
|
}
|
|
|
|
|
|
i = tagData.closeIndex + 1;
|
|
} else if (c1 === 33
|
|
&& xmlData.charCodeAt(i + 2) === 45
|
|
&& xmlData.charCodeAt(i + 3) === 45) { //'!--'
|
|
const endIndex = findClosingIndex(xmlData, "-->", i + 4, "Comment is not closed.")
|
|
if (options.commentPropName) {
|
|
const comment = xmlData.substring(i + 4, endIndex - 2);
|
|
|
|
textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
|
|
|
|
currentNode.add(options.commentPropName, [{ [options.textNodeName]: comment }]);
|
|
}
|
|
i = endIndex;
|
|
} else if (c1 === 33
|
|
&& xmlData.charCodeAt(i + 2) === 68) { //'!D'
|
|
const result = docTypeReader.readDocType(xmlData, i);
|
|
this.entityDecoder.addInputEntities(result.entities);
|
|
i = result.i;
|
|
} else if (c1 === 33
|
|
&& xmlData.charCodeAt(i + 2) === 91) { // '!['
|
|
const closeIndex = findClosingIndex(xmlData, "]]>", i, "CDATA is not closed.") - 2;
|
|
const tagExp = xmlData.substring(i + 9, closeIndex);
|
|
|
|
textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher);
|
|
|
|
let val = this.parseTextData(tagExp, currentNode.tagname, this.readonlyMatcher, true, false, true, true);
|
|
if (val == undefined) val = "";
|
|
|
|
//cdata should be set even if it is 0 length string
|
|
if (options.cdataPropName) {
|
|
currentNode.add(options.cdataPropName, [{ [options.textNodeName]: tagExp }]);
|
|
} else {
|
|
currentNode.add(options.textNodeName, val);
|
|
}
|
|
|
|
i = closeIndex + 2;
|
|
} else {//Opening tag
|
|
let result = readTagExp(xmlData, i, options.removeNSPrefix);
|
|
|
|
// Safety check: readTagExp can return undefined
|
|
if (!result) {
|
|
// Log context for debugging
|
|
const context = xmlData.substring(Math.max(0, i - 50), Math.min(xmlLen, i + 50));
|
|
throw new Error(`readTagExp returned undefined at position ${i}. Context: "${context}"`);
|
|
}
|
|
|
|
let tagName = result.tagName;
|
|
const rawTagName = result.rawTagName;
|
|
let tagExp = result.tagExp;
|
|
let attrExpPresent = result.attrExpPresent;
|
|
let closeIndex = result.closeIndex;
|
|
|
|
({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options));
|
|
|
|
if (options.strictReservedNames &&
|
|
(tagName === options.commentPropName
|
|
|| tagName === options.cdataPropName
|
|
|| tagName === options.textNodeName
|
|
|| tagName === options.attributesGroupName
|
|
)) {
|
|
throw new Error(`Invalid tag name: ${tagName}`);
|
|
}
|
|
|
|
//save text as child node
|
|
if (currentNode && textData) {
|
|
if (currentNode.tagname !== '!xml') {
|
|
//when nested tag is found
|
|
textData = this.saveTextToParentTag(textData, currentNode, this.readonlyMatcher, false);
|
|
}
|
|
}
|
|
|
|
//check if last tag was unpaired tag
|
|
const lastTag = currentNode;
|
|
if (lastTag && options.unpairedTagsSet.has(lastTag.tagname)) {
|
|
currentNode = this.tagsNodeStack.pop();
|
|
this.matcher.pop();
|
|
}
|
|
|
|
// Clean up self-closing syntax BEFORE processing attributes
|
|
// This is where tagExp gets the trailing / removed
|
|
let isSelfClosing = false;
|
|
if (tagExp.length > 0 && tagExp.lastIndexOf("/") === tagExp.length - 1) {
|
|
isSelfClosing = true;
|
|
if (tagName[tagName.length - 1] === "/") {
|
|
tagName = tagName.substr(0, tagName.length - 1);
|
|
tagExp = tagName;
|
|
} else {
|
|
tagExp = tagExp.substr(0, tagExp.length - 1);
|
|
}
|
|
|
|
// Re-check attrExpPresent after cleaning
|
|
attrExpPresent = (tagName !== tagExp);
|
|
}
|
|
|
|
// Now process attributes with CLEAN tagExp (no trailing /)
|
|
let prefixedAttrs = null;
|
|
let rawAttrs = {};
|
|
let namespace = undefined;
|
|
|
|
// Extract namespace from rawTagName
|
|
namespace = extractNamespace(rawTagName);
|
|
|
|
// Push tag to matcher FIRST (with empty attrs for now) so callbacks see correct path
|
|
if (tagName !== xmlObj.tagname) {
|
|
this.matcher.push(tagName, {}, namespace);
|
|
}
|
|
|
|
// Now build attributes - callbacks will see correct matcher state
|
|
if (tagName !== tagExp && attrExpPresent) {
|
|
// Build attributes (returns prefixed attributes for the tree)
|
|
// Note: buildAttributesMap now internally updates the matcher with raw attributes
|
|
prefixedAttrs = this.buildAttributesMap(tagExp, this.matcher, tagName);
|
|
|
|
if (prefixedAttrs) {
|
|
// Extract raw attributes (without prefix) for our use
|
|
//TODO: seems a performance overhead
|
|
rawAttrs = extractRawAttributes(prefixedAttrs, options);
|
|
}
|
|
}
|
|
|
|
// Now check if this is a stop node (after attributes are set)
|
|
if (tagName !== xmlObj.tagname) {
|
|
this.isCurrentNodeStopNode = this.isItStopNode();
|
|
}
|
|
|
|
const startIndex = i;
|
|
if (this.isCurrentNodeStopNode) {
|
|
let tagContent = "";
|
|
|
|
// For self-closing tags, content is empty
|
|
if (isSelfClosing) {
|
|
i = result.closeIndex;
|
|
}
|
|
//unpaired tag
|
|
else if (options.unpairedTagsSet.has(tagName)) {
|
|
i = result.closeIndex;
|
|
}
|
|
//normal tag
|
|
else {
|
|
//read until closing tag is found
|
|
const result = this.readStopNodeData(xmlData, rawTagName, closeIndex + 1);
|
|
if (!result) throw new Error(`Unexpected end of ${rawTagName}`);
|
|
i = result.i;
|
|
tagContent = result.tagContent;
|
|
}
|
|
|
|
const childNode = new xmlNode(tagName);
|
|
|
|
if (prefixedAttrs) {
|
|
childNode[":@"] = prefixedAttrs;
|
|
}
|
|
|
|
// For stop nodes, store raw content as-is without any processing
|
|
childNode.add(options.textNodeName, tagContent);
|
|
|
|
this.matcher.pop(); // Pop the stop node tag
|
|
this.isCurrentNodeStopNode = false; // Reset flag
|
|
|
|
this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
|
|
} else {
|
|
//selfClosing tag
|
|
if (isSelfClosing) {
|
|
({ tagName, tagExp } = transformTagName(options.transformTagName, tagName, tagExp, options));
|
|
|
|
const childNode = new xmlNode(tagName);
|
|
if (prefixedAttrs) {
|
|
childNode[":@"] = prefixedAttrs;
|
|
}
|
|
this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
|
|
this.matcher.pop(); // Pop self-closing tag
|
|
this.isCurrentNodeStopNode = false; // Reset flag
|
|
}
|
|
else if (options.unpairedTagsSet.has(tagName)) {//unpaired tag
|
|
const childNode = new xmlNode(tagName);
|
|
if (prefixedAttrs) {
|
|
childNode[":@"] = prefixedAttrs;
|
|
}
|
|
this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
|
|
this.matcher.pop(); // Pop unpaired tag
|
|
this.isCurrentNodeStopNode = false; // Reset flag
|
|
i = result.closeIndex;
|
|
// Continue to next iteration without changing currentNode
|
|
continue;
|
|
}
|
|
//opening tag
|
|
else {
|
|
const childNode = new xmlNode(tagName);
|
|
if (this.tagsNodeStack.length > options.maxNestedTags) {
|
|
throw new Error("Maximum nested tags exceeded");
|
|
}
|
|
this.tagsNodeStack.push(currentNode);
|
|
|
|
if (prefixedAttrs) {
|
|
childNode[":@"] = prefixedAttrs;
|
|
}
|
|
this.addChild(currentNode, childNode, this.readonlyMatcher, startIndex);
|
|
currentNode = childNode;
|
|
}
|
|
textData = "";
|
|
i = closeIndex;
|
|
}
|
|
}
|
|
} else {
|
|
textData += xmlData[i];
|
|
}
|
|
}
|
|
return xmlObj.child;
|
|
}
|
|
|
|
function addChild(currentNode, childNode, matcher, startIndex) {
|
|
// unset startIndex if not requested
|
|
if (!this.options.captureMetaData) startIndex = undefined;
|
|
|
|
// Pass jPath string or matcher based on options.jPath setting
|
|
const jPathOrMatcher = this.options.jPath ? matcher.toString() : matcher;
|
|
const result = this.options.updateTag(childNode.tagname, jPathOrMatcher, childNode[":@"])
|
|
if (result === false) {
|
|
//do nothing
|
|
} else if (typeof result === "string") {
|
|
childNode.tagname = result
|
|
currentNode.addChild(childNode, startIndex);
|
|
} else {
|
|
currentNode.addChild(childNode, startIndex);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {object} val - Entity object with regex and val properties
|
|
* @param {string} tagName - Tag name
|
|
* @param {string|Matcher} jPath - jPath string or Matcher instance based on options.jPath
|
|
*/
|
|
function replaceEntitiesValue(val, tagName, jPath) {
|
|
const entityConfig = this.options.processEntities;
|
|
|
|
if (!entityConfig || !entityConfig.enabled) {
|
|
return val;
|
|
}
|
|
|
|
// Check if tag is allowed to contain entities
|
|
if (entityConfig.allowedTags) {
|
|
const jPathOrMatcher = this.options.jPath ? jPath.toString() : jPath;
|
|
const allowed = Array.isArray(entityConfig.allowedTags)
|
|
? entityConfig.allowedTags.includes(tagName)
|
|
: entityConfig.allowedTags(tagName, jPathOrMatcher);
|
|
|
|
if (!allowed) {
|
|
return val;
|
|
}
|
|
}
|
|
|
|
// Apply custom tag filter if provided
|
|
if (entityConfig.tagFilter) {
|
|
const jPathOrMatcher = this.options.jPath ? jPath.toString() : jPath;
|
|
if (!entityConfig.tagFilter(tagName, jPathOrMatcher)) {
|
|
return val; // Skip based on custom filter
|
|
}
|
|
}
|
|
|
|
return this.entityDecoder.decode(val);
|
|
}
|
|
|
|
|
|
function saveTextToParentTag(textData, parentNode, matcher, isLeafNode) {
|
|
if (textData) { //store previously collected data as textNode
|
|
if (isLeafNode === undefined) isLeafNode = parentNode.child.length === 0
|
|
|
|
textData = this.parseTextData(textData,
|
|
parentNode.tagname,
|
|
matcher,
|
|
false,
|
|
parentNode[":@"] ? Object.keys(parentNode[":@"]).length !== 0 : false,
|
|
isLeafNode);
|
|
|
|
if (textData !== undefined && textData !== "")
|
|
parentNode.add(this.options.textNodeName, textData);
|
|
textData = "";
|
|
}
|
|
return textData;
|
|
}
|
|
|
|
/**
|
|
* @param {Array<Expression>} stopNodeExpressions - Array of compiled Expression objects
|
|
* @param {Matcher} matcher - Current path matcher
|
|
*/
|
|
function isItStopNode() {
|
|
if (this.stopNodeExpressionsSet.size === 0) return false;
|
|
|
|
return this.matcher.matchesAny(this.stopNodeExpressionsSet);
|
|
}
|
|
|
|
/**
|
|
* Returns the tag Expression and where it is ending handling single-double quotes situation
|
|
* @param {string} xmlData
|
|
* @param {number} i starting index
|
|
* @returns
|
|
*/
|
|
function tagExpWithClosingIndex(xmlData, i, closingChar = ">") {
|
|
let attrBoundary = 0;
|
|
const chars = [];
|
|
const len = xmlData.length;
|
|
const closeCode0 = closingChar.charCodeAt(0);
|
|
const closeCode1 = closingChar.length > 1 ? closingChar.charCodeAt(1) : -1;
|
|
|
|
for (let index = i; index < len; index++) {
|
|
const code = xmlData.charCodeAt(index);
|
|
|
|
if (attrBoundary) {
|
|
if (code === attrBoundary) attrBoundary = 0;
|
|
} else if (code === 34 || code === 39) { // " or '
|
|
attrBoundary = code;
|
|
} else if (code === closeCode0) {
|
|
if (closeCode1 !== -1) {
|
|
if (xmlData.charCodeAt(index + 1) === closeCode1) {
|
|
return { data: String.fromCharCode(...chars), index };
|
|
}
|
|
} else {
|
|
return { data: String.fromCharCode(...chars), index };
|
|
}
|
|
} else if (code === 9) { // \t
|
|
chars.push(32); // space
|
|
continue;
|
|
}
|
|
|
|
chars.push(code);
|
|
}
|
|
}
|
|
|
|
function findClosingIndex(xmlData, str, i, errMsg) {
|
|
const closingIndex = xmlData.indexOf(str, i);
|
|
if (closingIndex === -1) {
|
|
throw new Error(errMsg)
|
|
} else {
|
|
return closingIndex + str.length - 1;
|
|
}
|
|
}
|
|
|
|
function findClosingChar(xmlData, char, i, errMsg) {
|
|
const closingIndex = xmlData.indexOf(char, i);
|
|
if (closingIndex === -1) throw new Error(errMsg);
|
|
return closingIndex; // no offset needed
|
|
}
|
|
|
|
function readTagExp(xmlData, i, removeNSPrefix, closingChar = ">") {
|
|
const result = tagExpWithClosingIndex(xmlData, i + 1, closingChar);
|
|
if (!result) return;
|
|
let tagExp = result.data;
|
|
const closeIndex = result.index;
|
|
const separatorIndex = tagExp.search(/\s/);
|
|
let tagName = tagExp;
|
|
let attrExpPresent = true;
|
|
if (separatorIndex !== -1) {//separate tag name and attributes expression
|
|
tagName = tagExp.substring(0, separatorIndex);
|
|
tagExp = tagExp.substring(separatorIndex + 1).trimStart();
|
|
}
|
|
|
|
const rawTagName = tagName;
|
|
if (removeNSPrefix) {
|
|
const colonIndex = tagName.indexOf(":");
|
|
if (colonIndex !== -1) {
|
|
tagName = tagName.substr(colonIndex + 1);
|
|
attrExpPresent = tagName !== result.data.substr(colonIndex + 1);
|
|
}
|
|
}
|
|
|
|
return {
|
|
tagName: tagName,
|
|
tagExp: tagExp,
|
|
closeIndex: closeIndex,
|
|
attrExpPresent: attrExpPresent,
|
|
rawTagName: rawTagName,
|
|
}
|
|
}
|
|
/**
|
|
* find paired tag for a stop node
|
|
* @param {string} xmlData
|
|
* @param {string} tagName
|
|
* @param {number} i
|
|
*/
|
|
function readStopNodeData(xmlData, tagName, i) {
|
|
const startIndex = i;
|
|
// Starting at 1 since we already have an open tag
|
|
let openTagCount = 1;
|
|
|
|
const xmllen = xmlData.length;
|
|
for (; i < xmllen; i++) {
|
|
if (xmlData[i] === "<") {
|
|
const c1 = xmlData.charCodeAt(i + 1);
|
|
if (c1 === 47) {//close tag '/'
|
|
const closeIndex = findClosingChar(xmlData, ">", i, `${tagName} is not closed`);
|
|
let closeTagName = xmlData.substring(i + 2, closeIndex).trim();
|
|
if (closeTagName === tagName) {
|
|
openTagCount--;
|
|
if (openTagCount === 0) {
|
|
return {
|
|
tagContent: xmlData.substring(startIndex, i),
|
|
i: closeIndex
|
|
}
|
|
}
|
|
}
|
|
i = closeIndex;
|
|
} else if (c1 === 63) { //?
|
|
const closeIndex = findClosingIndex(xmlData, "?>", i + 1, "StopNode is not closed.")
|
|
i = closeIndex;
|
|
} else if (c1 === 33
|
|
&& xmlData.charCodeAt(i + 2) === 45
|
|
&& xmlData.charCodeAt(i + 3) === 45) { // '!--'
|
|
const closeIndex = findClosingIndex(xmlData, "-->", i + 3, "StopNode is not closed.")
|
|
i = closeIndex;
|
|
} else if (c1 === 33
|
|
&& xmlData.charCodeAt(i + 2) === 91) { // '!['
|
|
const closeIndex = findClosingIndex(xmlData, "]]>", i, "StopNode is not closed.") - 2;
|
|
i = closeIndex;
|
|
} else {
|
|
const tagData = readTagExp(xmlData, i, '>')
|
|
|
|
if (tagData) {
|
|
const openTagName = tagData && tagData.tagName;
|
|
if (openTagName === tagName && tagData.tagExp[tagData.tagExp.length - 1] !== "/") {
|
|
openTagCount++;
|
|
}
|
|
i = tagData.closeIndex;
|
|
}
|
|
}
|
|
}
|
|
}//end for loop
|
|
}
|
|
|
|
function parseValue(val, shouldParse, options) {
|
|
if (shouldParse && typeof val === 'string') {
|
|
//console.log(options)
|
|
const newval = val.trim();
|
|
if (newval === 'true') return true;
|
|
else if (newval === 'false') return false;
|
|
else return toNumber(val, options);
|
|
} else {
|
|
if (isExist(val)) {
|
|
return val;
|
|
} else {
|
|
return '';
|
|
}
|
|
}
|
|
}
|
|
|
|
function fromCodePoint(str, base, prefix) {
|
|
const codePoint = Number.parseInt(str, base);
|
|
|
|
if (codePoint >= 0 && codePoint <= 0x10FFFF) {
|
|
return String.fromCodePoint(codePoint);
|
|
} else {
|
|
return prefix + str + ";";
|
|
}
|
|
}
|
|
|
|
function transformTagName(fn, tagName, tagExp, options) {
|
|
if (fn) {
|
|
const newTagName = fn(tagName);
|
|
if (tagExp === tagName) {
|
|
tagExp = newTagName
|
|
}
|
|
tagName = newTagName;
|
|
}
|
|
tagName = sanitizeName(tagName, options);
|
|
return { tagName, tagExp };
|
|
}
|
|
|
|
|
|
|
|
function sanitizeName(name, options) {
|
|
if (criticalProperties.includes(name)) {
|
|
throw new Error(`[SECURITY] Invalid name: "${name}" is a reserved JavaScript keyword that could cause prototype pollution`);
|
|
} else if (DANGEROUS_PROPERTY_NAMES.includes(name)) {
|
|
return options.onDangerousProperty(name);
|
|
}
|
|
return name;
|
|
} |