/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ var EXPORTED_SYMBOLS = [ "Templater", "template" ]; Components.utils.import("resource://gre/modules/Services.jsm"); const Node = Components.interfaces.nsIDOMNode; /** * For full documentation, see: * https://github.com/mozilla/domtemplate/blob/master/README.md */ // WARNING: do not 'use_strict' without reading the notes in _envEval(); /** * Begin a new templating process. * @param node A DOM element or string referring to an element's id * @param data Data to use in filling out the template * @param options Options to customize the template processing. One of: * - allowEval: boolean (default false) Basic template interpolations are * either property paths (e.g. ${a.b.c.d}), or if allowEval=true then we * allow arbitrary JavaScript * - stack: string or array of strings (default empty array) The template * engine maintains a stack of tasks to help debug where it is. This allows * this stack to be prefixed with a template name * - blankNullUndefined: By default DOMTemplate exports null and undefined * values using the strings 'null' and 'undefined', which can be helpful for * debugging, but can introduce unnecessary extra logic in a template to * convert null/undefined to ''. By setting blankNullUndefined:true, this * conversion is handled by DOMTemplate */ function template(node, data, options) { var template = new Templater(options || {}); template.processNode(node, data); return template; } /** * Construct a Templater object. Use template() in preference to this ctor. * @deprecated Use template(node, data, options); */ function Templater(options) { if (options == null) { options = { allowEval: true }; } this.options = options; if (options.stack && Array.isArray(options.stack)) { this.stack = options.stack; } else if (typeof options.stack === 'string') { this.stack = [ options.stack ]; } else { this.stack = []; } } /** * Cached regex used to find ${...} sections in some text. * Performance note: This regex uses ( and ) to capture the 'script' for * further processing. Not all of the uses of this regex use this feature so * if use of the capturing group is a performance drain then we should split * this regex in two. */ Templater.prototype._templateRegion = /\$\{([^}]*)\}/g; /** * Cached regex used to split a string using the unicode chars F001 and F002. * See Templater._processTextNode() for details. */ Templater.prototype._splitSpecial = /\uF001|\uF002/; /** * Cached regex used to detect if a script is capable of being interpreted * using Template._property() or if we need to use Template._envEval() */ Templater.prototype._isPropertyScript = /^[_a-zA-Z0-9.]*$/; /** * Recursive function to walk the tree processing the attributes as it goes. * @param node the node to process. If you pass a string in instead of a DOM * element, it is assumed to be an id for use with document.getElementById() * @param data the data to use for node processing. */ Templater.prototype.processNode = function(node, data) { if (typeof node === 'string') { node = document.getElementById(node); } if (data == null) { data = {}; } this.stack.push(node.nodeName + (node.id ? '#' + node.id : '')); try { // Process attributes if (node.attributes && node.attributes.length) { // We need to handle 'foreach' and 'if' first because they might stop // some types of processing from happening, and foreach must come first // because it defines new data on which 'if' might depend. if (node.hasAttribute('foreach')) { this._processForEach(node, data); return; } if (node.hasAttribute('if')) { if (!this._processIf(node, data)) { return; } } // Only make the node available once we know it's not going away data.__element = node; // It's good to clean up the attributes when we've processed them, // but if we do it straight away, we mess up the array index var attrs = Array.prototype.slice.call(node.attributes); for (var i = 0; i < attrs.length; i++) { var value = attrs[i].value; var name = attrs[i].name; this.stack.push(name); try { if (name === 'save') { // Save attributes are a setter using the node value = this._stripBraces(value); this._property(value, data, node); node.removeAttribute('save'); } else if (name.substring(0, 2) === 'on') { // Event registration relies on property doing a bind value = this._stripBraces(value); var func = this._property(value, data); if (typeof func !== 'function') { this._handleError('Expected ' + value + ' to resolve to a function, but got ' + typeof func); } node.removeAttribute(name); var capture = node.hasAttribute('capture' + name.substring(2)); node.addEventListener(name.substring(2), func, capture); if (capture) { node.removeAttribute('capture' + name.substring(2)); } } else { // Replace references in all other attributes var newValue = value.replace(this._templateRegion, function(path) { var insert = this._envEval(path.slice(2, -1), data, value); if (this.options.blankNullUndefined && insert == null) { insert = ''; } return insert; }.bind(this)); // Remove '_' prefix of attribute names so the DOM won't try // to use them before we've processed the template if (name.charAt(0) === '_') { node.removeAttribute(name); node.setAttribute(name.substring(1), newValue); } else if (value !== newValue) { attrs[i].value = newValue; } } } finally { this.stack.pop(); } } } // Loop through our children calling processNode. First clone them, so the // set of nodes that we visit will be unaffected by additions or removals. var childNodes = Array.prototype.slice.call(node.childNodes); for (var j = 0; j < childNodes.length; j++) { this.processNode(childNodes[j], data); } if (node.nodeType === 3 /*Node.TEXT_NODE*/) { this._processTextNode(node, data); } } finally { delete data.__element; this.stack.pop(); } }; /** * Handle * @param node An element with an 'if' attribute * @param data The data to use with _envEval() * @returns true if processing should continue, false otherwise */ Templater.prototype._processIf = function(node, data) { this.stack.push('if'); try { var originalValue = node.getAttribute('if'); var value = this._stripBraces(originalValue); var recurse = true; try { var reply = this._envEval(value, data, originalValue); recurse = !!reply; } catch (ex) { this._handleError('Error with \'' + value + '\'', ex); recurse = false; } if (!recurse) { node.parentNode.removeChild(node); } node.removeAttribute('if'); return recurse; } finally { this.stack.pop(); } }; /** * Handle and the special case of * . * This function is responsible for extracting what it has to do from the * attributes, and getting the data to work on (including resolving promises * in getting the array). It delegates to _processForEachLoop to actually * unroll the data. * @param node An element with a 'foreach' attribute * @param data The data to use with _envEval() */ Templater.prototype._processForEach = function(node, data) { this.stack.push('foreach'); try { var originalValue = node.getAttribute('foreach'); var value = originalValue; var paramName = 'param'; if (value.charAt(0) === '$') { // No custom loop variable name. Use the default: 'param' value = this._stripBraces(value); } else { // Extract the loop variable name from 'NAME in ${ARRAY}' var nameArr = value.split(' in '); paramName = nameArr[0].trim(); value = this._stripBraces(nameArr[1].trim()); } node.removeAttribute('foreach'); try { var evaled = this._envEval(value, data, originalValue); this._handleAsync(evaled, node, function(reply, siblingNode) { this._processForEachLoop(reply, node, siblingNode, data, paramName); }.bind(this)); node.parentNode.removeChild(node); } catch (ex) { this._handleError('Error with \'' + value + '\'', ex); } } finally { this.stack.pop(); } }; /** * Called by _processForEach to handle looping over the data in a foreach loop. * This works with both arrays and objects. * Calls _processForEachMember() for each member of 'set' * @param set The object containing the data to loop over * @param template The node to copy for each set member * @param sibling The sibling node to which we add things * @param data the data to use for node processing * @param paramName foreach loops have a name for the parameter currently being * processed. The default is 'param'. e.g. ... */ Templater.prototype._processForEachLoop = function(set, template, sibling, data, paramName) { if (Array.isArray(set)) { set.forEach(function(member, i) { this._processForEachMember(member, template, sibling, data, paramName, '' + i); }, this); } else { for (var member in set) { if (set.hasOwnProperty(member)) { this._processForEachMember(member, template, sibling, data, paramName, member); } } } }; /** * Called by _processForEachLoop() to resolve any promises in the array (the * array itself can also be a promise, but that is resolved by * _processForEach()). Handle elements (which are taken out of the DOM), * clone the template, and pass the processing on to processNode(). * @param member The data item to use in templating * @param template The node to copy for each set member * @param siblingNode The parent node to which we add things * @param data the data to use for node processing * @param paramName The name given to 'member' by the foreach attribute * @param frame A name to push on the stack for debugging */ Templater.prototype._processForEachMember = function(member, template, siblingNode, data, paramName, frame) { this.stack.push(frame); try { this._handleAsync(member, siblingNode, function(reply, node) { data[paramName] = reply; if (template.nodeName.toLowerCase() === 'loop') { for (var i = 0; i < template.childNodes.length; i++) { var clone = template.childNodes[i].cloneNode(true); node.parentNode.insertBefore(clone, node); this.processNode(clone, data); } } else { var clone = template.cloneNode(true); clone.removeAttribute('foreach'); node.parentNode.insertBefore(clone, node); this.processNode(clone, data); } delete data[paramName]; }.bind(this)); } finally { this.stack.pop(); } }; /** * Take a text node and replace it with another text node with the ${...} * sections parsed out. We replace the node by altering node.parentNode but * we could probably use a DOM Text API to achieve the same thing. * @param node The Text node to work on * @param data The data to use in calls to _envEval() */ Templater.prototype._processTextNode = function(node, data) { // Replace references in other attributes var value = node.data; // We can't use the string.replace() with function trick (see generic // attribute processing in processNode()) because we need to support // functions that return DOM nodes, so we can't have the conversion to a // string. // Instead we process the string as an array of parts. In order to split // the string up, we first replace '${' with '\uF001$' and '}' with '\uF002' // We can then split using \uF001 or \uF002 to get an array of strings // where scripts are prefixed with $. // \uF001 and \uF002 are just unicode chars reserved for private use. value = value.replace(this._templateRegion, '\uF001$$$1\uF002'); var parts = value.split(this._splitSpecial); if (parts.length > 1) { parts.forEach(function(part) { if (part === null || part === undefined || part === '') { return; } if (part.charAt(0) === '$') { part = this._envEval(part.slice(1), data, node.data); } this._handleAsync(part, node, function(reply, siblingNode) { var doc = siblingNode.ownerDocument; if (reply == null) { reply = this.options.blankNullUndefined ? '' : '' + reply; } if (typeof reply.cloneNode === 'function') { // i.e. if (reply instanceof Element) { ... reply = this._maybeImportNode(reply, doc); siblingNode.parentNode.insertBefore(reply, siblingNode); } else if (typeof reply.item === 'function' && reply.length) { // if thing is a NodeList, then import the children for (var i = 0; i < reply.length; i++) { var child = this._maybeImportNode(reply.item(i), doc); siblingNode.parentNode.insertBefore(child, siblingNode); } } else { // if thing isn't a DOM element then wrap its string value in one reply = doc.createTextNode(reply.toString()); siblingNode.parentNode.insertBefore(reply, siblingNode); } }.bind(this)); }, this); node.parentNode.removeChild(node); } }; /** * Return node or a import of node, if it's not in the given document * @param node The node that we want to be properly owned * @param doc The document that the given node should belong to * @return A node that belongs to the given document */ Templater.prototype._maybeImportNode = function(node, doc) { return node.ownerDocument === doc ? node : doc.importNode(node, true); }; /** * A function to handle the fact that some nodes can be promises, so we check * and resolve if needed using a marker node to keep our place before calling * an inserter function. * @param thing The object which could be real data or a promise of real data * we use it directly if it's not a promise, or resolve it if it is. * @param siblingNode The element before which we insert new elements. * @param inserter The function to to the insertion. If thing is not a promise * then _handleAsync() is just 'inserter(thing, siblingNode)' */ Templater.prototype._handleAsync = function(thing, siblingNode, inserter) { if (thing != null && typeof thing.then === 'function') { // Placeholder element to be replaced once we have the real data var tempNode = siblingNode.ownerDocument.createElement('span'); siblingNode.parentNode.insertBefore(tempNode, siblingNode); thing.then(function(delayed) { inserter(delayed, tempNode); tempNode.parentNode.removeChild(tempNode); }.bind(this)); } else { inserter(thing, siblingNode); } }; /** * Warn of string does not begin '${' and end '}' * @param str the string to check. * @return The string stripped of ${ and }, or untouched if it does not match */ Templater.prototype._stripBraces = function(str) { if (!str.match(this._templateRegion)) { this._handleError('Expected ' + str + ' to match ${...}'); return str; } return str.slice(2, -1); }; /** * Combined getter and setter that works with a path through some data set. * For example: *
    *
  • _property('a.b', { a: { b: 99 }}); // returns 99 *
  • _property('a', { a: { b: 99 }}); // returns { b: 99 } *
  • _property('a', { a: { b: 99 }}, 42); // returns 99 and alters the * input data to be { a: { b: 42 }} *
* @param path An array of strings indicating the path through the data, or * a string to be cut into an array using split('.') * @param data the data to use for node processing * @param newValue (optional) If defined, this value will replace the * original value for the data at the path specified. * @return The value pointed to by path before any * newValue is applied. */ Templater.prototype._property = function(path, data, newValue) { try { if (typeof path === 'string') { path = path.split('.'); } var value = data[path[0]]; if (path.length === 1) { if (newValue !== undefined) { data[path[0]] = newValue; } if (typeof value === 'function') { return value.bind(data); } return value; } if (!value) { this._handleError('"' + path[0] + '" is undefined'); return null; } return this._property(path.slice(1), value, newValue); } catch (ex) { this._handleError('Path error with \'' + path + '\'', ex); return '${' + path + '}'; } }; /** * Like eval, but that creates a context of the variables in env in * which the script is evaluated. * WARNING: This script uses 'with' which is generally regarded to be evil. * The alternative is to create a Function at runtime that takes X parameters * according to the X keys in the env object, and then call that function using * the values in the env object. This is likely to be slow, but workable. * @param script The string to be evaluated. * @param data The environment in which to eval the script. * @param frame Optional debugging string in case of failure. * @return The return value of the script, or the error message if the script * execution failed. */ Templater.prototype._envEval = function(script, data, frame) { try { this.stack.push(frame.replace(/\s+/g, ' ')); if (this._isPropertyScript.test(script)) { return this._property(script, data); } else { if (!this.options.allowEval) { this._handleError('allowEval is not set, however \'' + script + '\'' + ' can not be resolved using a simple property path.'); return '${' + script + '}'; } with (data) { return eval(script); } } } catch (ex) { this._handleError('Template error evaluating \'' + script + '\'', ex); return '${' + script + '}'; } finally { this.stack.pop(); } }; /** * A generic way of reporting errors, for easy overloading in different * environments. * @param message the error message to report. * @param ex optional associated exception. */ Templater.prototype._handleError = function(message, ex) { this._logError(message + ' (In: ' + this.stack.join(' > ') + ')'); if (ex) { this._logError(ex); } }; /** * A generic way of reporting errors, for easy overloading in different * environments. * @param message the error message to report. */ Templater.prototype._logError = function(message) { Services.console.logStringMessage(message); };