UNPKG

9.36 kBJavaScriptView Raw
1const ARRAY = 'array';
2const BOOLEAN = 'boolean';
3const DATE = 'date';
4const NULL = 'null';
5const NUMBER = 'number';
6const OBJECT = 'object';
7const SPECIAL_OBJECT = 'special-object';
8const STRING = 'string';
9
10const PRIVATE_VARS = ['_selfCloseTag', '_attrs'];
11const PRIVATE_VARS_REGEXP = new RegExp(PRIVATE_VARS.join('|'), 'g');
12
13/**
14 * Determines the indent string based on current tree depth.
15 */
16const getIndentStr = (indent = '', depth = 0) => indent.repeat(depth);
17
18/**
19 * Sugar function supplementing JS's quirky typeof operator, plus some extra help to detect
20 * "special objects" expected by jstoxml.
21 * Example:
22 * getType(new Date());
23 * -> 'date'
24 */
25const getType = val =>
26 Array.isArray(val) && ARRAY ||
27 (typeof val === OBJECT && val !== null && val._name && SPECIAL_OBJECT) ||
28 (val instanceof Date && DATE) ||
29 val === null && NULL ||
30 typeof val;
31
32/**
33 * Replaces matching values in a string with a new value.
34 * Example:
35 * filterStr('foo&bar', { '&': '&' });
36 * -> 'foo&bar'
37 */
38const filterStr = (inputStr = '', filter = {}) => {
39 const regexp = new RegExp(`(${ Object.keys(filter).join('|') })`, 'g');
40
41 return String(inputStr).replace(regexp, (str, entity) => filter[entity] || '');
42};
43
44/**
45 * Maps an object or array of arribute keyval pairs to a string.
46 * Examples:
47 * { foo: 'bar', baz: 'g' } -> 'foo="bar" baz="g"'
48 * [ { ⚡: true }, { foo: 'bar' } ] -> '⚡ foo="bar"'
49 */
50const getAttributeKeyVals = (attributes = {}, filter) => {
51 const isArray = Array.isArray(attributes);
52
53 let keyVals = [];
54 if (isArray) {
55 // Array containing complex objects and potentially duplicate attributes.
56 keyVals = attributes.map(attr => {
57 const key = Object.keys(attr)[0];
58 const val = attr[key];
59
60 const filteredVal = (filter) ? filterStr(val, filter) : val;
61 const valStr = (filteredVal === true) ? '' : `="${filteredVal}"`;
62 return `${key}${valStr}`;
63 });
64 } else {
65 const keys = Object.keys(attributes);
66 keyVals = keys.map(key => {
67 // Simple object - keyval pairs.
68
69 // For boolean true, simply output the key.
70 const filteredVal = (filter) ? filterStr(attributes[key], filter) : attributes[key];
71 const valStr = (attributes[key] === true) ? '' : `="${filteredVal}"`;
72
73 return `${key}${valStr}`;
74 });
75 }
76
77 return keyVals;
78};
79
80/**
81 * Converts an attributes object/array to a string of keyval pairs.
82 * Example:
83 * formatAttributes({ a: 1, b: 2 })
84 * -> 'a="1" b="2"'
85 */
86const formatAttributes = (attributes = {}, filter) => {
87 const keyVals = getAttributeKeyVals(attributes, filter);
88 if (keyVals.length === 0) return '';
89
90 const keysValsJoined = keyVals.join(' ');
91 return ` ${keysValsJoined}`;
92};
93
94/**
95 * Converts an object to a jstoxml array.
96 * Example:
97 * objToArray({ foo: 'bar', baz: 2 });
98 * ->
99 * [
100 * {
101 * _name: 'foo',
102 * _content: 'bar'
103 * },
104 * {
105 * _name: 'baz',
106 * _content: 2
107 * }
108 * ]
109 */
110const objToArray = (obj = {}) => (Object.keys(obj).map(key => {
111 return {
112 _name: key,
113 _content: obj[key]
114 };
115}));
116
117/**
118 * Determines if a value is a primitive JavaScript value (not including Symbol).
119 * Example:
120 * isPrimitive(4);
121 * -> true
122 */
123const PRIMITIVE_TYPES = [STRING, NUMBER, BOOLEAN];
124const isPrimitive = val => PRIMITIVE_TYPES.includes(getType(val));
125
126/**
127 * Determines if a value is a simple primitive type that can fit onto one line. Needed for
128 * determining any needed indenting and line breaks.
129 * Example:
130 * isSimpleType(new Date());
131 * -> true
132 */
133const SIMPLE_TYPES = [...PRIMITIVE_TYPES, DATE, SPECIAL_OBJECT];
134const isSimpleType = val => SIMPLE_TYPES.includes(getType(val));
135/**
136 * Determines if an XML string is a simple primitive, or contains nested data.
137 * Example:
138 * isSimpleXML('<foo />');
139 * -> false
140 */
141const isSimpleXML = xmlStr => !xmlStr.match('<');
142
143/**
144 * Assembles an XML header as defined by the config.
145 */
146const DEFAULT_XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>';
147const getHeaderString = ({ header, indent, depth, isOutputStart }) => {
148 const shouldOutputHeader = header && isOutputStart;
149
150 if (!shouldOutputHeader) return '';
151
152 const shouldUseDefaultHeader = typeof header === BOOLEAN;
153 return `${ (shouldUseDefaultHeader) ? DEFAULT_XML_HEADER : header }${ indent ? '\n' : '' }`;
154};
155
156/**
157 * Recursively traverses an object tree and converts the output to an XML string.
158 * Example:
159 * toXML({ foo: 'bar' });
160 * -> <foo>bar</foo>
161 */
162export const toXML = (
163 obj = {},
164 config = {}
165) => {
166 const {
167 // Tree depth
168 depth = 0,
169 indent,
170 _isFirstItem,
171 _isLastItem,
172 attributesFilter,
173 header,
174 filter
175 } = config;
176
177 // Determine indent string based on depth.
178 const indentStr = getIndentStr(indent, depth);
179
180 // For branching based on value type.
181 const valType = getType(obj);
182 const isSimple = isSimpleType(obj);
183
184 // Determine if this is the start of the output. Needed for header and indenting.
185 const isOutputStart = depth === 0 && (isSimple || (!isSimple && _isFirstItem));
186
187 let outputStr = '';
188 switch (valType) {
189 case 'special-object': {
190 // Processes a specially-formatted object used by jstoxml.
191
192 const { _name, _content } = obj;
193
194 // Output text content without a tag wrapper.
195 if (_content === null) {
196 outputStr = _name;
197 break;
198 }
199
200 // Handles arrays of primitive values. (#33)
201 if (Array.isArray(_content) && _content.every(isPrimitive)) {
202 return _content.map(a => {
203 return toXML({
204 _name,
205 _content: a
206 },
207 {
208 ...config,
209 depth
210 });
211 }).join('');
212 }
213
214 // Don't output private vars (such as _attrs).
215 if (_name.match(PRIVATE_VARS_REGEXP)) break;
216
217 // Process the nested new value and create new config.
218 const newVal = toXML(_content, { ...config, depth: depth + 1 });
219 const newValType = getType(newVal);
220 const isNewValSimple = isSimpleXML(newVal);
221
222 // Pre-tag output (indent and line breaks).
223 const preIndentStr = (indent && !isOutputStart) ? '\n' : '';
224 const preTag = `${preIndentStr}${indentStr}`;
225
226 // Tag output.
227 const valIsEmpty = newValType === 'undefined' || newVal === '';
228 const shouldSelfClose = (typeof obj._selfCloseTag === BOOLEAN) ?
229 (valIsEmpty && obj._selfCloseTag) :
230 valIsEmpty;
231 const selfCloseStr = (shouldSelfClose) ? '/' : '';
232 const attributesString = formatAttributes(obj._attrs, attributesFilter);
233 const tag = `<${_name}${attributesString}${selfCloseStr}>`;
234
235 // Post-tag output (closing tag, indent, line breaks).
236 const preTagCloseStr = (indent && !isNewValSimple) ? `\n${indentStr}` : '';
237 const postTag = (!shouldSelfClose) ? `${newVal}${preTagCloseStr}</${_name}>` : '';
238
239 outputStr = `${preTag}${tag}${postTag}`;
240 break;
241 }
242
243 case 'object': {
244 // Iterates over keyval pairs in an object, converting each item to a special-object.
245
246 const keys = Object.keys(obj);
247 const outputArr = keys.map((key, index) => {
248 const newConfig = {
249 ...config,
250 _isFirstItem: index === 0,
251 _isLastItem: ((index + 1) === keys.length)
252 };
253
254 const outputObj = { _name: key };
255
256 if (getType(obj[key]) === 'object') {
257 // Sub-object contains an object.
258
259 // Move private vars up as needed. Needed to support certain types of objects
260 // E.g. { foo: { _attrs: { a: 1 } } } -> <foo a="1"/>
261 PRIVATE_VARS.forEach(privateVar => {
262 const val = obj[key][privateVar];
263 if (typeof val !== 'undefined') {
264 outputObj[privateVar] = val;
265 delete obj[key][privateVar];
266 }
267 });
268
269 const hasContent = typeof obj[key]._content !== 'undefined';
270 if (hasContent) {
271 // _content has sibling keys, so pass as an array (edge case).
272 // E.g. { foo: 'bar', _content: { baz: 2 } } -> <foo>bar</foo><baz>2</baz>
273 if (Object.keys(obj[key]).length > 1) {
274 const newContentObj = Object.assign({}, obj[key]);
275 delete newContentObj._content;
276
277 outputObj._content = [
278 ...objToArray(newContentObj),
279 obj[key]._content
280 ];
281 }
282 }
283 }
284
285 // Fallthrough: just pass the key as the content for the new special-object.
286 if (typeof outputObj._content === 'undefined') outputObj._content = obj[key];
287
288 const xml = toXML(outputObj, newConfig, key);
289
290 return xml;
291 }, config);
292
293 outputStr = outputArr.join('');
294 break;
295 }
296
297 case 'function': {
298 // Executes a user-defined function and return output.
299
300 const fnResult = obj(config);
301
302 outputStr = toXML(fnResult, config);
303 break;
304 }
305
306 case 'array': {
307 // Iterates and converts each value in an array.
308
309 const outputArr = obj.map((singleVal, index) => {
310 const newConfig = {
311 ...config,
312 _isFirstItem: index === 0,
313 _isLastItem: ((index + 1) === obj.length)
314 };
315 return toXML(singleVal, newConfig);
316 });
317
318 outputStr = outputArr.join('');
319
320 break;
321 }
322
323 // number, string, boolean, date, null, etc
324 default: {
325 outputStr = filterStr(obj, filter);
326 break;
327 }
328 }
329
330 const headerStr = getHeaderString({ header, indent, depth, isOutputStart });
331
332 return `${headerStr}${outputStr}`;
333};
334
335export default {
336 toXML
337};
338