UNPKG

jstoxml

Version:

Converts JavaScript/JSON to XML (for RSS, Podcasts, AMP, etc.)

386 lines (327 loc) 12.7 kB
const DATA_TYPES = { ARRAY: 'array', BOOLEAN: 'boolean', DATE: 'date', FUNCTION: 'function', JSTOXML_OBJECT: 'jstoxml-object', NULL: 'null', NUMBER: 'number', OBJECT: 'object', STRING: 'string' }; const PRIMITIVE_TYPES = [DATA_TYPES.STRING, DATA_TYPES.NUMBER, DATA_TYPES.BOOLEAN]; const DEFAULT_XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>'; const PRIVATE_VARS = ['_selfCloseTag', '_attrs']; /** * 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) && DATA_TYPES.ARRAY) || (typeof val === DATA_TYPES.OBJECT && val !== null && val._name && DATA_TYPES.JSTOXML_OBJECT) || (val instanceof Date && DATA_TYPES.DATE) || (val === null && DATA_TYPES.NULL) || typeof val; /** * Determines if a string is CDATA and shouldn't be touched. * @example * isCDATA('<![CDATA[<b>test</b>]]>'); * // -> true */ const isCDATA = (str) => str.startsWith('<![CDATA['); /** * Replaces matching values in a string with a new value. * @example * mapStr('foo&bar', { '&': '&amp;' }); * // -> 'foo&amp;bar' */ const mapStr = (input = '', replacements = {}, contentMap) => { let output = input; if (typeof input === DATA_TYPES.STRING) { if (isCDATA(input)) { return input; } const regexp = new RegExp(`(${Object.keys(replacements).join('|')})(?!(\\w|#)*;)`, 'g'); output = String(input).replace(regexp, (str, entity) => replacements[entity] || ''); } return typeof contentMap === 'function' ? contentMap(output) : output; }; /** * Maps an object or array of arribute keyval pairs to a string. * @example * getAttributeKeyVals({ foo: 'bar', baz: 'g' }); * // -> 'foo="bar" baz="g"' * getAttributeKeyVals([ { ⚡: true }, { foo: 'bar' } ]); * // -> '⚡ foo="bar"' */ const getAttributeKeyVals = (attributes = {}, replacements, filter, outputExplicitTrue) => { // Normalizes between attributes as object and as array. const attributesArr = Array.isArray(attributes) ? attributes : Object.entries(attributes).map(([key, val]) => { return { [key]: val }; }); return attributesArr.reduce((allAttributes, attr) => { const key = Object.keys(attr)[0]; const val = attr[key]; if (typeof filter === DATA_TYPES.FUNCTION) { const shouldFilterOut = filter(key, val); if (shouldFilterOut) { return allAttributes; } } const replacedVal = replacements ? mapStr(val, replacements) : val; const valStr = !outputExplicitTrue && replacedVal === true ? '' : `="${replacedVal}"`; allAttributes.push(`${key}${valStr}`); return allAttributes; }, []); }; /** * Converts an attributes object/array to a string of keyval pairs. * @example * formatAttributes({ a: 1, b: 2 }) * // -> 'a="1" b="2"' */ const formatAttributes = (attributes = {}, replacements, filter, outputExplicitTrue) => { const keyVals = getAttributeKeyVals(attributes, replacements, filter, outputExplicitTrue); if (keyVals.length === 0) return ''; const keysValsJoined = keyVals.join(' '); return ` ${keysValsJoined}`; }; /** * Converts an object into an array of jstoxml-object. * @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 isPrimitive = (val) => PRIMITIVE_TYPES.includes(getType(val)); /** * Determines if an XML string is simple (doesn't contain nested XML data). * @example * isSimpleXML('<foo />'); * // -> false */ const isSimpleXML = (xmlStr) => !xmlStr.match('<'); /** * Assembles an XML header as defined by the config. */ const getHeaderString = ({ header, isOutputStart }) => { const shouldOutputHeader = header && isOutputStart; if (!shouldOutputHeader) return ''; const shouldUseDefaultHeader = typeof header === DATA_TYPES.BOOLEAN; 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 defaultEntityReplacements = { '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }; export const toXML = (obj = {}, config = {}) => { const { // Tree depth depth = 0, indent, _isFirstItem, // _isLastItem, _isOutputStart = true, header, attributeReplacements: rawAttributeReplacements = {}, attributeFilter, attributeExplicitTrue = false, contentReplacements: rawContentReplacements = {}, contentMap, selfCloseTags = true } = config; const shouldTurnOffAttributeReplacements = typeof rawAttributeReplacements === 'boolean' && !rawAttributeReplacements; const attributeReplacements = shouldTurnOffAttributeReplacements ? {} : { ...defaultEntityReplacements, ...rawAttributeReplacements }; const shouldTurnOffContentReplacements = typeof rawContentReplacements === 'boolean' && !rawContentReplacements; const contentReplacements = shouldTurnOffContentReplacements ? {} : { ...defaultEntityReplacements, ...rawContentReplacements }; const shouldAddNewlines = typeof indent === 'string'; // Determines indent 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; const preIndentStr = shouldAddNewlines && !isOutputStart ? '\n' : ''; let outputStr = ''; switch (valType) { case DATA_TYPES.JSTOXML_OBJECT: { // Processes a specially-formatted object used by jstoxml. const { _name, _content } = obj; // Output text content without a tag wrapper. if (_content === null && typeof contentMap !== 'function') { outputStr = `${preIndentStr}${indentStr}${_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 (PRIVATE_VARS.includes(_name)) 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); const isNewValCDATA = isCDATA(newVal); // Pre-tag output (indent and line breaks). 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 globalSelfClose = selfCloseTags; const localSelfClose = obj._selfCloseTag; const shouldSelfClose = typeof localSelfClose === DATA_TYPES.BOOLEAN ? valIsEmpty && localSelfClose : valIsEmpty && globalSelfClose; const selfCloseStr = shouldSelfClose ? '/' : ''; const attributesString = formatAttributes( obj._attrs, attributeReplacements, attributeFilter, attributeExplicitTrue ); const tag = `<${_name}${attributesString}${selfCloseStr}>`; // Post-tag output (closing tag, indent, line breaks). const preTagCloseStr = shouldAddNewlines && !isNewValSimple && !isNewValCDATA ? `\n${indentStr}` : ''; const postTag = !shouldSelfClose ? `${newVal}${preTagCloseStr}</${_name}>` : ''; outputStr += `${preTag}${tag}${postTag}`; break; } case DATA_TYPES.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]) === DATA_TYPES.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); return xml; }, config); outputStr = outputArr.join(''); break; } case DATA_TYPES.FUNCTION: { // Executes a user-defined function and returns output. const fnResult = obj(config); outputStr = toXML(fnResult, config); break; } case DATA_TYPES.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; } // fallthrough types (number, string, boolean, date, null, etc) default: { outputStr = mapStr(obj, contentReplacements, contentMap); break; } } return `${headerStr}${outputStr}`; }; export default { toXML };