You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
379 lines
11 KiB
379 lines
11 KiB
1 month ago
|
const ARRAY = "array";
|
||
|
const BOOLEAN = "boolean";
|
||
|
const DATE = "date";
|
||
|
const NULL = "null";
|
||
|
const NUMBER = "number";
|
||
|
const OBJECT = "object";
|
||
|
const SPECIAL_OBJECT = "special-object";
|
||
|
const STRING = "string";
|
||
|
|
||
|
const PRIVATE_VARS = ["_selfCloseTag", "_attrs"];
|
||
|
const PRIVATE_VARS_REGEXP = new RegExp(PRIVATE_VARS.join("|"), "g");
|
||
|
|
||
|
/**
|
||
|
* Determines the indent string based on current tree depth.
|
||
|
*/
|
||
|
const getIndentStr = (indent = "", depth = 0) => indent.repeat(depth);
|
||
|
|
||
|
/**
|
||
|
* Sugar function supplementing JS's quirky typeof operator, plus some extra help to detect
|
||
|
* "special objects" expected by jstoxml.
|
||
|
* Example:
|
||
|
* getType(new Date());
|
||
|
* -> 'date'
|
||
|
*/
|
||
|
const getType = (val) =>
|
||
|
(Array.isArray(val) && ARRAY) ||
|
||
|
(typeof val === OBJECT && val !== null && val._name && SPECIAL_OBJECT) ||
|
||
|
(val instanceof Date && DATE) ||
|
||
|
(val === null && NULL) ||
|
||
|
typeof val;
|
||
|
|
||
|
/**
|
||
|
* Replaces matching values in a string with a new value.
|
||
|
* Example:
|
||
|
* filterStr('foo&bar', { '&': '&' });
|
||
|
* -> 'foo&bar'
|
||
|
*/
|
||
|
const filterStr = (inputStr = "", filter = {}) => {
|
||
|
// Passthrough/no-op for nonstrings (e.g. number, boolean).
|
||
|
if (typeof inputStr !== "string") {
|
||
|
return inputStr;
|
||
|
}
|
||
|
|
||
|
const regexp = new RegExp(
|
||
|
`(${Object.keys(filter).join("|")})(?!(\\w|#)*;)`,
|
||
|
"g"
|
||
|
);
|
||
|
|
||
|
return String(inputStr).replace(
|
||
|
regexp,
|
||
|
(str, entity) => filter[entity] || ""
|
||
|
);
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Maps an object or array of arribute keyval pairs to a string.
|
||
|
* Examples:
|
||
|
* { foo: 'bar', baz: 'g' } -> 'foo="bar" baz="g"'
|
||
|
* [ { ⚡: true }, { foo: 'bar' } ] -> '⚡ foo="bar"'
|
||
|
*/
|
||
|
const getAttributeKeyVals = (attributes = {}, filter) => {
|
||
|
let keyVals = [];
|
||
|
if (Array.isArray(attributes)) {
|
||
|
// Array containing complex objects and potentially duplicate attributes.
|
||
|
keyVals = attributes.map((attr) => {
|
||
|
const key = Object.keys(attr)[0];
|
||
|
const val = attr[key];
|
||
|
|
||
|
const filteredVal = filter ? filterStr(val, filter) : val;
|
||
|
const valStr = filteredVal === true ? "" : `="${filteredVal}"`;
|
||
|
return `${key}${valStr}`;
|
||
|
});
|
||
|
} else {
|
||
|
const keys = Object.keys(attributes);
|
||
|
keyVals = keys.map((key) => {
|
||
|
// Simple object - keyval pairs.
|
||
|
|
||
|
// For boolean true, simply output the key.
|
||
|
const filteredVal = filter
|
||
|
? filterStr(attributes[key], filter)
|
||
|
: attributes[key];
|
||
|
const valStr = attributes[key] === true ? "" : `="${filteredVal}"`;
|
||
|
|
||
|
return `${key}${valStr}`;
|
||
|
});
|
||
|
}
|
||
|
|
||
|
return keyVals;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Converts an attributes object/array to a string of keyval pairs.
|
||
|
* Example:
|
||
|
* formatAttributes({ a: 1, b: 2 })
|
||
|
* -> 'a="1" b="2"'
|
||
|
*/
|
||
|
const formatAttributes = (attributes = {}, filter) => {
|
||
|
const keyVals = getAttributeKeyVals(attributes, filter);
|
||
|
if (keyVals.length === 0) return "";
|
||
|
|
||
|
const keysValsJoined = keyVals.join(" ");
|
||
|
return ` ${keysValsJoined}`;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Converts an object to a jstoxml array.
|
||
|
* Example:
|
||
|
* objToArray({ foo: 'bar', baz: 2 });
|
||
|
* ->
|
||
|
* [
|
||
|
* {
|
||
|
* _name: 'foo',
|
||
|
* _content: 'bar'
|
||
|
* },
|
||
|
* {
|
||
|
* _name: 'baz',
|
||
|
* _content: 2
|
||
|
* }
|
||
|
* ]
|
||
|
*/
|
||
|
const objToArray = (obj = {}) =>
|
||
|
Object.keys(obj).map((key) => {
|
||
|
return {
|
||
|
_name: key,
|
||
|
_content: obj[key],
|
||
|
};
|
||
|
});
|
||
|
|
||
|
/**
|
||
|
* Determines if a value is a primitive JavaScript value (not including Symbol).
|
||
|
* Example:
|
||
|
* isPrimitive(4);
|
||
|
* -> true
|
||
|
*/
|
||
|
const PRIMITIVE_TYPES = [STRING, NUMBER, BOOLEAN];
|
||
|
const isPrimitive = (val) => PRIMITIVE_TYPES.includes(getType(val));
|
||
|
|
||
|
/**
|
||
|
* Determines if a value is a simple primitive type that can fit onto one line. Needed for
|
||
|
* determining any needed indenting and line breaks.
|
||
|
* Example:
|
||
|
* isSimpleType(new Date());
|
||
|
* -> true
|
||
|
*/
|
||
|
const SIMPLE_TYPES = [...PRIMITIVE_TYPES, DATE, SPECIAL_OBJECT];
|
||
|
const isSimpleType = (val) => SIMPLE_TYPES.includes(getType(val));
|
||
|
/**
|
||
|
* Determines if an XML string is a simple primitive, or contains nested data.
|
||
|
* Example:
|
||
|
* isSimpleXML('<foo />');
|
||
|
* -> false
|
||
|
*/
|
||
|
const isSimpleXML = (xmlStr) => !xmlStr.match("<");
|
||
|
|
||
|
/**
|
||
|
* Assembles an XML header as defined by the config.
|
||
|
*/
|
||
|
const DEFAULT_XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>';
|
||
|
const getHeaderString = ({ header, indent, isOutputStart /*, depth */ }) => {
|
||
|
const shouldOutputHeader = header && isOutputStart;
|
||
|
if (!shouldOutputHeader) return "";
|
||
|
|
||
|
const shouldUseDefaultHeader = typeof header === BOOLEAN;
|
||
|
// return `${shouldUseDefaultHeader ? DEFAULT_XML_HEADER : header}${indent ? "\n" : ""
|
||
|
// }`;
|
||
|
return shouldUseDefaultHeader ? DEFAULT_XML_HEADER : header;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Recursively traverses an object tree and converts the output to an XML string.
|
||
|
* Example:
|
||
|
* toXML({ foo: 'bar' });
|
||
|
* -> <foo>bar</foo>
|
||
|
*/
|
||
|
const defaultEntityFilter = {
|
||
|
"<": "<",
|
||
|
">": ">",
|
||
|
"&": "&",
|
||
|
};
|
||
|
export const toXML = (obj = {}, config = {}) => {
|
||
|
const {
|
||
|
// Tree depth
|
||
|
depth = 0,
|
||
|
indent,
|
||
|
_isFirstItem,
|
||
|
// _isLastItem,
|
||
|
_isOutputStart = true,
|
||
|
header,
|
||
|
attributesFilter: rawAttributesFilter = {},
|
||
|
filter: rawFilter = {},
|
||
|
} = config;
|
||
|
|
||
|
const shouldTurnOffAttributesFilter = typeof rawAttributesFilter === 'boolean' && !rawAttributesFilter;
|
||
|
const attributesFilter = shouldTurnOffAttributesFilter ? {} : {
|
||
|
...defaultEntityFilter,
|
||
|
...{ '"': """ },
|
||
|
...rawAttributesFilter,
|
||
|
};
|
||
|
|
||
|
const shouldTurnOffFilter = typeof rawFilter === 'boolean' && !rawFilter;
|
||
|
const filter = shouldTurnOffFilter ? {} : { ...defaultEntityFilter, ...rawFilter };
|
||
|
|
||
|
// Determine indent string based on depth.
|
||
|
const indentStr = getIndentStr(indent, depth);
|
||
|
|
||
|
// For branching based on value type.
|
||
|
const valType = getType(obj);
|
||
|
|
||
|
const headerStr = getHeaderString({ header, indent, depth, isOutputStart: _isOutputStart });
|
||
|
|
||
|
const isOutputStart = _isOutputStart && !headerStr && _isFirstItem && depth === 0;
|
||
|
|
||
|
let outputStr = "";
|
||
|
switch (valType) {
|
||
|
case "special-object": {
|
||
|
// Processes a specially-formatted object used by jstoxml.
|
||
|
|
||
|
const { _name, _content } = obj;
|
||
|
|
||
|
// Output text content without a tag wrapper.
|
||
|
if (_content === null) {
|
||
|
outputStr = _name;
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
// Handles arrays of primitive values. (#33)
|
||
|
const isArrayOfPrimitives =
|
||
|
Array.isArray(_content) && _content.every(isPrimitive);
|
||
|
if (isArrayOfPrimitives) {
|
||
|
const primitives = _content
|
||
|
.map((a) => {
|
||
|
return toXML(
|
||
|
{
|
||
|
_name,
|
||
|
_content: a,
|
||
|
},
|
||
|
{
|
||
|
...config,
|
||
|
depth,
|
||
|
_isOutputStart: false
|
||
|
}
|
||
|
);
|
||
|
});
|
||
|
return primitives.join('');
|
||
|
}
|
||
|
|
||
|
// Don't output private vars (such as _attrs).
|
||
|
if (_name.match(PRIVATE_VARS_REGEXP)) break;
|
||
|
|
||
|
// Process the nested new value and create new config.
|
||
|
const newVal = toXML(_content, { ...config, depth: depth + 1, _isOutputStart: isOutputStart });
|
||
|
const newValType = getType(newVal);
|
||
|
const isNewValSimple = isSimpleXML(newVal);
|
||
|
|
||
|
// Pre-tag output (indent and line breaks).
|
||
|
const preIndentStr = (indent && !isOutputStart) ? "\n" : "";
|
||
|
const preTag = `${preIndentStr}${indentStr}`;
|
||
|
|
||
|
// Special handling for comments, preserving preceding line breaks/indents.
|
||
|
if (_name === '_comment') {
|
||
|
outputStr += `${preTag}<!-- ${_content} -->`;
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
// Tag output.
|
||
|
const valIsEmpty = newValType === "undefined" || newVal === "";
|
||
|
const shouldSelfClose =
|
||
|
typeof obj._selfCloseTag === BOOLEAN
|
||
|
? valIsEmpty && obj._selfCloseTag
|
||
|
: valIsEmpty;
|
||
|
const selfCloseStr = shouldSelfClose ? "/" : "";
|
||
|
const attributesString = formatAttributes(obj._attrs, attributesFilter);
|
||
|
const tag = `<${_name}${attributesString}${selfCloseStr}>`;
|
||
|
|
||
|
// Post-tag output (closing tag, indent, line breaks).
|
||
|
const preTagCloseStr = indent && !isNewValSimple ? `\n${indentStr}` : "";
|
||
|
const postTag = !shouldSelfClose
|
||
|
? `${newVal}${preTagCloseStr}</${_name}>`
|
||
|
: "";
|
||
|
outputStr += `${preTag}${tag}${postTag}`;
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
case "object": {
|
||
|
// Iterates over keyval pairs in an object, converting each item to a special-object.
|
||
|
|
||
|
const keys = Object.keys(obj);
|
||
|
const outputArr = keys.map((key, index) => {
|
||
|
const newConfig = {
|
||
|
...config,
|
||
|
_isFirstItem: index === 0,
|
||
|
_isLastItem: index + 1 === keys.length,
|
||
|
_isOutputStart: isOutputStart
|
||
|
};
|
||
|
|
||
|
const outputObj = { _name: key };
|
||
|
|
||
|
if (getType(obj[key]) === "object") {
|
||
|
// Sub-object contains an object.
|
||
|
|
||
|
// Move private vars up as needed. Needed to support certain types of objects
|
||
|
// E.g. { foo: { _attrs: { a: 1 } } } -> <foo a="1"/>
|
||
|
PRIVATE_VARS.forEach((privateVar) => {
|
||
|
const val = obj[key][privateVar];
|
||
|
if (typeof val !== "undefined") {
|
||
|
outputObj[privateVar] = val;
|
||
|
delete obj[key][privateVar];
|
||
|
}
|
||
|
});
|
||
|
|
||
|
const hasContent = typeof obj[key]._content !== "undefined";
|
||
|
if (hasContent) {
|
||
|
// _content has sibling keys, so pass as an array (edge case).
|
||
|
// E.g. { foo: 'bar', _content: { baz: 2 } } -> <foo>bar</foo><baz>2</baz>
|
||
|
if (Object.keys(obj[key]).length > 1) {
|
||
|
const newContentObj = Object.assign({}, obj[key]);
|
||
|
delete newContentObj._content;
|
||
|
|
||
|
outputObj._content = [
|
||
|
...objToArray(newContentObj),
|
||
|
obj[key]._content,
|
||
|
];
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Fallthrough: just pass the key as the content for the new special-object.
|
||
|
if (typeof outputObj._content === "undefined")
|
||
|
outputObj._content = obj[key];
|
||
|
|
||
|
const xml = toXML(outputObj, newConfig, key);
|
||
|
|
||
|
return xml;
|
||
|
}, config);
|
||
|
|
||
|
outputStr = outputArr.join('');
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
case "function": {
|
||
|
// Executes a user-defined function and returns output.
|
||
|
|
||
|
const fnResult = obj(config);
|
||
|
|
||
|
outputStr = toXML(fnResult, config);
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
case "array": {
|
||
|
// Iterates and converts each value in an array.
|
||
|
const outputArr = obj.map((singleVal, index) => {
|
||
|
const newConfig = {
|
||
|
...config,
|
||
|
_isFirstItem: index === 0,
|
||
|
_isLastItem: index + 1 === obj.length,
|
||
|
_isOutputStart: isOutputStart
|
||
|
};
|
||
|
return toXML(singleVal, newConfig);
|
||
|
});
|
||
|
|
||
|
outputStr = outputArr.join('');
|
||
|
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
// number, string, boolean, date, null, etc
|
||
|
default: {
|
||
|
outputStr = filterStr(obj, filter);
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return `${headerStr}${outputStr}`;
|
||
|
};
|
||
|
|
||
|
export default {
|
||
|
toXML,
|
||
|
};
|