|
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 */ |
|
16 |
|
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"); |
|
21 |
|
22 'do not use strict'; |
|
23 |
|
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. |
|
27 |
|
28 /** |
|
29 * For full documentation, see: |
|
30 * https://github.com/mozilla/domtemplate/blob/master/README.md |
|
31 */ |
|
32 |
|
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 }; |
|
57 |
|
58 state.stack = state.options.stack; |
|
59 |
|
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 } |
|
68 |
|
69 processNode(state, node, data); |
|
70 }; |
|
71 |
|
72 // |
|
73 // |
|
74 // |
|
75 |
|
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 } |
|
87 |
|
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; |
|
96 |
|
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; |
|
137 |
|
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 } |
|
177 |
|
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 } |
|
206 |
|
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 } |
|
213 |
|
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 } |
|
225 |
|
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 } |
|
235 |
|
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 } |
|
266 |
|
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; |
|
282 |
|
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 } |
|
311 |
|
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 } |
|
339 |
|
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 } |
|
380 |
|
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 } |
|
442 |
|
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 } |
|
452 |
|
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 } |
|
481 |
|
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 } |
|
494 |
|
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 } |
|
538 |
|
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 } |
|
578 |
|
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 } |
|
591 |
|
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 } |