| 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, | |
| };
 |