templater.js 21 KB

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