toolkit/devtools/gcli/Templater.jsm

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

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 }

mercurial