UEA-Prodem

This commit is contained in:
2026-06-10 12:38:42 -03:00
parent 3f33154e16
commit c41625e542
9352 changed files with 1128292 additions and 14752 deletions
+40
View File
@@ -0,0 +1,40 @@
**1.2.0** (2026-05-08)
- Add support for `sanitizeName` option
- Support xml-naming for validating and sanitizing tag and attribute names
**1.1.9** (2026-05-06)
- fix: format output for preserve order when indent by is set to empty string
**1.1.8** (2026-05-05)
- fix: skip text property for PI tags
- improve typings
**1.1.7** (2026--05-04)
- fix security issues when attribute value contains quotes
**1.1.6** (2026--05-04)
- fix security issues related to comment
- skip comment with null value
**1.1.5** (2026-04-17)
- fix security issues related to comment and cdata
**1.1.4** (2026-03-16)
- support maxNestedTags option
**1.1.3** (2026-03-13)
- declare Matcher & Expression as unknown so user is not forced to install path-expression-matcher
**1.1.2** (2026-03-11)
- fix typings
**1.1.1** (2026-03-11)
- upgrade path-expression-matcher to 1.1.3
**1.1.0** (2026-03-10)
- Integrate [path-expression-matcher](https://github.com/NaturalIntelligence/path-expression-matcher)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Natural Intelligence
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+74
View File
@@ -0,0 +1,74 @@
# fast-xml-builder
Build XML from JSON
XML Builder was part of [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) for years. But considering that any bug in the parser may false-alarm users who are only using the builder, we have decided to split it into a separate package.
## Installation
```bash
npm install fast-xml-builder
```
## Usage
```javascript
import XMLBuilder from 'fast-xml-builder';
const builder = new XMLBuilder();
const xml = builder.build({ name: 'value' });
```
fast-xml-builder fully supports the response generated by fast-xml-parser. You can use options like `preserveOrder`, `ignoreAttributes`, `attributeNamePrefix`, `textNodeName`, `cdataPropName`, `commentPropName`, `format`, `indentBy`, `suppressEmptyNode`, `suppressUnpairedNode`, `stopNodes`, `oneListGroup`, `maxNestedTags`, and many more.
## Default Options
```js
{
attributeNamePrefix: '@_',
attributesGroupName: false,
textNodeName: '#text',
ignoreAttributes: true,
cdataPropName: false,
commentPropName: false,
format: false,
indentBy: ' ',
suppressEmptyNode: false,
suppressUnpairedNode: true,
suppressBooleanAttributes: true,
preserveOrder: false,
processEntities: true,
unpairedTags: [],
stopNodes: [],
oneListGroup: false,
maxNestedTags: 100,
jPath: true,
tagValueProcessor: (key, val) => val,
attributeValueProcessor: (attrName, val) => val,
}
```
## Options Reference
Check [Options reference](docs/Builder_v1.md) for more detail and examples.
- **arrayNodeName**: When building XML from an array, set `arrayNodeName` to wrap each element in a tag name.
- **attributeNamePrefix**: Prefix used to identify attribute properties in the JS object. Default: `'@_'`.
- **attributesGroupName**: Group name for attributes in the JS object. When set, all attributes are expected to be nested under this key. Not supported with `preserveOrder: true`.
- **attributeValueProcessor**: Customize how attribute values are serialized. Receives the attribute name and value.
- **cdataPropName**: Property name that identifies CDATA content. Values under this key are wrapped in `<![CDATA[...]]>`.
- **commentPropName**: Property name that identifies comment content. Values under this key are rendered as `<!-- ... -->`.
- **format**: By default, output is a single-line XML string. Set `format: true` for human-readable, indented output.
- **ignoreAttributes**: By default (`true`), attributes are skipped. Set to `false` to include them. Also supports selective ignoring via an array of strings, array of regular expressions, or a callback function.
- **indentBy**: String used for each level of indentation. Default: `' '` (two spaces). Only applies when `format: true`.
- **maxNestedTags**: Limits the maximum depth of nested tags. An error is thrown if this depth is exceeded. Default: `100`.
- **oneListGroup**: Groups all repeated child tags under a single parent tag.
- **preserveOrder**: When a JS object was produced by XMLParser with `preserveOrder: true`, pass the same option to XMLBuilder to reconstruct the original XML correctly.
- **processEntities**: When `true` (default), special characters in text and attribute values are replaced with XML entities (`&amp;`, `&lt;`, etc.). Set to `false` for a performance boost when you know your content has no entities. Note: quotes in attribute values are always escaped regardless of this setting.
- **stopNodes**: Tags listed here are treated as raw content containers — their text content is written as-is without entity encoding. Accepts an array of tag name strings or `Expression` instances from `path-expression-matcher`. The old `*.tagName` wildcard syntax is still accepted and automatically converted to the equivalent `..tagName` deep-wildcard syntax.
- **suppressBooleanAttributes**: When `true` (default), attributes with the value `true` are rendered without the value (e.g. `<tag attr>` instead of `<tag attr="true">`).
- **suppressEmptyNode**: When `true`, tags with no text value are rendered as self-closing (`<tag/>`).
- **suppressUnpairedNode**: When `true` (default), unpaired tags are rendered without a closing slash (`<br>`). When `false`, they are rendered as `<br/>`.
- **tagValueProcessor**: Customize how tag text values are serialized. Receives the tag name and value.
- **textNodeName**: Property name representing the text content of a tag in the JS object. Default: `'#text'`.
- **unpairedTags**: List of tag names that have no matching closing tag (e.g. `<br>` in HTML).
+1
View File
File diff suppressed because one or more lines are too long
+270
View File
@@ -0,0 +1,270 @@
// const { Expression } = require('path-expression-matcher');
//type Matcher = unknown;
type Expression = unknown;
/**
* A lightweight, live read-only view of a Matcher instance.
*
* Returned by `Matcher.readOnly()`. The same instance is reused across every
* callback invocation — no allocation overhead per call. Reads directly from
* the parent Matcher's internal state so it always reflects the current parser
* position with no copying or freezing.
*/
class MatcherView {
readonly separator: string;
/** Check if current path matches an Expression. */
matches(expression: Expression): boolean;
/** Get current tag name, or `undefined` if path is empty. */
getCurrentTag(): string | undefined;
/** Get current namespace, or `undefined` if not present. */
getCurrentNamespace(): string | undefined;
/** Get attribute value of the current node. */
getAttrValue(attrName: string): any;
/** Check if the current node has a given attribute. */
hasAttr(attrName: string): boolean;
/** Sibling position of the current node (child index in parent). */
getPosition(): number;
/** Occurrence counter of the current tag name at this level. */
getCounter(): number;
/** Number of nodes in the current path. */
getDepth(): number;
/** Current path as a string (e.g. `"root.users.user"`). */
toString(separator?: string, includeNamespace?: boolean): string;
/** Current path as an array of tag names. */
toArray(): string[];
}
type XmlBuilderOptions = {
/**
* Give a prefix to the attribute name in the resulting JS object
*
* Defaults to '@_'
*/
attributeNamePrefix?: string;
/**
* A name to group all attributes of a tag under, or `false` to disable
*
* Defaults to `false`
*/
attributesGroupName?: false | string;
/**
* The name of the next node in the resulting JS
*
* Defaults to `#text`
*/
textNodeName?: string;
/**
* Whether to ignore attributes when building
*
* When `true` - ignores all the attributes
*
* When `false` - builds all the attributes
*
* When `Array<string | RegExp>` - filters out attributes that match provided patterns
*
* When `Function` - calls the function for each attribute and filters out those for which the function returned `true`
*
* Defaults to `true`
*/
ignoreAttributes?: boolean | (string | RegExp)[] | ((attrName: string, jPath: string) => boolean);
/**
* Give a property name to set CDATA values to instead of merging to tag's text value
*
* Defaults to `false`
*/
cdataPropName?: false | string;
/**
* If set, parse comments and set as this property
*
* Defaults to `false`
*/
commentPropName?: false | string;
/**
* Whether to make output pretty instead of single line
*
* Defaults to `false`
*/
format?: boolean;
/**
* If `format` is set to `true`, sets the indent string
*
* Defaults to ` `
*/
indentBy?: string;
/**
* Give a name to a top-level array
*
* Defaults to `undefined`
*/
arrayNodeName?: string;
/**
* Create empty tags for tags with no text value
*
* Defaults to `false`
*/
suppressEmptyNode?: boolean;
/**
* Suppress an unpaired tag
*
* Defaults to `true`
*/
suppressUnpairedNode?: boolean;
/**
* Don't put a value for boolean attributes
*
* Defaults to `true`
*/
suppressBooleanAttributes?: boolean;
/**
* Preserve the order of tags in resulting JS object
*
* Defaults to `false`
*/
preserveOrder?: boolean;
/**
* List of tags without closing tags
*
* Defaults to `[]`
*/
unpairedTags?: string[];
/**
* Nodes to stop parsing at
*
* Accepts string patterns or Expression objects from path-expression-matcher
*
* String patterns starting with "*." are automatically converted to ".." for backward compatibility
*
* Defaults to `[]`
*/
stopNodes?: (string | Expression)[];
/**
* Control how tag value should be parsed. Called only if tag value is not empty
*
* @returns {undefined|null} `undefined` or `null` to set original value.
* @returns {unknown}
*
* 1. Different value or value with different data type to set new value.
* 2. Same value to set parsed value if `parseTagValue: true`.
*
* Defaults to `(tagName, val, jPath, hasAttributes, isLeafNode) => val`
*/
tagValueProcessor?: (name: string, value: unknown) => unknown;
/**
* Control how attribute value should be parsed
*
* @param attrName
* @param attrValue
* @param jPath
* @returns {undefined|null} `undefined` or `null` to set original value
* @returns {unknown}
*
* Defaults to `(attrName, val, jPath) => val`
*/
attributeValueProcessor?: (name: string, value: unknown) => unknown;
/**
* Whether to process default and DOCTYPE entities
*
* Defaults to `true`
*/
processEntities?: boolean;
oneListGroup?: boolean;
/**
* Maximum number of nested tags
*
* Defaults to `100`
*/
maxNestedTags?: number;
/**
* Validate or sanitize tag and attribute names before they are written to XML output.
*
* The context object provides:
* - `isAttribute` — `true` when the name being resolved is an attribute name,
* `false` when it is a tag name.
* - `matcher` — the current path matcher (readonly). Can be used to inspect the
* current element path, e.g. via `.toString()` or `.getDepth()`.
*
* Return the (possibly transformed) name to use in the output.
* Throw an error inside the function to reject an invalid name entirely.
*
* When set to `false` (default) all names are written as-is, preserving
* backward-compatible behaviour.
*
* @example
* // Auto-fix invalid names using xml-naming
* import { sanitize } from 'xml-naming';
* { sanitizeName: (name) => sanitize(name, 'qName') }
*
* @example
* // Reject invalid names
* import { qName } from 'xml-naming';
* { sanitizeName: (name) => { if (!qName(name)) throw new Error(`Invalid XML name: "${name}"`); return name; } }
*
* Defaults to `false`
*/
sanitizeName?: false | ((name: string, context: SanitizeNameContext) => string);
};
/**
* Context object passed as the second argument to {@link XmlBuilderOptions.sanitizeName}.
*/
type SanitizeNameContext = {
/**
* `true` when the name being resolved is an XML attribute name;
* `false` when it is an XML element (tag) name.
*/
isAttribute: boolean;
/**
* The current path matcher at the point where the name is being resolved.
* Readonly from the callback's perspective — do not call mutating methods.
* Use `.toString()` to get the current jPath string, `.getDepth()` for nesting depth.
*/
matcher: MatcherView;
};
interface XMLBuilder {
build(jObj: any): string;
}
interface XMLBuilderConstructor {
new(options?: XmlBuilderOptions): XMLBuilder;
(options?: XmlBuilderOptions): XMLBuilder;
}
declare const Builder: XMLBuilderConstructor;
export = Builder;
+2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+81
View File
@@ -0,0 +1,81 @@
{
"name": "fast-xml-builder",
"version": "1.2.0",
"description": "Build XML from JSON without C/C++ based libraries",
"main": "./lib/fxb.cjs",
"type": "module",
"sideEffects": false,
"module": "./src/fxb.js",
"types": "./src/fxb.d.ts",
"exports": {
".": {
"import": {
"types": "./src/fxb.d.ts",
"default": "./src/fxb.js"
},
"require": {
"types": "./lib/fxb.d.cts",
"default": "./lib/fxb.cjs"
}
}
},
"scripts": {
"test": "c8 --reporter=lcov --reporter=text jasmine spec/*spec.js",
"test-types": "tsc --noEmit spec/typings/typings-test.ts",
"unit": "jasmine",
"lint": "eslint src/**/*.js spec/**/*.js benchmark/**/*.js",
"bundle": "webpack --config webpack.cjs.config.js",
"prettier": "prettier --write src/**/*.js",
"checkReadiness": "publish-please --dry-run",
"publish-please": "publish-please",
"prepublishOnly": "publish-please guard"
},
"files": [
"lib",
"src",
"CHANGELOG.md"
],
"repository": {
"type": "git",
"url": "git+https://github.com/NaturalIntelligence/fast-xml-builder.git"
},
"keywords": [
"xml",
"json",
"fast",
"builder",
"parser",
"js2xml",
"json2xml"
],
"author": "Amit Gupta (https://solothought.com)",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.13.10",
"@babel/plugin-transform-runtime": "^7.13.10",
"@babel/preset-env": "^7.13.10",
"@babel/register": "^7.13.8",
"@types/node": "20",
"babel-loader": "^8.2.2",
"c8": "^10.1.3",
"eslint": "^8.3.0",
"fast-xml-parser": "^5.3.9",
"he": "^1.2.0",
"jasmine": "^5.6.0",
"prettier": "^3.5.1",
"publish-please": "^5.5.2",
"typescript": "5",
"webpack": "^5.64.4",
"webpack-cli": "^4.9.1"
},
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
}
+270
View File
@@ -0,0 +1,270 @@
// import { Expression, Matcher } from 'path-expression-matcher';
//type Matcher = unknown;
type Expression = unknown;
/**
* A lightweight, live read-only view of a Matcher instance.
*
* Returned by `Matcher.readOnly()`. The same instance is reused across every
* callback invocation — no allocation overhead per call. Reads directly from
* the parent Matcher's internal state so it always reflects the current parser
* position with no copying or freezing.
*/
export class MatcherView {
readonly separator: string;
/** Check if current path matches an Expression. */
matches(expression: Expression): boolean;
/** Get current tag name, or `undefined` if path is empty. */
getCurrentTag(): string | undefined;
/** Get current namespace, or `undefined` if not present. */
getCurrentNamespace(): string | undefined;
/** Get attribute value of the current node. */
getAttrValue(attrName: string): any;
/** Check if the current node has a given attribute. */
hasAttr(attrName: string): boolean;
/** Sibling position of the current node (child index in parent). */
getPosition(): number;
/** Occurrence counter of the current tag name at this level. */
getCounter(): number;
/** Number of nodes in the current path. */
getDepth(): number;
/** Current path as a string (e.g. `"root.users.user"`). */
toString(separator?: string, includeNamespace?: boolean): string;
/** Current path as an array of tag names. */
toArray(): string[];
}
export type XmlBuilderOptions = {
/**
* Give a prefix to the attribute name in the resulting JS object
*
* Defaults to '@_'
*/
attributeNamePrefix?: string;
/**
* A name to group all attributes of a tag under, or `false` to disable
*
* Defaults to `false`
*/
attributesGroupName?: false | string;
/**
* The name of the next node in the resulting JS
*
* Defaults to `#text`
*/
textNodeName?: string;
/**
* Whether to ignore attributes when building
*
* When `true` - ignores all the attributes
*
* When `false` - builds all the attributes
*
* When `Array<string | RegExp>` - filters out attributes that match provided patterns
*
* When `Function` - calls the function for each attribute and filters out those for which the function returned `true`
*
* Defaults to `true`
*/
ignoreAttributes?: boolean | (string | RegExp)[] | ((attrName: string, jPath: string) => boolean);
/**
* Give a property name to set CDATA values to instead of merging to tag's text value
*
* Defaults to `false`
*/
cdataPropName?: false | string;
/**
* If set, parse comments and set as this property
*
* Defaults to `false`
*/
commentPropName?: false | string;
/**
* Whether to make output pretty instead of single line
*
* Defaults to `false`
*/
format?: boolean;
/**
* If `format` is set to `true`, sets the indent string
*
* Defaults to ` `
*/
indentBy?: string;
/**
* Give a name to a top-level array
*
* Defaults to `undefined`
*/
arrayNodeName?: string;
/**
* Create empty tags for tags with no text value
*
* Defaults to `false`
*/
suppressEmptyNode?: boolean;
/**
* Suppress an unpaired tag
*
* Defaults to `true`
*/
suppressUnpairedNode?: boolean;
/**
* Don't put a value for boolean attributes
*
* Defaults to `true`
*/
suppressBooleanAttributes?: boolean;
/**
* Preserve the order of tags in resulting JS object
*
* Defaults to `false`
*/
preserveOrder?: boolean;
/**
* List of tags without closing tags
*
* Defaults to `[]`
*/
unpairedTags?: string[];
/**
* Nodes to stop parsing at
*
* Accepts string patterns or Expression objects from path-expression-matcher
*
* String patterns starting with "*." are automatically converted to ".." for backward compatibility
*
* Defaults to `[]`
*/
stopNodes?: (string | Expression)[];
/**
* Control how tag value should be parsed. Called only if tag value is not empty
*
* @returns {undefined|null} `undefined` or `null` to set original value.
* @returns {unknown}
*
* 1. Different value or value with different data type to set new value.
* 2. Same value to set parsed value if `parseTagValue: true`.
*
* Defaults to `(tagName, val, jPath, hasAttributes, isLeafNode) => val`
*/
tagValueProcessor?: (name: string, value: unknown) => unknown;
/**
* Control how attribute value should be parsed
*
* @param attrName
* @param attrValue
* @param jPath
* @returns {undefined|null} `undefined` or `null` to set original value
* @returns {unknown}
*
* Defaults to `(attrName, val, jPath) => val`
*/
attributeValueProcessor?: (name: string, value: unknown) => unknown;
/**
* Whether to process default and DOCTYPE entities
*
* Defaults to `true`
*/
processEntities?: boolean;
oneListGroup?: boolean;
/**
* Maximum number of nested tags
*
* Defaults to `100`
*/
maxNestedTags?: number;
/**
* Validate or sanitize tag and attribute names before they are written to XML output.
*
* The context object provides:
* - `isAttribute` — `true` when the name being resolved is an attribute name,
* `false` when it is a tag name.
* - `matcher` — the current path matcher (readonly). Can be used to inspect the
* current element path, e.g. via `.toString()` or `.getDepth()`.
*
* Return the (possibly transformed) name to use in the output.
* Throw an error inside the function to reject an invalid name entirely.
*
* When set to `false` (default) all names are written as-is, preserving
* backward-compatible behaviour.
*
* @example
* // Auto-fix invalid names using xml-naming
* import { sanitize } from 'xml-naming';
* { sanitizeName: (name) => sanitize(name, 'qName') }
*
* @example
* // Reject invalid names
* import { qName } from 'xml-naming';
* { sanitizeName: (name) => { if (!qName(name)) throw new Error(`Invalid XML name: "${name}"`); return name; } }
*
* Defaults to `false`
*/
sanitizeName?: false | ((name: string, context: SanitizeNameContext) => string);
};
/**
* Context object passed as the second argument to {@link XmlBuilderOptions.sanitizeName}.
*/
export type SanitizeNameContext = {
/**
* `true` when the name being resolved is an XML attribute name;
* `false` when it is an XML element (tag) name.
*/
isAttribute: boolean;
/**
* The current path matcher at the point where the name is being resolved.
* Readonly from the callback's perspective — do not call mutating methods.
* Use `.toString()` to get the current jPath string, `.getDepth()` for nesting depth.
*/
matcher: MatcherView;
};
export interface XMLBuilder {
build(jObj: any): string;
}
export interface XMLBuilderConstructor {
new(options?: XmlBuilderOptions): XMLBuilder;
(options?: XmlBuilderOptions): XMLBuilder;
}
declare const Builder: XMLBuilderConstructor;
export { Builder };
export default Builder;
+599
View File
@@ -0,0 +1,599 @@
'use strict';
//parse Empty Node as self closing node
import buildFromOrderedJs from './orderedJs2Xml.js';
import getIgnoreAttributesFn from "./ignoreAttributes.js";
import { Expression, Matcher } from 'path-expression-matcher';
import { safeComment, safeCdata, escapeAttribute } from './util.js';
import { qName } from 'xml-naming';
const defaultOptions = {
attributeNamePrefix: '@_',
attributesGroupName: false,
textNodeName: '#text',
ignoreAttributes: true,
cdataPropName: false,
format: false,
indentBy: ' ',
suppressEmptyNode: false,
suppressUnpairedNode: true,
suppressBooleanAttributes: true,
tagValueProcessor: function (key, a) {
return a;
},
attributeValueProcessor: function (attrName, a) {
return a;
},
preserveOrder: false,
commentPropName: false,
unpairedTags: [],
entities: [
{ regex: new RegExp("&", "g"), val: "&amp;" },//it must be on top
{ regex: new RegExp(">", "g"), val: "&gt;" },
{ regex: new RegExp("<", "g"), val: "&lt;" },
{ regex: new RegExp("\'", "g"), val: "&apos;" },
{ regex: new RegExp("\"", "g"), val: "&quot;" }
],
processEntities: true,
stopNodes: [],
// transformTagName: false,
// transformAttributeName: false,
oneListGroup: false,
maxNestedTags: 100,
jPath: true, // When true, callbacks receive string jPath; when false, receive Matcher instance
sanitizeName: false // false = allow all names as-is (default, backward-compatible).
// Set to a function (name, { isAttribute, matcher }) => string to
// validate/sanitize tag and attribute names. Throw inside the function
// to reject an invalid name.
};
export default function Builder(options) {
this.options = Object.assign({}, defaultOptions, options);
// Convert old-style stopNodes for backward compatibility
// Old syntax: "*.tag" meant "tag anywhere in tree"
// New syntax: "..tag" means "tag anywhere in tree"
if (this.options.stopNodes && Array.isArray(this.options.stopNodes)) {
this.options.stopNodes = this.options.stopNodes.map(node => {
if (typeof node === 'string' && node.startsWith('*.')) {
// Convert old wildcard syntax to deep wildcard
return '..' + node.substring(2);
}
return node;
});
}
// Pre-compile stopNode expressions for pattern matching
this.stopNodeExpressions = [];
if (this.options.stopNodes && Array.isArray(this.options.stopNodes)) {
for (let i = 0; i < this.options.stopNodes.length; i++) {
const node = this.options.stopNodes[i];
if (typeof node === 'string') {
this.stopNodeExpressions.push(new Expression(node));
} else if (node instanceof Expression) {
this.stopNodeExpressions.push(node);
}
}
}
if (this.options.ignoreAttributes === true || this.options.attributesGroupName) {
this.isAttribute = function (/*a*/) {
return false;
};
} else {
this.ignoreAttributesFn = getIgnoreAttributesFn(this.options.ignoreAttributes)
this.attrPrefixLen = this.options.attributeNamePrefix.length;
this.isAttribute = isAttribute;
}
this.processTextOrObjNode = processTextOrObjNode
if (this.options.format) {
this.indentate = indentate;
this.tagEndChar = '>\n';
this.newLine = '\n';
} else {
this.indentate = function () {
return '';
};
this.tagEndChar = '>';
this.newLine = '';
}
}
/**
* Detect XML version from the ?xml declaration at the root of a plain-object input.
* Checks both attributesGroupName and flat attribute forms.
* Returns '1.0' if no declaration is found.
*/
function detectXmlVersionFromObj(jObj, options) {
const decl = jObj['?xml'];
if (decl && typeof decl === 'object') {
// attributesGroupName path e.g. { '$$': { '@_version': '1.1' } }
if (options.attributesGroupName && decl[options.attributesGroupName]) {
const v = decl[options.attributesGroupName][options.attributeNamePrefix + 'version'];
if (v) return v;
}
// flat attribute path e.g. { '@_version': '1.1' }
const v = decl[options.attributeNamePrefix + 'version'];
if (v) return v;
}
return '1.0';
}
/**
* Resolve a tag or attribute name through sanitizeName if configured.
* Validation via xml-naming's qName is performed first; the sanitizeName
* callback is invoked only when the name is invalid. If sanitizeName is
* false (default), no validation occurs and the name is used as-is.
*
* @param {string} name - raw name from the JS object
* @param {boolean} isAttribute - true when resolving an attribute name
* @param {object} options
* @param {Matcher} matcher - current matcher state (readonly from callback perspective)
* @param {string} xmlVersion - '1.0' or '1.1', forwarded to xml-naming
*/
function resolveTagName(name, isAttribute, options, matcher, xmlVersion) {
if (!options.sanitizeName) return name;
if (qName(name, { xmlVersion })) return name;
return options.sanitizeName(name, { isAttribute, matcher: matcher.readOnly() });
}
Builder.prototype.build = function (jObj) {
if (this.options.preserveOrder) {
return buildFromOrderedJs(jObj, this.options);
} else {
if (Array.isArray(jObj) && this.options.arrayNodeName && this.options.arrayNodeName.length > 1) {
jObj = {
[this.options.arrayNodeName]: jObj
}
}
// Initialize matcher for path tracking
const matcher = new Matcher();
const xmlVersion = detectXmlVersionFromObj(jObj, this.options);
return this.j2x(jObj, 0, matcher, xmlVersion).val;
}
};
Builder.prototype.j2x = function (jObj, level, matcher, xmlVersion) {
let attrStr = '';
let val = '';
if (this.options.maxNestedTags && matcher.getDepth() >= this.options.maxNestedTags) {
throw new Error("Maximum nested tags exceeded");
}
// Get jPath based on option: string for backward compatibility, or Matcher for new features
const jPath = this.options.jPath ? matcher.toString() : matcher;
// Check if current node is a stopNode (will be used for attribute encoding)
const isCurrentStopNode = this.checkStopNode(matcher);
for (let key in jObj) {
if (!Object.prototype.hasOwnProperty.call(jObj, key)) continue;
// Resolve the key through sanitizeName before any use.
// Special keys (textNodeName, cdataPropName, commentPropName, attributeNamePrefix,
// attributesGroupName, "?" PI tags) are exempt — they are builder-internal conventions,
// not user-supplied XML names.
const isSpecialKey = key === this.options.textNodeName
|| key === this.options.cdataPropName
|| key === this.options.commentPropName
|| (this.options.attributesGroupName && key === this.options.attributesGroupName)
|| this.isAttribute(key)
|| key[0] === '?';
const resolvedKey = isSpecialKey
? key
: resolveTagName(key, false, this.options, matcher, xmlVersion);
if (typeof jObj[key] === 'undefined') {
// supress undefined node only if it is not an attribute
if (this.isAttribute(key)) {
val += '';
}
} else if (jObj[key] === null) {
// null attribute should be ignored by the attribute list, but should not cause the tag closing
if (this.isAttribute(key)) {
val += '';
} else if (resolvedKey === this.options.cdataPropName || resolvedKey === this.options.commentPropName) {
val += '';
} else if (resolvedKey[0] === '?') {
val += this.indentate(level) + '<' + resolvedKey + '?' + this.tagEndChar;
} else {
val += this.indentate(level) + '<' + resolvedKey + '/' + this.tagEndChar;
}
} else if (jObj[key] instanceof Date) {
val += this.buildTextValNode(jObj[key], resolvedKey, '', level, matcher);
} else if (typeof jObj[key] !== 'object') {
//premitive type
const attr = this.isAttribute(key);
if (attr && !this.ignoreAttributesFn(attr, jPath)) {
// Resolve the attribute name through sanitizeName
const resolvedAttr = resolveTagName(attr, true, this.options, matcher, xmlVersion);
attrStr += this.buildAttrPairStr(resolvedAttr, '' + jObj[key], isCurrentStopNode);
} else if (!attr) {
//tag value
if (key === this.options.textNodeName) {
let newval = this.options.tagValueProcessor(key, '' + jObj[key]);
val += this.replaceEntitiesValue(newval);
} else {
// Check if this is a stopNode before building
matcher.push(resolvedKey);
const isStopNode = this.checkStopNode(matcher);
matcher.pop();
if (isStopNode) {
// Build as raw content without encoding
const textValue = '' + jObj[key];
if (textValue === '') {
val += this.indentate(level) + '<' + resolvedKey + this.closeTag(resolvedKey) + this.tagEndChar;
} else {
val += this.indentate(level) + '<' + resolvedKey + '>' + textValue + '</' + resolvedKey + this.tagEndChar;
}
} else {
val += this.buildTextValNode(jObj[key], resolvedKey, '', level, matcher);
}
}
}
} else if (Array.isArray(jObj[key])) {
//repeated nodes
const arrLen = jObj[key].length;
let listTagVal = "";
let listTagAttr = "";
for (let j = 0; j < arrLen; j++) {
const item = jObj[key][j];
if (typeof item === 'undefined') {
// supress undefined node
} else if (item === null) {
if (resolvedKey[0] === "?") val += this.indentate(level) + '<' + resolvedKey + '?' + this.tagEndChar;
else val += this.indentate(level) + '<' + resolvedKey + '/' + this.tagEndChar;
} else if (typeof item === 'object') {
if (this.options.oneListGroup) {
// Push tag to matcher before recursive call
matcher.push(resolvedKey);
const result = this.j2x(item, level + 1, matcher, xmlVersion);
// Pop tag from matcher after recursive call
matcher.pop();
listTagVal += result.val;
if (this.options.attributesGroupName && item.hasOwnProperty(this.options.attributesGroupName)) {
listTagAttr += result.attrStr
}
} else {
listTagVal += this.processTextOrObjNode(item, resolvedKey, level, matcher, xmlVersion)
}
} else {
if (this.options.oneListGroup) {
let textValue = this.options.tagValueProcessor(resolvedKey, item);
textValue = this.replaceEntitiesValue(textValue);
listTagVal += textValue;
} else {
// Check if this is a stopNode before building
matcher.push(resolvedKey);
const isStopNode = this.checkStopNode(matcher);
matcher.pop();
if (isStopNode) {
// Build as raw content without encoding
const textValue = '' + item;
if (textValue === '') {
listTagVal += this.indentate(level) + '<' + resolvedKey + this.closeTag(resolvedKey) + this.tagEndChar;
} else {
listTagVal += this.indentate(level) + '<' + resolvedKey + '>' + textValue + '</' + resolvedKey + this.tagEndChar;
}
} else {
listTagVal += this.buildTextValNode(item, resolvedKey, '', level, matcher);
}
}
}
}
if (this.options.oneListGroup) {
listTagVal = this.buildObjectNode(listTagVal, resolvedKey, listTagAttr, level);
}
val += listTagVal;
} else {
//nested node
if (this.options.attributesGroupName && key === this.options.attributesGroupName) {
const Ks = Object.keys(jObj[key]);
const L = Ks.length;
for (let j = 0; j < L; j++) {
// Resolve attribute names inside attributesGroupName
const resolvedAttr = resolveTagName(Ks[j], true, this.options, matcher, xmlVersion);
attrStr += this.buildAttrPairStr(resolvedAttr, '' + jObj[key][Ks[j]], isCurrentStopNode);
}
} else {
val += this.processTextOrObjNode(jObj[key], resolvedKey, level, matcher, xmlVersion)
}
}
}
return { attrStr: attrStr, val: val };
};
Builder.prototype.buildAttrPairStr = function (attrName, val, isStopNode) {
if (!isStopNode) {
val = this.options.attributeValueProcessor(attrName, '' + val);
val = this.replaceEntitiesValue(val);
}
if (this.options.suppressBooleanAttributes && val === "true") {
return ' ' + attrName;
} else return ' ' + attrName + '="' + escapeAttribute(val) + '"';
}
function processTextOrObjNode(object, key, level, matcher, xmlVersion) {
// Extract attributes to pass to matcher
const attrValues = this.extractAttributes(object);
// Push tag to matcher before recursion WITH attributes
matcher.push(key, attrValues);
// Check if this entire node is a stopNode
const isStopNode = this.checkStopNode(matcher);
if (isStopNode) {
// For stopNodes, build raw content without entity encoding
const rawContent = this.buildRawContent(object);
const attrStr = this.buildAttributesForStopNode(object);
matcher.pop();
return this.buildObjectNode(rawContent, key, attrStr, level);
}
const result = this.j2x(object, level + 1, matcher, xmlVersion);
// Pop tag from matcher after recursion
matcher.pop();
// PI/XML-declaration tags must never emit text content — route through
// buildTextValNode which correctly ignores the text node for "?" tags.
if (key[0] === '?') {
return this.buildTextValNode('', key, result.attrStr, level, matcher);
} else if (object[this.options.textNodeName] !== undefined && Object.keys(object).length === 1) {
return this.buildTextValNode(object[this.options.textNodeName], key, result.attrStr, level, matcher);
} else {
return this.buildObjectNode(result.val, key, result.attrStr, level);
}
}
// Helper method to extract attributes from an object
Builder.prototype.extractAttributes = function (obj) {
if (!obj || typeof obj !== 'object') return null;
const attrValues = {};
let hasAttrs = false;
// Check for attributesGroupName (when attributes are grouped)
if (this.options.attributesGroupName && obj[this.options.attributesGroupName]) {
const attrGroup = obj[this.options.attributesGroupName];
for (let attrKey in attrGroup) {
if (!Object.prototype.hasOwnProperty.call(attrGroup, attrKey)) continue;
// Remove attribute prefix if present
const cleanKey = attrKey.startsWith(this.options.attributeNamePrefix)
? attrKey.substring(this.options.attributeNamePrefix.length)
: attrKey;
attrValues[cleanKey] = escapeAttribute(attrGroup[attrKey]);
hasAttrs = true;
}
} else {
// Look for individual attributes (prefixed with attributeNamePrefix)
for (let key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
const attr = this.isAttribute(key);
if (attr) {
attrValues[attr] = escapeAttribute(obj[key]);
hasAttrs = true;
}
}
}
return hasAttrs ? attrValues : null;
};
// Build raw content for stopNode without entity encoding
Builder.prototype.buildRawContent = function (obj) {
if (typeof obj === 'string') {
return obj; // Already a string, return as-is
}
if (typeof obj !== 'object' || obj === null) {
return String(obj);
}
// Check if this is a stopNode data from parser: { "#text": "raw xml", "@_attr": "val" }
if (obj[this.options.textNodeName] !== undefined) {
return obj[this.options.textNodeName]; // Return raw text without encoding
}
// Build raw XML from nested structure
let content = '';
for (let key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
// Skip attributes
if (this.isAttribute(key)) continue;
if (this.options.attributesGroupName && key === this.options.attributesGroupName) continue;
const value = obj[key];
if (key === this.options.textNodeName) {
content += value; // Raw text
} else if (Array.isArray(value)) {
// Array of same tag
for (let item of value) {
if (typeof item === 'string' || typeof item === 'number') {
content += `<${key}>${item}</${key}>`;
} else if (typeof item === 'object' && item !== null) {
const nestedContent = this.buildRawContent(item);
const nestedAttrs = this.buildAttributesForStopNode(item);
if (nestedContent === '') {
content += `<${key}${nestedAttrs}/>`;
} else {
content += `<${key}${nestedAttrs}>${nestedContent}</${key}>`;
}
}
}
} else if (typeof value === 'object' && value !== null) {
// Nested object
const nestedContent = this.buildRawContent(value);
const nestedAttrs = this.buildAttributesForStopNode(value);
if (nestedContent === '') {
content += `<${key}${nestedAttrs}/>`;
} else {
content += `<${key}${nestedAttrs}>${nestedContent}</${key}>`;
}
} else {
// Primitive value
content += `<${key}>${value}</${key}>`;
}
}
return content;
};
// Build attribute string for stopNode (no entity encoding)
Builder.prototype.buildAttributesForStopNode = function (obj) {
if (!obj || typeof obj !== 'object') return '';
let attrStr = '';
// Check for attributesGroupName (when attributes are grouped)
if (this.options.attributesGroupName && obj[this.options.attributesGroupName]) {
const attrGroup = obj[this.options.attributesGroupName];
for (let attrKey in attrGroup) {
if (!Object.prototype.hasOwnProperty.call(attrGroup, attrKey)) continue;
const cleanKey = attrKey.startsWith(this.options.attributeNamePrefix)
? attrKey.substring(this.options.attributeNamePrefix.length)
: attrKey;
const val = attrGroup[attrKey];
if (val === true && this.options.suppressBooleanAttributes) {
attrStr += ' ' + cleanKey;
} else {
attrStr += ' ' + cleanKey + '="' + val + '"'; // No encoding for stopNode
}
}
} else {
// Look for individual attributes
for (let key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
const attr = this.isAttribute(key);
if (attr) {
const val = obj[key];
if (val === true && this.options.suppressBooleanAttributes) {
attrStr += ' ' + attr;
} else {
attrStr += ' ' + attr + '="' + val + '"'; // No encoding for stopNode
}
}
}
}
return attrStr;
};
Builder.prototype.buildObjectNode = function (val, key, attrStr, level) {
if (val === "") {
if (key[0] === "?") return this.indentate(level) + '<' + key + attrStr + '?' + this.tagEndChar;
else {
return this.indentate(level) + '<' + key + attrStr + this.closeTag(key) + this.tagEndChar;
}
} else if (key[0] === "?") {
// PI/XML-declaration tags never have body content — treat them like empty.
return this.indentate(level) + '<' + key + attrStr + '?' + this.tagEndChar;
} else {
let tagEndExp = '</' + key + this.tagEndChar;
let piClosingChar = "";
if (key[0] === "?") {
piClosingChar = "?";
tagEndExp = "";
}
// attrStr is an empty string in case the attribute came as undefined or null
if ((attrStr || attrStr === '') && val.indexOf('<') === -1) {
return (this.indentate(level) + '<' + key + attrStr + piClosingChar + '>' + val + tagEndExp);
} else if (this.options.commentPropName !== false && key === this.options.commentPropName && piClosingChar.length === 0) {
return this.indentate(level) + `<!--${val}-->` + this.newLine;
} else {
return (
this.indentate(level) + '<' + key + attrStr + piClosingChar + this.tagEndChar +
val +
this.indentate(level) + tagEndExp);
}
}
}
Builder.prototype.closeTag = function (key) {
let closeTag = "";
if (this.options.unpairedTags.indexOf(key) !== -1) { //unpaired
if (!this.options.suppressUnpairedNode) closeTag = "/"
} else if (this.options.suppressEmptyNode) { //empty
closeTag = "/";
} else {
closeTag = `></${key}`
}
return closeTag;
}
Builder.prototype.checkStopNode = function (matcher) {
if (!this.stopNodeExpressions || this.stopNodeExpressions.length === 0) return false;
for (let i = 0; i < this.stopNodeExpressions.length; i++) {
if (matcher.matches(this.stopNodeExpressions[i])) {
return true;
}
}
return false;
}
function buildEmptyObjNode(val, key, attrStr, level) {
if (val !== '') {
return this.buildObjectNode(val, key, attrStr, level);
} else {
if (key[0] === "?") return this.indentate(level) + '<' + key + attrStr + '?' + this.tagEndChar;
else {
return this.indentate(level) + '<' + key + attrStr + '/' + this.tagEndChar;
}
}
}
Builder.prototype.buildTextValNode = function (val, key, attrStr, level, matcher) {
if (this.options.cdataPropName !== false && key === this.options.cdataPropName) {
const safeVal = safeCdata(val);
return this.indentate(level) + `<![CDATA[${safeVal}]]>` + this.newLine;
} else if (this.options.commentPropName !== false && key === this.options.commentPropName) {
const safeVal = safeComment(val);
return this.indentate(level) + `<!--${safeVal}-->` + this.newLine;
} else if (key[0] === "?") {//PI tag
return this.indentate(level) + '<' + key + attrStr + '?' + this.tagEndChar;
} else {
// Normal processing: apply tagValueProcessor and entity replacement
let textValue = this.options.tagValueProcessor(key, val);
textValue = this.replaceEntitiesValue(textValue);
if (textValue === '') {
return this.indentate(level) + '<' + key + attrStr + this.closeTag(key) + this.tagEndChar;
} else {
return this.indentate(level) + '<' + key + attrStr + '>' +
textValue +
'</' + key + this.tagEndChar;
}
}
}
Builder.prototype.replaceEntitiesValue = function (textValue) {
if (textValue && textValue.length > 0 && this.options.processEntities) {
for (let i = 0; i < this.options.entities.length; i++) {
const entity = this.options.entities[i];
textValue = textValue.replace(entity.regex, entity.val);
}
}
return textValue;
}
function indentate(level) {
return this.options.indentBy.repeat(level);
}
function isAttribute(name /*, options*/) {
if (name.startsWith(this.options.attributeNamePrefix) && name !== this.options.textNodeName) {
return name.substr(this.attrPrefixLen);
} else {
return false;
}
}
+18
View File
@@ -0,0 +1,18 @@
export default function getIgnoreAttributesFn(ignoreAttributes) {
if (typeof ignoreAttributes === 'function') {
return ignoreAttributes
}
if (Array.isArray(ignoreAttributes)) {
return (attrName) => {
for (const pattern of ignoreAttributes) {
if (typeof pattern === 'string' && attrName === pattern) {
return true
}
if (pattern instanceof RegExp && pattern.test(attrName)) {
return true
}
}
}
}
return () => false
}
+359
View File
@@ -0,0 +1,359 @@
import { Expression, Matcher } from 'path-expression-matcher';
import { safeComment, safeCdata, escapeAttribute } from "./util.js";
import { qName } from 'xml-naming';
const EOL = "\n";
/**
* Detect XML version from the first element of the ordered array input.
* The first element must be a ?xml processing instruction with a version attribute.
* Returns '1.0' if not found.
*
* @param {array} jArray
* @param {object} options
*/
function detectXmlVersionFromArray(jArray, options) {
if (!Array.isArray(jArray) || jArray.length === 0) return '1.0';
const first = jArray[0];
const firstKey = propName(first);
if (firstKey === '?xml') {
const attrs = first[':@'];
if (attrs) {
const versionKey = options.attributeNamePrefix + 'version';
if (attrs[versionKey]) return attrs[versionKey];
}
}
return '1.0';
}
/**
* Resolve a tag or attribute name through sanitizeName if configured.
* Validation via xml-naming's qName is performed first; the sanitizeName
* callback is invoked only when the name is invalid. If sanitizeName is
* false (default), no validation occurs and the name is used as-is.
*
* @param {string} name - raw name from the JS object
* @param {boolean} isAttribute - true when resolving an attribute name
* @param {object} options
* @param {Matcher} matcher - current matcher state (readonly from callback perspective)
* @param {string} xmlVersion - '1.0' or '1.1', forwarded to xml-naming
*/
function resolveTagName(name, isAttribute, options, matcher, xmlVersion) {
if (!options.sanitizeName) return name;
if (qName(name, { xmlVersion })) return name;
return options.sanitizeName(name, { isAttribute, matcher: matcher.readOnly() });
}
/**
* @param {array} jArray
* @param {any} options
* @returns
*/
export default function toXml(jArray, options) {
let indentation = "";
if (options.format) {
indentation = EOL;
}
// Pre-compile stopNode expressions for pattern matching
const stopNodeExpressions = [];
if (options.stopNodes && Array.isArray(options.stopNodes)) {
for (let i = 0; i < options.stopNodes.length; i++) {
const node = options.stopNodes[i];
if (typeof node === 'string') {
stopNodeExpressions.push(new Expression(node));
} else if (node instanceof Expression) {
stopNodeExpressions.push(node);
}
}
}
// Detect XML version for use in name validation
const xmlVersion = detectXmlVersionFromArray(jArray, options);
// Initialize matcher for path tracking
const matcher = new Matcher();
return arrToStr(jArray, options, indentation, matcher, stopNodeExpressions, xmlVersion);
}
function arrToStr(arr, options, indentation, matcher, stopNodeExpressions, xmlVersion) {
let xmlStr = "";
let isPreviousElementTag = false;
if (options.maxNestedTags && matcher.getDepth() > options.maxNestedTags) {
throw new Error("Maximum nested tags exceeded");
}
if (!Array.isArray(arr)) {
// Non-array values (e.g. string tag values) should be treated as text content
if (arr !== undefined && arr !== null) {
let text = arr.toString();
text = replaceEntitiesValue(text, options);
return text;
}
return "";
}
for (let i = 0; i < arr.length; i++) {
const tagObj = arr[i];
const rawTagName = propName(tagObj);
if (rawTagName === undefined) continue;
// Special names are exempt from sanitizeName: internal conventions and PI tags
// are not user-supplied XML element names.
const isSpecialName = rawTagName === options.textNodeName
|| rawTagName === options.cdataPropName
|| rawTagName === options.commentPropName
|| rawTagName[0] === '?';
// Resolve tag name (may transform it; may throw for invalid names)
const tagName = isSpecialName
? rawTagName
: resolveTagName(rawTagName, false, options, matcher, xmlVersion);
// Extract attributes from ":@" property
const attrValues = extractAttributeValues(tagObj[":@"], options);
// Push resolved tag to matcher WITH attributes
matcher.push(tagName, attrValues);
// Check if this is a stop node using Expression matching
const isStopNode = checkStopNode(matcher, stopNodeExpressions);
if (tagName === options.textNodeName) {
let tagText = tagObj[rawTagName];
if (!isStopNode) {
tagText = options.tagValueProcessor(tagName, tagText);
tagText = replaceEntitiesValue(tagText, options);
}
if (isPreviousElementTag) {
xmlStr += indentation;
}
xmlStr += tagText;
isPreviousElementTag = false;
matcher.pop();
continue;
} else if (tagName === options.cdataPropName) {
if (isPreviousElementTag) {
xmlStr += indentation;
}
const val = tagObj[rawTagName][0][options.textNodeName];
const safeVal = safeCdata(val);
xmlStr += `<![CDATA[${safeVal}]]>`;
isPreviousElementTag = false;
matcher.pop();
continue;
} else if (tagName === options.commentPropName) {
const val = tagObj[rawTagName][0][options.textNodeName];
const safeVal = safeComment(val);
xmlStr += indentation + `<!--${safeVal}-->`;
isPreviousElementTag = true;
matcher.pop();
continue;
} else if (tagName[0] === "?") {
const attStr = attr_to_str(tagObj[":@"], options, isStopNode, matcher, xmlVersion);
const tempInd = tagName === "?xml" ? "" : indentation;
// Text node content on PI/XML declaration tags is intentionally ignored.
// Only attributes are valid on these tags per the XML spec.
xmlStr += tempInd + `<${tagName}${attStr}?>`;
isPreviousElementTag = true;
matcher.pop();
continue;
}
let newIdentation = indentation;
if (newIdentation !== "") {
newIdentation += options.indentBy;
}
// Pass isStopNode to attr_to_str so attributes are also not processed for stopNodes
const attStr = attr_to_str(tagObj[":@"], options, isStopNode, matcher, xmlVersion);
const tagStart = indentation + `<${tagName}${attStr}`;
// If this is a stopNode, get raw content without processing
let tagValue;
if (isStopNode) {
tagValue = getRawContent(tagObj[rawTagName], options);
} else {
tagValue = arrToStr(tagObj[rawTagName], options, newIdentation, matcher, stopNodeExpressions, xmlVersion);
}
if (options.unpairedTags.indexOf(tagName) !== -1) {
if (options.suppressUnpairedNode) xmlStr += tagStart + ">";
else xmlStr += tagStart + "/>";
} else if ((!tagValue || tagValue.length === 0) && options.suppressEmptyNode) {
xmlStr += tagStart + "/>";
} else if (tagValue && tagValue.endsWith(">")) {
xmlStr += tagStart + `>${tagValue}${indentation}</${tagName}>`;
} else {
xmlStr += tagStart + ">";
if (tagValue && indentation !== "" && (tagValue.includes("/>") || tagValue.includes("</"))) {
xmlStr += indentation + options.indentBy + tagValue + indentation;
} else {
xmlStr += tagValue;
}
xmlStr += `</${tagName}>`;
}
isPreviousElementTag = true;
// Pop tag from matcher
matcher.pop();
}
return xmlStr;
}
/**
* Extract attribute values from the ":@" object and return as plain object
* for passing to matcher.push()
*/
function extractAttributeValues(attrMap, options) {
if (!attrMap || options.ignoreAttributes) return null;
const attrValues = {};
let hasAttrs = false;
for (let attr in attrMap) {
if (!Object.prototype.hasOwnProperty.call(attrMap, attr)) continue;
// Remove the attribute prefix to get clean attribute name
const cleanAttrName = attr.startsWith(options.attributeNamePrefix)
? attr.substr(options.attributeNamePrefix.length)
: attr;
attrValues[cleanAttrName] = escapeAttribute(attrMap[attr]);
hasAttrs = true;
}
return hasAttrs ? attrValues : null;
}
/**
* Extract raw content from a stopNode without any processing
* This preserves the content exactly as-is, including special characters
*/
function getRawContent(arr, options) {
if (!Array.isArray(arr)) {
// Non-array values return as-is
if (arr !== undefined && arr !== null) {
return arr.toString();
}
return "";
}
let content = "";
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
const tagName = propName(item);
if (tagName === options.textNodeName) {
// Raw text content - NO processing, NO entity replacement
content += item[tagName];
} else if (tagName === options.cdataPropName) {
// CDATA content
content += item[tagName][0][options.textNodeName];
} else if (tagName === options.commentPropName) {
// Comment content
content += item[tagName][0][options.textNodeName];
} else if (tagName && tagName[0] === "?") {
// Processing instruction - skip for stopNodes
continue;
} else if (tagName) {
// Nested tags within stopNode — no sanitizeName, content is raw
const attStr = attr_to_str_raw(item[":@"], options);
const nestedContent = getRawContent(item[tagName], options);
if (!nestedContent || nestedContent.length === 0) {
content += `<${tagName}${attStr}/>`;
} else {
content += `<${tagName}${attStr}>${nestedContent}</${tagName}>`;
}
}
}
return content;
}
/**
* Build attribute string for stopNodes - NO entity replacement
*/
function attr_to_str_raw(attrMap, options) {
let attrStr = "";
if (attrMap && !options.ignoreAttributes) {
for (let attr in attrMap) {
if (!Object.prototype.hasOwnProperty.call(attrMap, attr)) continue;
// For stopNodes, use raw value without processing
let attrVal = attrMap[attr];
if (attrVal === true && options.suppressBooleanAttributes) {
attrStr += ` ${attr.substr(options.attributeNamePrefix.length)}`;
} else {
attrStr += ` ${attr.substr(options.attributeNamePrefix.length)}="${escapeAttribute(attrVal)}"`;
}
}
}
return attrStr;
}
function propName(obj) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
if (key !== ":@") return key;
}
}
/**
* Build attribute string, resolving attribute names through sanitizeName when configured.
* Accepts matcher so the callback has path context.
*/
function attr_to_str(attrMap, options, isStopNode, matcher, xmlVersion) {
let attrStr = "";
if (attrMap && !options.ignoreAttributes) {
for (let attr in attrMap) {
if (!Object.prototype.hasOwnProperty.call(attrMap, attr)) continue;
// Strip prefix to get the clean XML attribute name, then optionally sanitize it
const cleanAttrName = attr.substr(options.attributeNamePrefix.length);
const resolvedAttrName = isStopNode
? cleanAttrName // stopNodes are raw — skip sanitizeName for attr names too
: resolveTagName(cleanAttrName, true, options, matcher, xmlVersion);
let attrVal;
if (isStopNode) {
// For stopNodes, use raw value without any processing
attrVal = attrMap[attr];
} else {
// Normal processing: apply attributeValueProcessor and entity replacement
attrVal = options.attributeValueProcessor(attr, attrMap[attr]);
attrVal = replaceEntitiesValue(attrVal, options);
}
if (attrVal === true && options.suppressBooleanAttributes) {
attrStr += ` ${resolvedAttrName}`;
} else {
attrStr += ` ${resolvedAttrName}="${escapeAttribute(attrVal)}"`;
}
}
}
return attrStr;
}
function checkStopNode(matcher, stopNodeExpressions) {
if (!stopNodeExpressions || stopNodeExpressions.length === 0) return false;
for (let i = 0; i < stopNodeExpressions.length; i++) {
if (matcher.matches(stopNodeExpressions[i])) {
return true;
}
}
return false;
}
function replaceEntitiesValue(textValue, options) {
if (textValue && textValue.length > 0 && options.processEntities) {
for (let i = 0; i < options.entities.length; i++) {
const entity = options.entities[i];
textValue = textValue.replace(entity.regex, entity.val);
}
}
return textValue;
}
View File
+16
View File
@@ -0,0 +1,16 @@
export function safeComment(val) {
return String(val)
.replace(/--/g, '- -') // -- is illegal anywhere in comment content
.replace(/--/g, '- -') // handle the scenario when 2 consiucative dashes appears
.replace(/-$/, '- '); // trailing - would form -- with the closing -->
}
export function safeCdata(val) {
return String(val).replace(/\]\]>/g, ']]]]><![CDATA[>')
}
export function escapeAttribute(val) {
return String(val).replace(/"/g, '&quot;').replace(/'/g, '&apos;')
}