gecko-dev/browser/devtools/shared/Templater.jsm

526 lines
19 KiB
JavaScript

/* 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 = [];
}
this.nodes = [];
}
/**
* 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 : ''));
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')) {
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
this.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;
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 {
if (pushedNode) {
data.__element = this.nodes.pop();
}
this.stack.pop();
}
};
/**
* Handle <x if="${...}">
* @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 <x foreach="param in ${array}"> and the special case of
* <loop foreach="param in ${array}">.
* 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. <loop foreach="param in ${x}">...
*/
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 <LOOP> 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) {
// 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 = this._maybeImportNode(child, doc);
siblingNode.parentNode.insertBefore(imported, siblingNode);
}.bind(this));
}
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:
* <ul>
* <li>_property('a.b', { a: { b: 99 }}); // returns 99
* <li>_property('a', { a: { b: 99 }}); // returns { b: 99 }
* <li>_property('a', { a: { b: 99 }}, 42); // returns 99 and alters the
* input data to be { a: { b: 42 }}
* </ul>
* @param path An array of strings indicating the path through the data, or
* a string to be cut into an array using <tt>split('.')</tt>
* @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 <tt>path</tt> before any
* <tt>newValue</tt> 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 <tt>env</tt> 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);
};