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(''); * -> false */ const isSimpleXML = (xmlStr) => !xmlStr.match("<"); /** * Assembles an XML header as defined by the config. */ const DEFAULT_XML_HEADER = ''; 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' }); * -> bar */ 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}`; 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}` : ""; 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 } } } -> 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 } } -> bar2 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, };