Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
1 /*
2 * Copyright 2012, Mozilla Foundation and contributors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
17 this.EXPORTED_SYMBOLS = [ "template" ];
18 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
19 XPCOMUtils.defineLazyModuleGetter(this, "console",
20 "resource://gre/modules/devtools/Console.jsm");
22 'do not use strict';
24 // WARNING: do not 'use strict' without reading the notes in envEval();
25 // Also don't remove the 'do not use strict' marker. The orion build uses these
26 // markers to know where to insert AMD headers.
28 /**
29 * For full documentation, see:
30 * https://github.com/mozilla/domtemplate/blob/master/README.md
31 */
33 /**
34 * Begin a new templating process.
35 * @param node A DOM element or string referring to an element's id
36 * @param data Data to use in filling out the template
37 * @param options Options to customize the template processing. One of:
38 * - allowEval: boolean (default false) Basic template interpolations are
39 * either property paths (e.g. ${a.b.c.d}), or if allowEval=true then we
40 * allow arbitrary JavaScript
41 * - stack: string or array of strings (default empty array) The template
42 * engine maintains a stack of tasks to help debug where it is. This allows
43 * this stack to be prefixed with a template name
44 * - blankNullUndefined: By default DOMTemplate exports null and undefined
45 * values using the strings 'null' and 'undefined', which can be helpful for
46 * debugging, but can introduce unnecessary extra logic in a template to
47 * convert null/undefined to ''. By setting blankNullUndefined:true, this
48 * conversion is handled by DOMTemplate
49 */
50 var template = function(node, data, options) {
51 var state = {
52 options: options || {},
53 // We keep a track of the nodes that we've passed through so we can keep
54 // data.__element pointing to the correct node
55 nodes: []
56 };
58 state.stack = state.options.stack;
60 if (!Array.isArray(state.stack)) {
61 if (typeof state.stack === 'string') {
62 state.stack = [ options.stack ];
63 }
64 else {
65 state.stack = [];
66 }
67 }
69 processNode(state, node, data);
70 };
72 //
73 //
74 //
76 /**
77 * Helper for the places where we need to act asynchronously and keep track of
78 * where we are right now
79 */
80 function cloneState(state) {
81 return {
82 options: state.options,
83 stack: state.stack.slice(),
84 nodes: state.nodes.slice()
85 };
86 }
88 /**
89 * Regex used to find ${...} sections in some text.
90 * Performance note: This regex uses ( and ) to capture the 'script' for
91 * further processing. Not all of the uses of this regex use this feature so
92 * if use of the capturing group is a performance drain then we should split
93 * this regex in two.
94 */
95 var TEMPLATE_REGION = /\$\{([^}]*)\}/g;
97 /**
98 * Recursive function to walk the tree processing the attributes as it goes.
99 * @param node the node to process. If you pass a string in instead of a DOM
100 * element, it is assumed to be an id for use with document.getElementById()
101 * @param data the data to use for node processing.
102 */
103 function processNode(state, node, data) {
104 if (typeof node === 'string') {
105 node = document.getElementById(node);
106 }
107 if (data == null) {
108 data = {};
109 }
110 state.stack.push(node.nodeName + (node.id ? '#' + node.id : ''));
111 var pushedNode = false;
112 try {
113 // Process attributes
114 if (node.attributes && node.attributes.length) {
115 // We need to handle 'foreach' and 'if' first because they might stop
116 // some types of processing from happening, and foreach must come first
117 // because it defines new data on which 'if' might depend.
118 if (node.hasAttribute('foreach')) {
119 processForEach(state, node, data);
120 return;
121 }
122 if (node.hasAttribute('if')) {
123 if (!processIf(state, node, data)) {
124 return;
125 }
126 }
127 // Only make the node available once we know it's not going away
128 state.nodes.push(data.__element);
129 data.__element = node;
130 pushedNode = true;
131 // It's good to clean up the attributes when we've processed them,
132 // but if we do it straight away, we mess up the array index
133 var attrs = Array.prototype.slice.call(node.attributes);
134 for (var i = 0; i < attrs.length; i++) {
135 var value = attrs[i].value;
136 var name = attrs[i].name;
138 state.stack.push(name);
139 try {
140 if (name === 'save') {
141 // Save attributes are a setter using the node
142 value = stripBraces(state, value);
143 property(state, value, data, node);
144 node.removeAttribute('save');
145 }
146 else if (name.substring(0, 2) === 'on') {
147 // If this attribute value contains only an expression
148 if (value.substring(0, 2) === '${' && value.slice(-1) === '}' &&
149 value.indexOf('${', 2) === -1) {
150 value = stripBraces(state, value);
151 var func = property(state, value, data);
152 if (typeof func === 'function') {
153 node.removeAttribute(name);
154 var capture = node.hasAttribute('capture' + name.substring(2));
155 node.addEventListener(name.substring(2), func, capture);
156 if (capture) {
157 node.removeAttribute('capture' + name.substring(2));
158 }
159 }
160 else {
161 // Attribute value is not a function - use as a DOM-L0 string
162 node.setAttribute(name, func);
163 }
164 }
165 else {
166 // Attribute value is not a single expression use as DOM-L0
167 node.setAttribute(name, processString(state, value, data));
168 }
169 }
170 else {
171 node.removeAttribute(name);
172 // Remove '_' prefix of attribute names so the DOM won't try
173 // to use them before we've processed the template
174 if (name.charAt(0) === '_') {
175 name = name.substring(1);
176 }
178 // Async attributes can only work if the whole attribute is async
179 var replacement;
180 if (value.indexOf('${') === 0 &&
181 value.charAt(value.length - 1) === '}') {
182 replacement = envEval(state, value.slice(2, -1), data, value);
183 if (replacement && typeof replacement.then === 'function') {
184 node.setAttribute(name, '');
185 replacement.then(function(newValue) {
186 node.setAttribute(name, newValue);
187 }).then(null, console.error);
188 }
189 else {
190 if (state.options.blankNullUndefined && replacement == null) {
191 replacement = '';
192 }
193 node.setAttribute(name, replacement);
194 }
195 }
196 else {
197 node.setAttribute(name, processString(state, value, data));
198 }
199 }
200 }
201 finally {
202 state.stack.pop();
203 }
204 }
205 }
207 // Loop through our children calling processNode. First clone them, so the
208 // set of nodes that we visit will be unaffected by additions or removals.
209 var childNodes = Array.prototype.slice.call(node.childNodes);
210 for (var j = 0; j < childNodes.length; j++) {
211 processNode(state, childNodes[j], data);
212 }
214 if (node.nodeType === 3 /*Node.TEXT_NODE*/) {
215 processTextNode(state, node, data);
216 }
217 }
218 finally {
219 if (pushedNode) {
220 data.__element = state.nodes.pop();
221 }
222 state.stack.pop();
223 }
224 }
226 /**
227 * Handle attribute values where the output can only be a string
228 */
229 function processString(state, value, data) {
230 return value.replace(TEMPLATE_REGION, function(path) {
231 var insert = envEval(state, path.slice(2, -1), data, value);
232 return state.options.blankNullUndefined && insert == null ? '' : insert;
233 });
234 }
236 /**
237 * Handle <x if="${...}">
238 * @param node An element with an 'if' attribute
239 * @param data The data to use with envEval()
240 * @returns true if processing should continue, false otherwise
241 */
242 function processIf(state, node, data) {
243 state.stack.push('if');
244 try {
245 var originalValue = node.getAttribute('if');
246 var value = stripBraces(state, originalValue);
247 var recurse = true;
248 try {
249 var reply = envEval(state, value, data, originalValue);
250 recurse = !!reply;
251 }
252 catch (ex) {
253 handleError(state, 'Error with \'' + value + '\'', ex);
254 recurse = false;
255 }
256 if (!recurse) {
257 node.parentNode.removeChild(node);
258 }
259 node.removeAttribute('if');
260 return recurse;
261 }
262 finally {
263 state.stack.pop();
264 }
265 }
267 /**
268 * Handle <x foreach="param in ${array}"> and the special case of
269 * <loop foreach="param in ${array}">.
270 * This function is responsible for extracting what it has to do from the
271 * attributes, and getting the data to work on (including resolving promises
272 * in getting the array). It delegates to processForEachLoop to actually
273 * unroll the data.
274 * @param node An element with a 'foreach' attribute
275 * @param data The data to use with envEval()
276 */
277 function processForEach(state, node, data) {
278 state.stack.push('foreach');
279 try {
280 var originalValue = node.getAttribute('foreach');
281 var value = originalValue;
283 var paramName = 'param';
284 if (value.charAt(0) === '$') {
285 // No custom loop variable name. Use the default: 'param'
286 value = stripBraces(state, value);
287 }
288 else {
289 // Extract the loop variable name from 'NAME in ${ARRAY}'
290 var nameArr = value.split(' in ');
291 paramName = nameArr[0].trim();
292 value = stripBraces(state, nameArr[1].trim());
293 }
294 node.removeAttribute('foreach');
295 try {
296 var evaled = envEval(state, value, data, originalValue);
297 var cState = cloneState(state);
298 handleAsync(evaled, node, function(reply, siblingNode) {
299 processForEachLoop(cState, reply, node, siblingNode, data, paramName);
300 });
301 node.parentNode.removeChild(node);
302 }
303 catch (ex) {
304 handleError(state, 'Error with \'' + value + '\'', ex);
305 }
306 }
307 finally {
308 state.stack.pop();
309 }
310 }
312 /**
313 * Called by processForEach to handle looping over the data in a foreach loop.
314 * This works with both arrays and objects.
315 * Calls processForEachMember() for each member of 'set'
316 * @param set The object containing the data to loop over
317 * @param templNode The node to copy for each set member
318 * @param sibling The sibling node to which we add things
319 * @param data the data to use for node processing
320 * @param paramName foreach loops have a name for the parameter currently being
321 * processed. The default is 'param'. e.g. <loop foreach="param in ${x}">...
322 */
323 function processForEachLoop(state, set, templNode, sibling, data, paramName) {
324 if (Array.isArray(set)) {
325 set.forEach(function(member, i) {
326 processForEachMember(state, member, templNode, sibling,
327 data, paramName, '' + i);
328 });
329 }
330 else {
331 for (var member in set) {
332 if (set.hasOwnProperty(member)) {
333 processForEachMember(state, member, templNode, sibling,
334 data, paramName, member);
335 }
336 }
337 }
338 }
340 /**
341 * Called by processForEachLoop() to resolve any promises in the array (the
342 * array itself can also be a promise, but that is resolved by
343 * processForEach()). Handle <LOOP> elements (which are taken out of the DOM),
344 * clone the template node, and pass the processing on to processNode().
345 * @param member The data item to use in templating
346 * @param templNode The node to copy for each set member
347 * @param siblingNode The parent node to which we add things
348 * @param data the data to use for node processing
349 * @param paramName The name given to 'member' by the foreach attribute
350 * @param frame A name to push on the stack for debugging
351 */
352 function processForEachMember(state, member, templNode, siblingNode, data, paramName, frame) {
353 state.stack.push(frame);
354 try {
355 var cState = cloneState(state);
356 handleAsync(member, siblingNode, function(reply, node) {
357 data[paramName] = reply;
358 if (node.parentNode != null) {
359 if (templNode.nodeName.toLowerCase() === 'loop') {
360 for (var i = 0; i < templNode.childNodes.length; i++) {
361 var clone = templNode.childNodes[i].cloneNode(true);
362 node.parentNode.insertBefore(clone, node);
363 processNode(cState, clone, data);
364 }
365 }
366 else {
367 var clone = templNode.cloneNode(true);
368 clone.removeAttribute('foreach');
369 node.parentNode.insertBefore(clone, node);
370 processNode(cState, clone, data);
371 }
372 }
373 delete data[paramName];
374 });
375 }
376 finally {
377 state.stack.pop();
378 }
379 }
381 /**
382 * Take a text node and replace it with another text node with the ${...}
383 * sections parsed out. We replace the node by altering node.parentNode but
384 * we could probably use a DOM Text API to achieve the same thing.
385 * @param node The Text node to work on
386 * @param data The data to use in calls to envEval()
387 */
388 function processTextNode(state, node, data) {
389 // Replace references in other attributes
390 var value = node.data;
391 // We can't use the string.replace() with function trick (see generic
392 // attribute processing in processNode()) because we need to support
393 // functions that return DOM nodes, so we can't have the conversion to a
394 // string.
395 // Instead we process the string as an array of parts. In order to split
396 // the string up, we first replace '${' with '\uF001$' and '}' with '\uF002'
397 // We can then split using \uF001 or \uF002 to get an array of strings
398 // where scripts are prefixed with $.
399 // \uF001 and \uF002 are just unicode chars reserved for private use.
400 value = value.replace(TEMPLATE_REGION, '\uF001$$$1\uF002');
401 // Split a string using the unicode chars F001 and F002.
402 var parts = value.split(/\uF001|\uF002/);
403 if (parts.length > 1) {
404 parts.forEach(function(part) {
405 if (part === null || part === undefined || part === '') {
406 return;
407 }
408 if (part.charAt(0) === '$') {
409 part = envEval(state, part.slice(1), data, node.data);
410 }
411 var cState = cloneState(state);
412 handleAsync(part, node, function(reply, siblingNode) {
413 var doc = siblingNode.ownerDocument;
414 if (reply == null) {
415 reply = cState.options.blankNullUndefined ? '' : '' + reply;
416 }
417 if (typeof reply.cloneNode === 'function') {
418 // i.e. if (reply instanceof Element) { ...
419 reply = maybeImportNode(cState, reply, doc);
420 siblingNode.parentNode.insertBefore(reply, siblingNode);
421 }
422 else if (typeof reply.item === 'function' && reply.length) {
423 // NodeLists can be live, in which case maybeImportNode can
424 // remove them from the document, and thus the NodeList, which in
425 // turn breaks iteration. So first we clone the list
426 var list = Array.prototype.slice.call(reply, 0);
427 list.forEach(function(child) {
428 var imported = maybeImportNode(cState, child, doc);
429 siblingNode.parentNode.insertBefore(imported, siblingNode);
430 });
431 }
432 else {
433 // if thing isn't a DOM element then wrap its string value in one
434 reply = doc.createTextNode(reply.toString());
435 siblingNode.parentNode.insertBefore(reply, siblingNode);
436 }
437 });
438 });
439 node.parentNode.removeChild(node);
440 }
441 }
443 /**
444 * Return node or a import of node, if it's not in the given document
445 * @param node The node that we want to be properly owned
446 * @param doc The document that the given node should belong to
447 * @return A node that belongs to the given document
448 */
449 function maybeImportNode(state, node, doc) {
450 return node.ownerDocument === doc ? node : doc.importNode(node, true);
451 }
453 /**
454 * A function to handle the fact that some nodes can be promises, so we check
455 * and resolve if needed using a marker node to keep our place before calling
456 * an inserter function.
457 * @param thing The object which could be real data or a promise of real data
458 * we use it directly if it's not a promise, or resolve it if it is.
459 * @param siblingNode The element before which we insert new elements.
460 * @param inserter The function to to the insertion. If thing is not a promise
461 * then handleAsync() is just 'inserter(thing, siblingNode)'
462 */
463 function handleAsync(thing, siblingNode, inserter) {
464 if (thing != null && typeof thing.then === 'function') {
465 // Placeholder element to be replaced once we have the real data
466 var tempNode = siblingNode.ownerDocument.createElement('span');
467 siblingNode.parentNode.insertBefore(tempNode, siblingNode);
468 thing.then(function(delayed) {
469 inserter(delayed, tempNode);
470 if (tempNode.parentNode != null) {
471 tempNode.parentNode.removeChild(tempNode);
472 }
473 }).then(null, function(error) {
474 console.error(error.stack);
475 });
476 }
477 else {
478 inserter(thing, siblingNode);
479 }
480 }
482 /**
483 * Warn of string does not begin '${' and end '}'
484 * @param str the string to check.
485 * @return The string stripped of ${ and }, or untouched if it does not match
486 */
487 function stripBraces(state, str) {
488 if (!str.match(TEMPLATE_REGION)) {
489 handleError(state, 'Expected ' + str + ' to match ${...}');
490 return str;
491 }
492 return str.slice(2, -1);
493 }
495 /**
496 * Combined getter and setter that works with a path through some data set.
497 * For example:
498 * <ul>
499 * <li>property(state, 'a.b', { a: { b: 99 }}); // returns 99
500 * <li>property(state, 'a', { a: { b: 99 }}); // returns { b: 99 }
501 * <li>property(state, 'a', { a: { b: 99 }}, 42); // returns 99 and alters the
502 * input data to be { a: { b: 42 }}
503 * </ul>
504 * @param path An array of strings indicating the path through the data, or
505 * a string to be cut into an array using <tt>split('.')</tt>
506 * @param data the data to use for node processing
507 * @param newValue (optional) If defined, this value will replace the
508 * original value for the data at the path specified.
509 * @return The value pointed to by <tt>path</tt> before any
510 * <tt>newValue</tt> is applied.
511 */
512 function property(state, path, data, newValue) {
513 try {
514 if (typeof path === 'string') {
515 path = path.split('.');
516 }
517 var value = data[path[0]];
518 if (path.length === 1) {
519 if (newValue !== undefined) {
520 data[path[0]] = newValue;
521 }
522 if (typeof value === 'function') {
523 return value.bind(data);
524 }
525 return value;
526 }
527 if (!value) {
528 handleError(state, '"' + path[0] + '" is undefined');
529 return null;
530 }
531 return property(state, path.slice(1), value, newValue);
532 }
533 catch (ex) {
534 handleError(state, 'Path error with \'' + path + '\'', ex);
535 return '${' + path + '}';
536 }
537 }
539 /**
540 * Like eval, but that creates a context of the variables in <tt>env</tt> in
541 * which the script is evaluated.
542 * WARNING: This script uses 'with' which is generally regarded to be evil.
543 * The alternative is to create a Function at runtime that takes X parameters
544 * according to the X keys in the env object, and then call that function using
545 * the values in the env object. This is likely to be slow, but workable.
546 * @param script The string to be evaluated.
547 * @param data The environment in which to eval the script.
548 * @param frame Optional debugging string in case of failure.
549 * @return The return value of the script, or the error message if the script
550 * execution failed.
551 */
552 function envEval(state, script, data, frame) {
553 try {
554 state.stack.push(frame.replace(/\s+/g, ' '));
555 // Detect if a script is capable of being interpreted using property()
556 if (/^[_a-zA-Z0-9.]*$/.test(script)) {
557 return property(state, script, data);
558 }
559 else {
560 if (!state.options.allowEval) {
561 handleError(state, 'allowEval is not set, however \'' + script + '\'' +
562 ' can not be resolved using a simple property path.');
563 return '${' + script + '}';
564 }
565 with (data) {
566 return eval(script);
567 }
568 }
569 }
570 catch (ex) {
571 handleError(state, 'Template error evaluating \'' + script + '\'', ex);
572 return '${' + script + '}';
573 }
574 finally {
575 state.stack.pop();
576 }
577 }
579 /**
580 * A generic way of reporting errors, for easy overloading in different
581 * environments.
582 * @param message the error message to report.
583 * @param ex optional associated exception.
584 */
585 function handleError(state, message, ex) {
586 logError(message + ' (In: ' + state.stack.join(' > ') + ')');
587 if (ex) {
588 logError(ex);
589 }
590 }
592 /**
593 * A generic way of reporting errors, for easy overloading in different
594 * environments.
595 * @param message the error message to report.
596 */
597 function logError(message) {
598 console.log(message);
599 }