/* * Copyright 2012, Mozilla Foundation and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ this.EXPORTED_SYMBOLS = [ "template" ]; Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "console", "resource://gre/modules/Console.jsm"); 'use strict'; /** * For full documentation, see: * https://github.com/mozilla/domtemplate/blob/master/README.md */ /** * 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 */ var template = function(node, data, options) { var state = { options: options || {}, // We keep a track of the nodes that we've passed through so we can keep // data.__element pointing to the correct node nodes: [] }; state.stack = state.options.stack; if (!Array.isArray(state.stack)) { if (typeof state.stack === 'string') { state.stack = [ options.stack ]; } else { state.stack = []; } } processNode(state, node, data); }; if (typeof exports !== 'undefined') { exports.template = template; } /** * Helper for the places where we need to act asynchronously and keep track of * where we are right now */ function cloneState(state) { return { options: state.options, stack: state.stack.slice(), nodes: state.nodes.slice() }; } /** * 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. */ var TEMPLATE_REGION = /\$\{([^}]*)\}/g; /** * 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. */ function processNode(state, node, data) { if (typeof node === 'string') { node = document.getElementById(node); } if (data == null) { data = {}; } state.stack.push(node.nodeName + (node.id ? '#' + node.id : '')); var pushedNode = false; 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')) { processForEach(state, node, data); return; } if (node.hasAttribute('if')) { if (!processIf(state, node, data)) { return; } } // Only make the node available once we know it's not going away state.nodes.push(data.__element); data.__element = node; pushedNode = true; // 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; state.stack.push(name); try { if (name === 'save') { // Save attributes are a setter using the node value = stripBraces(state, value); property(state, value, data, node); node.removeAttribute('save'); } else if (name.substring(0, 2) === 'on') { // If this attribute value contains only an expression if (value.substring(0, 2) === '${' && value.slice(-1) === '}' && value.indexOf('${', 2) === -1) { value = stripBraces(state, value); var func = property(state, value, data); if (typeof func === 'function') { 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 { // Attribute value is not a function - use as a DOM-L0 string node.setAttribute(name, func); } } else { // Attribute value is not a single expression use as DOM-L0 node.setAttribute(name, processString(state, value, data)); } } else { node.removeAttribute(name); // Remove '_' prefix of attribute names so the DOM won't try // to use them before we've processed the template if (name.charAt(0) === '_') { name = name.substring(1); } // Async attributes can only work if the whole attribute is async var replacement; if (value.indexOf('${') === 0 && value.charAt(value.length - 1) === '}') { replacement = envEval(state, value.slice(2, -1), data, value); if (replacement && typeof replacement.then === 'function') { node.setAttribute(name, ''); /* jshint loopfunc:true */ replacement.then(function(newValue) { node.setAttribute(name, newValue); }).then(null, console.error); } else { if (state.options.blankNullUndefined && replacement == null) { replacement = ''; } node.setAttribute(name, replacement); } } else { node.setAttribute(name, processString(state, value, data)); } } } finally { state.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++) { processNode(state, childNodes[j], data); } if (node.nodeType === 3 /*Node.TEXT_NODE*/) { processTextNode(state, node, data); } } finally { if (pushedNode) { data.__element = state.nodes.pop(); } state.stack.pop(); } } /** * Handle attribute values where the output can only be a string */ function processString(state, value, data) { return value.replace(TEMPLATE_REGION, function(path) { var insert = envEval(state, path.slice(2, -1), data, value); return state.options.blankNullUndefined && insert == null ? '' : insert; }); } /** * 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 */ function processIf(state, node, data) { state.stack.push('if'); try { var originalValue = node.getAttribute('if'); var value = stripBraces(state, originalValue); var recurse = true; try { var reply = envEval(state, value, data, originalValue); recurse = !!reply; } catch (ex) { handleError(state, 'Error with \'' + value + '\'', ex); recurse = false; } if (!recurse) { node.parentNode.removeChild(node); } node.removeAttribute('if'); return recurse; } finally { state.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() */ function processForEach(state, node, data) { state.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 = stripBraces(state, value); } else { // Extract the loop variable name from 'NAME in ${ARRAY}' var nameArr = value.split(' in '); paramName = nameArr[0].trim(); value = stripBraces(state, nameArr[1].trim()); } node.removeAttribute('foreach'); try { var evaled = envEval(state, value, data, originalValue); var cState = cloneState(state); handleAsync(evaled, node, function(reply, siblingNode) { processForEachLoop(cState, reply, node, siblingNode, data, paramName); }); node.parentNode.removeChild(node); } catch (ex) { handleError(state, 'Error with \'' + value + '\'', ex); } } finally { state.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 templNode 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. ... */ function processForEachLoop(state, set, templNode, sibling, data, paramName) { if (Array.isArray(set)) { set.forEach(function(member, i) { processForEachMember(state, member, templNode, sibling, data, paramName, '' + i); }); } else { for (var member in set) { if (set.hasOwnProperty(member)) { processForEachMember(state, member, templNode, 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 node, and pass the processing on to processNode(). * @param member The data item to use in templating * @param templNode 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 */ function processForEachMember(state, member, templNode, siblingNode, data, paramName, frame) { state.stack.push(frame); try { var cState = cloneState(state); handleAsync(member, siblingNode, function(reply, node) { // Clone data because we can't be sure that we can safely mutate it var newData = Object.create(null); Object.keys(data).forEach(function(key) { newData[key] = data[key]; }); newData[paramName] = reply; if (node.parentNode != null) { var clone; if (templNode.nodeName.toLowerCase() === 'loop') { for (var i = 0; i < templNode.childNodes.length; i++) { clone = templNode.childNodes[i].cloneNode(true); node.parentNode.insertBefore(clone, node); processNode(cState, clone, newData); } } else { clone = templNode.cloneNode(true); clone.removeAttribute('foreach'); node.parentNode.insertBefore(clone, node); processNode(cState, clone, newData); } } }); } finally { state.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() */ function processTextNode(state, 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(TEMPLATE_REGION, '\uF001$$$1\uF002'); // Split a string using the unicode chars F001 and F002. var parts = value.split(/\uF001|\uF002/); if (parts.length > 1) { parts.forEach(function(part) { if (part === null || part === undefined || part === '') { return; } if (part.charAt(0) === '$') { part = envEval(state, part.slice(1), data, node.data); } var cState = cloneState(state); handleAsync(part, node, function(reply, siblingNode) { var doc = siblingNode.ownerDocument; if (reply == null) { reply = cState.options.blankNullUndefined ? '' : '' + reply; } if (typeof reply.cloneNode === 'function') { // i.e. if (reply instanceof Element) { ... reply = maybeImportNode(cState, reply, doc); siblingNode.parentNode.insertBefore(reply, siblingNode); } else if (typeof reply.item === 'function' && reply.length) { // NodeLists can be live, in which case maybeImportNode can // remove them from the document, and thus the NodeList, which in // turn breaks iteration. So first we clone the list var list = Array.prototype.slice.call(reply, 0); list.forEach(function(child) { var imported = maybeImportNode(cState, child, doc); siblingNode.parentNode.insertBefore(imported, 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); } }); }); 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 */ function maybeImportNode(state, 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)' */ function handleAsync(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); if (tempNode.parentNode != null) { tempNode.parentNode.removeChild(tempNode); } }).then(null, function(error) { console.error(error.stack); }); } 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 */ function stripBraces(state, str) { if (!str.match(TEMPLATE_REGION)) { handleError(state, '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(state, 'a.b', { a: { b: 99 }}); // returns 99 *
  • property(state, 'a', { a: { b: 99 }}); // returns { b: 99 } *
  • property(state, '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. */ function property(state, 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) { handleError(state, '"' + path[0] + '" is undefined'); return null; } return property(state, path.slice(1), value, newValue); } catch (ex) { handleError(state, '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. * @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. */ function envEval(state, script, data, frame) { try { state.stack.push(frame.replace(/\s+/g, ' ')); // Detect if a script is capable of being interpreted using property() if (/^[_a-zA-Z0-9.]*$/.test(script)) { return property(state, script, data); } else { if (!state.options.allowEval) { handleError(state, 'allowEval is not set, however \'' + script + '\'' + ' can not be resolved using a simple property path.'); return '${' + script + '}'; } // What we're looking to do is basically: // with(data) { return eval(script); } // except in strict mode where 'with' is banned. // So we create a function which has a parameter list the same as the // keys in 'data' and with 'script' as its function body. // We then call this function with the values in 'data' var keys = allKeys(data); var func = Function.apply(null, keys.concat("return " + script)); var values = keys.map(function(key) { return data[key]; }); return func.apply(null, values); // TODO: The 'with' method is different from the code above in the value // of 'this' when calling functions. For example: // envEval(state, 'foo()', { foo: function() { return this; } }, ...); // The global for 'foo' when using 'with' is the data object. However the // code above, the global is null. (Using 'func.apply(data, values)' // changes 'this' in the 'foo()' frame, but not in the inside the body // of 'foo', so that wouldn't help) } } catch (ex) { handleError(state, 'Template error evaluating \'' + script + '\'', ex); return '${' + script + '}'; } finally { state.stack.pop(); } } /** * Object.keys() that respects the prototype chain */ function allKeys(data) { var keys = []; for (var key in data) { keys.push(key); } return keys; } /** * A generic way of reporting errors, for easy overloading in different * environments. * @param message the error message to report. * @param ex optional associated exception. */ function handleError(state, message, ex) { logError(message + ' (In: ' + state.stack.join(' > ') + ')'); if (ex) { logError(ex); } } /** * A generic way of reporting errors, for easy overloading in different * environments. * @param message the error message to report. */ function logError(message) { console.error(message); }