michael@0: /* michael@0: * Copyright 2012, Mozilla Foundation and contributors michael@0: * michael@0: * Licensed under the Apache License, Version 2.0 (the "License"); michael@0: * you may not use this file except in compliance with the License. michael@0: * You may obtain a copy of the License at michael@0: * michael@0: * http://www.apache.org/licenses/LICENSE-2.0 michael@0: * michael@0: * Unless required by applicable law or agreed to in writing, software michael@0: * distributed under the License is distributed on an "AS IS" BASIS, michael@0: * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. michael@0: * See the License for the specific language governing permissions and michael@0: * limitations under the License. michael@0: */ michael@0: michael@0: this.EXPORTED_SYMBOLS = [ "template" ]; michael@0: Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "console", michael@0: "resource://gre/modules/devtools/Console.jsm"); michael@0: michael@0: 'do not use strict'; michael@0: michael@0: // WARNING: do not 'use strict' without reading the notes in envEval(); michael@0: // Also don't remove the 'do not use strict' marker. The orion build uses these michael@0: // markers to know where to insert AMD headers. michael@0: michael@0: /** michael@0: * For full documentation, see: michael@0: * https://github.com/mozilla/domtemplate/blob/master/README.md michael@0: */ michael@0: michael@0: /** michael@0: * Begin a new templating process. michael@0: * @param node A DOM element or string referring to an element's id michael@0: * @param data Data to use in filling out the template michael@0: * @param options Options to customize the template processing. One of: michael@0: * - allowEval: boolean (default false) Basic template interpolations are michael@0: * either property paths (e.g. ${a.b.c.d}), or if allowEval=true then we michael@0: * allow arbitrary JavaScript michael@0: * - stack: string or array of strings (default empty array) The template michael@0: * engine maintains a stack of tasks to help debug where it is. This allows michael@0: * this stack to be prefixed with a template name michael@0: * - blankNullUndefined: By default DOMTemplate exports null and undefined michael@0: * values using the strings 'null' and 'undefined', which can be helpful for michael@0: * debugging, but can introduce unnecessary extra logic in a template to michael@0: * convert null/undefined to ''. By setting blankNullUndefined:true, this michael@0: * conversion is handled by DOMTemplate michael@0: */ michael@0: var template = function(node, data, options) { michael@0: var state = { michael@0: options: options || {}, michael@0: // We keep a track of the nodes that we've passed through so we can keep michael@0: // data.__element pointing to the correct node michael@0: nodes: [] michael@0: }; michael@0: michael@0: state.stack = state.options.stack; michael@0: michael@0: if (!Array.isArray(state.stack)) { michael@0: if (typeof state.stack === 'string') { michael@0: state.stack = [ options.stack ]; michael@0: } michael@0: else { michael@0: state.stack = []; michael@0: } michael@0: } michael@0: michael@0: processNode(state, node, data); michael@0: }; michael@0: michael@0: // michael@0: // michael@0: // michael@0: michael@0: /** michael@0: * Helper for the places where we need to act asynchronously and keep track of michael@0: * where we are right now michael@0: */ michael@0: function cloneState(state) { michael@0: return { michael@0: options: state.options, michael@0: stack: state.stack.slice(), michael@0: nodes: state.nodes.slice() michael@0: }; michael@0: } michael@0: michael@0: /** michael@0: * Regex used to find ${...} sections in some text. michael@0: * Performance note: This regex uses ( and ) to capture the 'script' for michael@0: * further processing. Not all of the uses of this regex use this feature so michael@0: * if use of the capturing group is a performance drain then we should split michael@0: * this regex in two. michael@0: */ michael@0: var TEMPLATE_REGION = /\$\{([^}]*)\}/g; michael@0: michael@0: /** michael@0: * Recursive function to walk the tree processing the attributes as it goes. michael@0: * @param node the node to process. If you pass a string in instead of a DOM michael@0: * element, it is assumed to be an id for use with document.getElementById() michael@0: * @param data the data to use for node processing. michael@0: */ michael@0: function processNode(state, node, data) { michael@0: if (typeof node === 'string') { michael@0: node = document.getElementById(node); michael@0: } michael@0: if (data == null) { michael@0: data = {}; michael@0: } michael@0: state.stack.push(node.nodeName + (node.id ? '#' + node.id : '')); michael@0: var pushedNode = false; michael@0: try { michael@0: // Process attributes michael@0: if (node.attributes && node.attributes.length) { michael@0: // We need to handle 'foreach' and 'if' first because they might stop michael@0: // some types of processing from happening, and foreach must come first michael@0: // because it defines new data on which 'if' might depend. michael@0: if (node.hasAttribute('foreach')) { michael@0: processForEach(state, node, data); michael@0: return; michael@0: } michael@0: if (node.hasAttribute('if')) { michael@0: if (!processIf(state, node, data)) { michael@0: return; michael@0: } michael@0: } michael@0: // Only make the node available once we know it's not going away michael@0: state.nodes.push(data.__element); michael@0: data.__element = node; michael@0: pushedNode = true; michael@0: // It's good to clean up the attributes when we've processed them, michael@0: // but if we do it straight away, we mess up the array index michael@0: var attrs = Array.prototype.slice.call(node.attributes); michael@0: for (var i = 0; i < attrs.length; i++) { michael@0: var value = attrs[i].value; michael@0: var name = attrs[i].name; michael@0: michael@0: state.stack.push(name); michael@0: try { michael@0: if (name === 'save') { michael@0: // Save attributes are a setter using the node michael@0: value = stripBraces(state, value); michael@0: property(state, value, data, node); michael@0: node.removeAttribute('save'); michael@0: } michael@0: else if (name.substring(0, 2) === 'on') { michael@0: // If this attribute value contains only an expression michael@0: if (value.substring(0, 2) === '${' && value.slice(-1) === '}' && michael@0: value.indexOf('${', 2) === -1) { michael@0: value = stripBraces(state, value); michael@0: var func = property(state, value, data); michael@0: if (typeof func === 'function') { michael@0: node.removeAttribute(name); michael@0: var capture = node.hasAttribute('capture' + name.substring(2)); michael@0: node.addEventListener(name.substring(2), func, capture); michael@0: if (capture) { michael@0: node.removeAttribute('capture' + name.substring(2)); michael@0: } michael@0: } michael@0: else { michael@0: // Attribute value is not a function - use as a DOM-L0 string michael@0: node.setAttribute(name, func); michael@0: } michael@0: } michael@0: else { michael@0: // Attribute value is not a single expression use as DOM-L0 michael@0: node.setAttribute(name, processString(state, value, data)); michael@0: } michael@0: } michael@0: else { michael@0: node.removeAttribute(name); michael@0: // Remove '_' prefix of attribute names so the DOM won't try michael@0: // to use them before we've processed the template michael@0: if (name.charAt(0) === '_') { michael@0: name = name.substring(1); michael@0: } michael@0: michael@0: // Async attributes can only work if the whole attribute is async michael@0: var replacement; michael@0: if (value.indexOf('${') === 0 && michael@0: value.charAt(value.length - 1) === '}') { michael@0: replacement = envEval(state, value.slice(2, -1), data, value); michael@0: if (replacement && typeof replacement.then === 'function') { michael@0: node.setAttribute(name, ''); michael@0: replacement.then(function(newValue) { michael@0: node.setAttribute(name, newValue); michael@0: }).then(null, console.error); michael@0: } michael@0: else { michael@0: if (state.options.blankNullUndefined && replacement == null) { michael@0: replacement = ''; michael@0: } michael@0: node.setAttribute(name, replacement); michael@0: } michael@0: } michael@0: else { michael@0: node.setAttribute(name, processString(state, value, data)); michael@0: } michael@0: } michael@0: } michael@0: finally { michael@0: state.stack.pop(); michael@0: } michael@0: } michael@0: } michael@0: michael@0: // Loop through our children calling processNode. First clone them, so the michael@0: // set of nodes that we visit will be unaffected by additions or removals. michael@0: var childNodes = Array.prototype.slice.call(node.childNodes); michael@0: for (var j = 0; j < childNodes.length; j++) { michael@0: processNode(state, childNodes[j], data); michael@0: } michael@0: michael@0: if (node.nodeType === 3 /*Node.TEXT_NODE*/) { michael@0: processTextNode(state, node, data); michael@0: } michael@0: } michael@0: finally { michael@0: if (pushedNode) { michael@0: data.__element = state.nodes.pop(); michael@0: } michael@0: state.stack.pop(); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Handle attribute values where the output can only be a string michael@0: */ michael@0: function processString(state, value, data) { michael@0: return value.replace(TEMPLATE_REGION, function(path) { michael@0: var insert = envEval(state, path.slice(2, -1), data, value); michael@0: return state.options.blankNullUndefined && insert == null ? '' : insert; michael@0: }); michael@0: } michael@0: michael@0: /** michael@0: * Handle michael@0: * @param node An element with an 'if' attribute michael@0: * @param data The data to use with envEval() michael@0: * @returns true if processing should continue, false otherwise michael@0: */ michael@0: function processIf(state, node, data) { michael@0: state.stack.push('if'); michael@0: try { michael@0: var originalValue = node.getAttribute('if'); michael@0: var value = stripBraces(state, originalValue); michael@0: var recurse = true; michael@0: try { michael@0: var reply = envEval(state, value, data, originalValue); michael@0: recurse = !!reply; michael@0: } michael@0: catch (ex) { michael@0: handleError(state, 'Error with \'' + value + '\'', ex); michael@0: recurse = false; michael@0: } michael@0: if (!recurse) { michael@0: node.parentNode.removeChild(node); michael@0: } michael@0: node.removeAttribute('if'); michael@0: return recurse; michael@0: } michael@0: finally { michael@0: state.stack.pop(); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Handle and the special case of michael@0: * . michael@0: * This function is responsible for extracting what it has to do from the michael@0: * attributes, and getting the data to work on (including resolving promises michael@0: * in getting the array). It delegates to processForEachLoop to actually michael@0: * unroll the data. michael@0: * @param node An element with a 'foreach' attribute michael@0: * @param data The data to use with envEval() michael@0: */ michael@0: function processForEach(state, node, data) { michael@0: state.stack.push('foreach'); michael@0: try { michael@0: var originalValue = node.getAttribute('foreach'); michael@0: var value = originalValue; michael@0: michael@0: var paramName = 'param'; michael@0: if (value.charAt(0) === '$') { michael@0: // No custom loop variable name. Use the default: 'param' michael@0: value = stripBraces(state, value); michael@0: } michael@0: else { michael@0: // Extract the loop variable name from 'NAME in ${ARRAY}' michael@0: var nameArr = value.split(' in '); michael@0: paramName = nameArr[0].trim(); michael@0: value = stripBraces(state, nameArr[1].trim()); michael@0: } michael@0: node.removeAttribute('foreach'); michael@0: try { michael@0: var evaled = envEval(state, value, data, originalValue); michael@0: var cState = cloneState(state); michael@0: handleAsync(evaled, node, function(reply, siblingNode) { michael@0: processForEachLoop(cState, reply, node, siblingNode, data, paramName); michael@0: }); michael@0: node.parentNode.removeChild(node); michael@0: } michael@0: catch (ex) { michael@0: handleError(state, 'Error with \'' + value + '\'', ex); michael@0: } michael@0: } michael@0: finally { michael@0: state.stack.pop(); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Called by processForEach to handle looping over the data in a foreach loop. michael@0: * This works with both arrays and objects. michael@0: * Calls processForEachMember() for each member of 'set' michael@0: * @param set The object containing the data to loop over michael@0: * @param templNode The node to copy for each set member michael@0: * @param sibling The sibling node to which we add things michael@0: * @param data the data to use for node processing michael@0: * @param paramName foreach loops have a name for the parameter currently being michael@0: * processed. The default is 'param'. e.g. ... michael@0: */ michael@0: function processForEachLoop(state, set, templNode, sibling, data, paramName) { michael@0: if (Array.isArray(set)) { michael@0: set.forEach(function(member, i) { michael@0: processForEachMember(state, member, templNode, sibling, michael@0: data, paramName, '' + i); michael@0: }); michael@0: } michael@0: else { michael@0: for (var member in set) { michael@0: if (set.hasOwnProperty(member)) { michael@0: processForEachMember(state, member, templNode, sibling, michael@0: data, paramName, member); michael@0: } michael@0: } michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Called by processForEachLoop() to resolve any promises in the array (the michael@0: * array itself can also be a promise, but that is resolved by michael@0: * processForEach()). Handle elements (which are taken out of the DOM), michael@0: * clone the template node, and pass the processing on to processNode(). michael@0: * @param member The data item to use in templating michael@0: * @param templNode The node to copy for each set member michael@0: * @param siblingNode The parent node to which we add things michael@0: * @param data the data to use for node processing michael@0: * @param paramName The name given to 'member' by the foreach attribute michael@0: * @param frame A name to push on the stack for debugging michael@0: */ michael@0: function processForEachMember(state, member, templNode, siblingNode, data, paramName, frame) { michael@0: state.stack.push(frame); michael@0: try { michael@0: var cState = cloneState(state); michael@0: handleAsync(member, siblingNode, function(reply, node) { michael@0: data[paramName] = reply; michael@0: if (node.parentNode != null) { michael@0: if (templNode.nodeName.toLowerCase() === 'loop') { michael@0: for (var i = 0; i < templNode.childNodes.length; i++) { michael@0: var clone = templNode.childNodes[i].cloneNode(true); michael@0: node.parentNode.insertBefore(clone, node); michael@0: processNode(cState, clone, data); michael@0: } michael@0: } michael@0: else { michael@0: var clone = templNode.cloneNode(true); michael@0: clone.removeAttribute('foreach'); michael@0: node.parentNode.insertBefore(clone, node); michael@0: processNode(cState, clone, data); michael@0: } michael@0: } michael@0: delete data[paramName]; michael@0: }); michael@0: } michael@0: finally { michael@0: state.stack.pop(); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Take a text node and replace it with another text node with the ${...} michael@0: * sections parsed out. We replace the node by altering node.parentNode but michael@0: * we could probably use a DOM Text API to achieve the same thing. michael@0: * @param node The Text node to work on michael@0: * @param data The data to use in calls to envEval() michael@0: */ michael@0: function processTextNode(state, node, data) { michael@0: // Replace references in other attributes michael@0: var value = node.data; michael@0: // We can't use the string.replace() with function trick (see generic michael@0: // attribute processing in processNode()) because we need to support michael@0: // functions that return DOM nodes, so we can't have the conversion to a michael@0: // string. michael@0: // Instead we process the string as an array of parts. In order to split michael@0: // the string up, we first replace '${' with '\uF001$' and '}' with '\uF002' michael@0: // We can then split using \uF001 or \uF002 to get an array of strings michael@0: // where scripts are prefixed with $. michael@0: // \uF001 and \uF002 are just unicode chars reserved for private use. michael@0: value = value.replace(TEMPLATE_REGION, '\uF001$$$1\uF002'); michael@0: // Split a string using the unicode chars F001 and F002. michael@0: var parts = value.split(/\uF001|\uF002/); michael@0: if (parts.length > 1) { michael@0: parts.forEach(function(part) { michael@0: if (part === null || part === undefined || part === '') { michael@0: return; michael@0: } michael@0: if (part.charAt(0) === '$') { michael@0: part = envEval(state, part.slice(1), data, node.data); michael@0: } michael@0: var cState = cloneState(state); michael@0: handleAsync(part, node, function(reply, siblingNode) { michael@0: var doc = siblingNode.ownerDocument; michael@0: if (reply == null) { michael@0: reply = cState.options.blankNullUndefined ? '' : '' + reply; michael@0: } michael@0: if (typeof reply.cloneNode === 'function') { michael@0: // i.e. if (reply instanceof Element) { ... michael@0: reply = maybeImportNode(cState, reply, doc); michael@0: siblingNode.parentNode.insertBefore(reply, siblingNode); michael@0: } michael@0: else if (typeof reply.item === 'function' && reply.length) { michael@0: // NodeLists can be live, in which case maybeImportNode can michael@0: // remove them from the document, and thus the NodeList, which in michael@0: // turn breaks iteration. So first we clone the list michael@0: var list = Array.prototype.slice.call(reply, 0); michael@0: list.forEach(function(child) { michael@0: var imported = maybeImportNode(cState, child, doc); michael@0: siblingNode.parentNode.insertBefore(imported, siblingNode); michael@0: }); michael@0: } michael@0: else { michael@0: // if thing isn't a DOM element then wrap its string value in one michael@0: reply = doc.createTextNode(reply.toString()); michael@0: siblingNode.parentNode.insertBefore(reply, siblingNode); michael@0: } michael@0: }); michael@0: }); michael@0: node.parentNode.removeChild(node); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Return node or a import of node, if it's not in the given document michael@0: * @param node The node that we want to be properly owned michael@0: * @param doc The document that the given node should belong to michael@0: * @return A node that belongs to the given document michael@0: */ michael@0: function maybeImportNode(state, node, doc) { michael@0: return node.ownerDocument === doc ? node : doc.importNode(node, true); michael@0: } michael@0: michael@0: /** michael@0: * A function to handle the fact that some nodes can be promises, so we check michael@0: * and resolve if needed using a marker node to keep our place before calling michael@0: * an inserter function. michael@0: * @param thing The object which could be real data or a promise of real data michael@0: * we use it directly if it's not a promise, or resolve it if it is. michael@0: * @param siblingNode The element before which we insert new elements. michael@0: * @param inserter The function to to the insertion. If thing is not a promise michael@0: * then handleAsync() is just 'inserter(thing, siblingNode)' michael@0: */ michael@0: function handleAsync(thing, siblingNode, inserter) { michael@0: if (thing != null && typeof thing.then === 'function') { michael@0: // Placeholder element to be replaced once we have the real data michael@0: var tempNode = siblingNode.ownerDocument.createElement('span'); michael@0: siblingNode.parentNode.insertBefore(tempNode, siblingNode); michael@0: thing.then(function(delayed) { michael@0: inserter(delayed, tempNode); michael@0: if (tempNode.parentNode != null) { michael@0: tempNode.parentNode.removeChild(tempNode); michael@0: } michael@0: }).then(null, function(error) { michael@0: console.error(error.stack); michael@0: }); michael@0: } michael@0: else { michael@0: inserter(thing, siblingNode); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Warn of string does not begin '${' and end '}' michael@0: * @param str the string to check. michael@0: * @return The string stripped of ${ and }, or untouched if it does not match michael@0: */ michael@0: function stripBraces(state, str) { michael@0: if (!str.match(TEMPLATE_REGION)) { michael@0: handleError(state, 'Expected ' + str + ' to match ${...}'); michael@0: return str; michael@0: } michael@0: return str.slice(2, -1); michael@0: } michael@0: michael@0: /** michael@0: * Combined getter and setter that works with a path through some data set. michael@0: * For example: michael@0: *
    michael@0: *
  • property(state, 'a.b', { a: { b: 99 }}); // returns 99 michael@0: *
  • property(state, 'a', { a: { b: 99 }}); // returns { b: 99 } michael@0: *
  • property(state, 'a', { a: { b: 99 }}, 42); // returns 99 and alters the michael@0: * input data to be { a: { b: 42 }} michael@0: *
michael@0: * @param path An array of strings indicating the path through the data, or michael@0: * a string to be cut into an array using split('.') michael@0: * @param data the data to use for node processing michael@0: * @param newValue (optional) If defined, this value will replace the michael@0: * original value for the data at the path specified. michael@0: * @return The value pointed to by path before any michael@0: * newValue is applied. michael@0: */ michael@0: function property(state, path, data, newValue) { michael@0: try { michael@0: if (typeof path === 'string') { michael@0: path = path.split('.'); michael@0: } michael@0: var value = data[path[0]]; michael@0: if (path.length === 1) { michael@0: if (newValue !== undefined) { michael@0: data[path[0]] = newValue; michael@0: } michael@0: if (typeof value === 'function') { michael@0: return value.bind(data); michael@0: } michael@0: return value; michael@0: } michael@0: if (!value) { michael@0: handleError(state, '"' + path[0] + '" is undefined'); michael@0: return null; michael@0: } michael@0: return property(state, path.slice(1), value, newValue); michael@0: } michael@0: catch (ex) { michael@0: handleError(state, 'Path error with \'' + path + '\'', ex); michael@0: return '${' + path + '}'; michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Like eval, but that creates a context of the variables in env in michael@0: * which the script is evaluated. michael@0: * WARNING: This script uses 'with' which is generally regarded to be evil. michael@0: * The alternative is to create a Function at runtime that takes X parameters michael@0: * according to the X keys in the env object, and then call that function using michael@0: * the values in the env object. This is likely to be slow, but workable. michael@0: * @param script The string to be evaluated. michael@0: * @param data The environment in which to eval the script. michael@0: * @param frame Optional debugging string in case of failure. michael@0: * @return The return value of the script, or the error message if the script michael@0: * execution failed. michael@0: */ michael@0: function envEval(state, script, data, frame) { michael@0: try { michael@0: state.stack.push(frame.replace(/\s+/g, ' ')); michael@0: // Detect if a script is capable of being interpreted using property() michael@0: if (/^[_a-zA-Z0-9.]*$/.test(script)) { michael@0: return property(state, script, data); michael@0: } michael@0: else { michael@0: if (!state.options.allowEval) { michael@0: handleError(state, 'allowEval is not set, however \'' + script + '\'' + michael@0: ' can not be resolved using a simple property path.'); michael@0: return '${' + script + '}'; michael@0: } michael@0: with (data) { michael@0: return eval(script); michael@0: } michael@0: } michael@0: } michael@0: catch (ex) { michael@0: handleError(state, 'Template error evaluating \'' + script + '\'', ex); michael@0: return '${' + script + '}'; michael@0: } michael@0: finally { michael@0: state.stack.pop(); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * A generic way of reporting errors, for easy overloading in different michael@0: * environments. michael@0: * @param message the error message to report. michael@0: * @param ex optional associated exception. michael@0: */ michael@0: function handleError(state, message, ex) { michael@0: logError(message + ' (In: ' + state.stack.join(' > ') + ')'); michael@0: if (ex) { michael@0: logError(ex); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * A generic way of reporting errors, for easy overloading in different michael@0: * environments. michael@0: * @param message the error message to report. michael@0: */ michael@0: function logError(message) { michael@0: console.log(message); michael@0: }