MDL-57009 js: Do not fetch the same template twice
[moodle.git] / lib / amd / src / templates.js
CommitLineData
9bdcf579
DW
1// This file is part of Moodle - http://moodle.org/
2//
3// Moodle is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// (at your option) any later version.
7//
8// Moodle is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
15
16/**
17 * Template renderer for Moodle. Load and render Moodle templates with Mustache.
18 *
19 * @module core/templates
20 * @package core
21 * @class templates
22 * @copyright 2015 Damyon Wiese <damyon@moodle.com>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 * @since 2.9
25 */
35be5826 26define(['core/mustache',
9bdcf579
DW
27 'jquery',
28 'core/ajax',
29 'core/str',
30 'core/notification',
31 'core/url',
4b9e5326 32 'core/config',
28de7771 33 'core/localstorage',
1fca8a7b 34 'core/event',
29879f8f
FM
35 'core/yui',
36 'core/log'
9bdcf579 37 ],
29879f8f 38 function(mustache, $, ajax, str, notification, coreurl, config, storage, event, Y, Log) {
9bdcf579 39
8d00afb1
DW
40 // Module variables.
41 /** @var {Number} uniqInstances Count of times this constructor has been called. */
42 var uniqInstances = 0;
9bdcf579
DW
43
44 /** @var {string[]} templateCache - Cache of already loaded templates */
45 var templateCache = {};
46
f20a336b
DW
47 /** @var {string[]} pixTemplateCache - The pix icon template must be cached as a string (once per theme) */
48 var pixTemplateCache = [];
49
8d00afb1
DW
50 /**
51 * Constructor
52 *
53 * Each call to templates.render gets it's own instance of this class.
54 */
55 var Renderer = function() {
56 this.requiredStrings = [];
57 this.requiredJS = [];
58 this.currentThemeName = '';
59 };
60 // Class variables and functions.
61
9bdcf579 62 /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
8d00afb1 63 Renderer.prototype.requiredStrings = null;
9bdcf579
DW
64
65 /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
8d00afb1 66 Renderer.prototype.requiredJS = null;
9bdcf579
DW
67
68 /** @var {String} themeName for the current render */
8d00afb1 69 Renderer.prototype.currentThemeName = '';
9bdcf579 70
f992dcf6
DP
71 /**
72 * Load a template from the cache or local storage or ajax request.
73 *
74 * @method getTemplate
75 * @private
76 * @param {string} templateName - should consist of the component and the name of the template like this:
77 * core/menu (lib/templates/menu.mustache) or
78 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
c96f55e6 79 * @param {Boolean} async If false - this function will not return until the promises are resolved.
f992dcf6
DP
80 * @return {Promise} JQuery promise object resolved when the template has been fetched.
81 */
8d00afb1 82 Renderer.prototype.getTemplate = function(templateName, async) {
f992dcf6
DP
83 var parts = templateName.split('/');
84 var component = parts.shift();
85 var name = parts.shift();
86
8d00afb1 87 var searchKey = this.currentThemeName + '/' + templateName;
f992dcf6
DP
88
89 // First try request variables.
90 if (searchKey in templateCache) {
f20a336b 91 return templateCache[searchKey];
f992dcf6
DP
92 }
93
94 // Now try local storage.
95 var cached = storage.get('core_template/' + searchKey);
96
97 if (cached) {
f20a336b
DW
98 templateCache[searchKey] = $.Deferred().resolve(cached).promise();
99 return templateCache[searchKey];
f992dcf6
DP
100 }
101
102 // Oh well - load via ajax.
103 var promises = ajax.call([{
104 methodname: 'core_output_load_template',
35be5826 105 args: {
f992dcf6
DP
106 component: component,
107 template: name,
8d00afb1 108 themename: this.currentThemeName
f992dcf6
DP
109 }
110 }], async, false);
111
f20a336b 112 templateCache[searchKey] = promises[0].then(
35be5826 113 function(templateSource) {
f992dcf6 114 storage.set('core_template/' + searchKey, templateSource);
f20a336b 115 return templateSource;
f992dcf6
DP
116 }
117 );
f20a336b 118 return templateCache[searchKey];
f992dcf6
DP
119 };
120
121 /**
122 * Load a partial from the cache or ajax.
123 *
124 * @method partialHelper
125 * @private
126 * @param {string} name The partial name to load.
127 * @return {string}
128 */
8d00afb1 129 Renderer.prototype.partialHelper = function(name) {
f992dcf6
DP
130 var template = '';
131
f20a336b 132 // Don't use "then" here it will always be async even if the promise is resolved immediately.
8d00afb1 133 this.getTemplate(name, false).done(
f992dcf6
DP
134 function(source) {
135 template = source;
f20a336b 136 }
f992dcf6
DP
137 ).fail(notification.exception);
138
139 return template;
140 };
141
9bdcf579
DW
142 /**
143 * Render image icons.
144 *
145 * @method pixHelper
146 * @private
8d00afb1 147 * @param {object} context The mustache context
9bdcf579 148 * @param {string} sectionText The text to parse arguments from.
e37d53da 149 * @param {function} helper Used to render the alt attribute of the text.
9bdcf579
DW
150 * @return {string}
151 */
8d00afb1 152 Renderer.prototype.pixHelper = function(context, sectionText, helper) {
9bdcf579
DW
153 var parts = sectionText.split(',');
154 var key = '';
155 var component = '';
156 var text = '';
157 var result;
158
159 if (parts.length > 0) {
160 key = parts.shift().trim();
161 }
162 if (parts.length > 0) {
163 component = parts.shift().trim();
164 }
165 if (parts.length > 0) {
166 text = parts.join(',').trim();
167 }
168 var url = coreurl.imageUrl(key, component);
169
170 var templatecontext = {
171 attributes: [
9f5f3dcc
DP
172 {name: 'src', value: url},
173 {name: 'alt', value: helper(text)},
8354d12d 174 {name: 'title', value: helper(text)},
9f5f3dcc 175 {name: 'class', value: 'smallicon'}
9bdcf579
DW
176 ]
177 };
178 // We forced loading of this early, so it will be in the cache.
f20a336b 179 var template = pixTemplateCache[this.currentThemeName];
8d00afb1 180 result = mustache.render(template, templatecontext, this.partialHelper.bind(this));
9bdcf579
DW
181 return result.trim();
182 };
183
9bdcf579
DW
184 /**
185 * Render blocks of javascript and save them in an array.
186 *
187 * @method jsHelper
188 * @private
8d00afb1 189 * @param {object} context The current mustache context.
9bdcf579
DW
190 * @param {string} sectionText The text to save as a js block.
191 * @param {function} helper Used to render the block.
192 * @return {string}
193 */
8d00afb1
DW
194 Renderer.prototype.jsHelper = function(context, sectionText, helper) {
195 this.requiredJS.push(helper(sectionText, context));
9bdcf579
DW
196 return '';
197 };
198
199 /**
200 * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
201 * into a get_string call.
202 *
203 * @method stringHelper
204 * @private
8d00afb1 205 * @param {object} context The current mustache context.
9bdcf579
DW
206 * @param {string} sectionText The text to parse the arguments from.
207 * @param {function} helper Used to render subsections of the text.
208 * @return {string}
209 */
8d00afb1 210 Renderer.prototype.stringHelper = function(context, sectionText, helper) {
9bdcf579
DW
211 var parts = sectionText.split(',');
212 var key = '';
213 var component = '';
214 var param = '';
215 if (parts.length > 0) {
216 key = parts.shift().trim();
217 }
218 if (parts.length > 0) {
219 component = parts.shift().trim();
220 }
221 if (parts.length > 0) {
222 param = parts.join(',').trim();
223 }
224
225 if (param !== '') {
226 // Allow variable expansion in the param part only.
8d00afb1 227 param = helper(param, context);
9bdcf579
DW
228 }
229 // Allow json formatted $a arguments.
230 if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
231 param = JSON.parse(param);
232 }
233
8d00afb1
DW
234 var index = this.requiredStrings.length;
235 this.requiredStrings.push({key: key, component: component, param: param});
a89cf237
FM
236
237 // The placeholder must not use {{}} as those can be misinterpreted by the engine.
238 return '[[_s' + index + ']]';
9bdcf579
DW
239 };
240
0b4bff8c
AN
241 /**
242 * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
243 *
244 * @method quoteHelper
245 * @private
8d00afb1 246 * @param {object} context The current mustache context.
0b4bff8c
AN
247 * @param {string} sectionText The text to parse the arguments from.
248 * @param {function} helper Used to render subsections of the text.
249 * @return {string}
250 */
8d00afb1
DW
251 Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
252 var content = helper(sectionText.trim(), context);
0b4bff8c
AN
253
254 // Escape the {{ and the ".
255 // This involves wrapping {{, and }} in change delimeter tags.
256 content = content
257 .replace('"', '\\"')
258 .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
259 ;
260 return '"' + content + '"';
261 };
262
9bdcf579
DW
263 /**
264 * Add some common helper functions to all context objects passed to templates.
265 * These helpers match exactly the helpers available in php.
266 *
267 * @method addHelpers
268 * @private
269 * @param {Object} context Simple types used as the context for the template.
270 * @param {String} themeName We set this multiple times, because there are async calls.
271 */
8d00afb1
DW
272 Renderer.prototype.addHelpers = function(context, themeName) {
273 this.currentThemeName = themeName;
274 this.requiredStrings = [];
275 this.requiredJS = [];
276 context.uniqid = (uniqInstances++);
c96f55e6 277 context.str = function() {
8d00afb1
DW
278 return this.stringHelper.bind(this, context);
279 }.bind(this);
c96f55e6 280 context.pix = function() {
8d00afb1
DW
281 return this.pixHelper.bind(this, context);
282 }.bind(this);
c96f55e6 283 context.js = function() {
8d00afb1
DW
284 return this.jsHelper.bind(this, context);
285 }.bind(this);
c96f55e6 286 context.quote = function() {
8d00afb1
DW
287 return this.quoteHelper.bind(this, context);
288 }.bind(this);
9f5f3dcc 289 context.globals = {config: config};
9bdcf579
DW
290 context.currentTheme = themeName;
291 };
292
293 /**
294 * Get all the JS blocks from the last rendered template.
295 *
296 * @method getJS
297 * @private
298 * @param {string[]} strings Replacement strings.
299 * @return {string}
300 */
8d00afb1 301 Renderer.prototype.getJS = function(strings) {
9bdcf579 302 var js = '';
8d00afb1
DW
303 if (this.requiredJS.length > 0) {
304 js = this.requiredJS.join(";\n");
9bdcf579
DW
305 }
306
9bdcf579 307 // Re-render to get the final strings.
8d00afb1 308 return this.treatStringsInContent(js, strings);
29879f8f
FM
309 };
310
311 /**
312 * Treat strings in content.
313 *
314 * The purpose of this method is to replace the placeholders found in a string
315 * with the their respective translated strings.
316 *
317 * Previously we were relying on String.replace() but the complexity increased with
318 * the numbers of strings to replace. Now we manually walk the string and stop at each
319 * placeholder we find, only then we replace it. Most of the time we will
320 * replace all the placeholders in a single run, at times we will need a few
321 * more runs when placeholders are replaced with strings that contain placeholders
322 * themselves.
323 *
324 * @param {String} content The content in which string placeholders are to be found.
325 * @param {Array} strings The strings to replace with.
326 * @return {String} The treated content.
327 */
8d00afb1 328 Renderer.prototype.treatStringsInContent = function(content, strings) {
a89cf237 329 var pattern = /\[\[_s\d+\]\]/,
29879f8f
FM
330 treated,
331 index,
332 strIndex,
333 walker,
334 char,
335 strFinal;
336
337 do {
338 treated = '';
339 index = content.search(pattern);
340 while (index > -1) {
341
342 // Copy the part prior to the placeholder to the treated string.
343 treated += content.substring(0, index);
344 content = content.substr(index);
345 strIndex = '';
a89cf237 346 walker = 4; // 4 is the length of '[[_s'.
29879f8f
FM
347
348 // Walk the characters to manually extract the index of the string from the placeholder.
349 char = content.substr(walker, 1);
350 do {
351 strIndex += char;
352 walker++;
353 char = content.substr(walker, 1);
a89cf237 354 } while (char != ']');
29879f8f
FM
355
356 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
357 strFinal = strings[parseInt(strIndex, 10)];
358 if (typeof strFinal === 'undefined') {
a89cf237 359 Log.debug('Could not find string for pattern [[_s' + strIndex + ']].');
29879f8f
FM
360 strFinal = '';
361 }
362 treated += strFinal;
a89cf237 363 content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '[[_s]]'.
29879f8f
FM
364
365 // Find the next placeholder.
366 index = content.search(pattern);
367 }
368
369 // The content becomes the treated part with the rest of the content.
370 content = treated + content;
371
372 // Check if we need to walk the content again, in case strings contained placeholders.
373 index = content.search(pattern);
374
375 } while (index > -1);
376
377 return content;
9bdcf579
DW
378 };
379
380 /**
381 * Render a template and then call the callback with the result.
382 *
383 * @method doRender
384 * @private
385 * @param {string} templateSource The mustache template to render.
386 * @param {Object} context Simple types used as the context for the template.
387 * @param {String} themeName Name of the current theme.
388 * @return {Promise} object
389 */
8d00afb1 390 Renderer.prototype.doRender = function(templateSource, context, themeName) {
8d00afb1 391 this.currentThemeName = themeName;
9bdcf579 392
f20a336b
DW
393 return this.getTemplate('core/pix_icon', true).then(function(str) {
394 // Make sure we fetch this first.
395 pixTemplateCache[this.currentThemeName] = str;
396 }.bind(this)).then(function() {
397 this.addHelpers(context, themeName);
398 var result = mustache.render(templateSource, context, this.partialHelper.bind(this));
399
400 if (this.requiredStrings.length > 0) {
401 return str.get_strings(this.requiredStrings).then(function(strings) {
402
403 // Why do we not do another call the render here?
404 //
405 // Because that would expose DOS holes. E.g.
406 // I create an assignment called "{{fish" which
407 // would get inserted in the template in the first pass
408 // and cause the template to die on the second pass (unbalanced).
409
410 result = this.treatStringsInContent(result, strings);
411 return $.Deferred().resolve(result, this.getJS(strings)).promise();
412 }.bind(this));
413 } else {
414 return $.Deferred().resolve(result.trim(), this.getJS([])).promise();
415 }
416 }.bind(this));
9bdcf579
DW
417 };
418
28de7771
DW
419 /**
420 * Execute a block of JS returned from a template.
421 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
422 *
423 * @method runTemplateJS
424 * @param {string} source - A block of javascript.
425 */
426 var runTemplateJS = function(source) {
427 if (source.trim() !== '') {
35be5826 428 var newscript = $('<script>').attr('type', 'text/javascript').html(source);
28de7771
DW
429 $('head').append(newscript);
430 }
431 };
432
433 /**
434 * Do some DOM replacement and trigger correct events and fire javascript.
435 *
436 * @method domReplace
437 * @private
438 * @param {JQuery} element - Element or selector to replace.
439 * @param {String} newHTML - HTML to insert / replace.
440 * @param {String} newJS - Javascript to run after the insertion.
441 * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
442 */
443 var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
444 var replaceNode = $(element);
445 if (replaceNode.length) {
446 // First create the dom nodes so we have a reference to them.
447 var newNodes = $(newHTML);
1fca8a7b 448 var yuiNodes = null;
28de7771
DW
449 // Do the replacement in the page.
450 if (replaceChildNodes) {
1fca8a7b
DW
451 // Cleanup any YUI event listeners attached to any of these nodes.
452 yuiNodes = new Y.NodeList(replaceNode.children().get());
453 yuiNodes.destroy(true);
454
455 // JQuery will cleanup after itself.
28de7771
DW
456 replaceNode.empty();
457 replaceNode.append(newNodes);
458 } else {
1fca8a7b
DW
459 // Cleanup any YUI event listeners attached to any of these nodes.
460 yuiNodes = new Y.NodeList(replaceNode.get());
461 yuiNodes.destroy(true);
462
463 // JQuery will cleanup after itself.
28de7771
DW
464 replaceNode.replaceWith(newNodes);
465 }
466 // Run any javascript associated with the new HTML.
467 runTemplateJS(newJS);
468 // Notify all filters about the new content.
469 event.notifyFilterContentUpdated(newNodes);
470 }
471 };
472
8d00afb1
DW
473 /**
474 * Load a template and call doRender on it.
475 *
476 * @method render
477 * @private
478 * @param {string} templateName - should consist of the component and the name of the template like this:
479 * core/menu (lib/templates/menu.mustache) or
480 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
481 * @param {Object} context - Could be array, string or simple value for the context of the template.
482 * @param {string} themeName - Name of the current theme.
483 * @return {Promise} JQuery promise object resolved when the template has been rendered.
484 */
485 Renderer.prototype.render = function(templateName, context, themeName) {
8d00afb1
DW
486 if (typeof (themeName) === "undefined") {
487 // System context by default.
488 themeName = config.theme;
489 }
490
491 this.currentThemeName = themeName;
492
f20a336b
DW
493 return this.getTemplate(templateName, true).then( function(templateSource) {
494 return this.doRender(templateSource, context, themeName);
495 }.bind(this) );
8d00afb1
DW
496 };
497
90525930
MN
498 /**
499 * Prepend some HTML to a node and trigger events and fire javascript.
500 *
501 * @method domPrepend
502 * @private
503 * @param {jQuery|String} element - Element or selector to prepend HTML to
504 * @param {String} html - HTML to prepend
505 * @param {String} js - Javascript to run after we prepend the html
506 */
507 var domPrepend = function(element, html, js) {
508 var node = $(element);
509 if (node.length) {
510 // Prepend the html.
511 node.prepend(html);
512 // Run any javascript associated with the new HTML.
513 runTemplateJS(js);
514 // Notify all filters about the new content.
515 event.notifyFilterContentUpdated(node);
516 }
517 };
28de7771 518
f7775c9a
MN
519 /**
520 * Append some HTML to a node and trigger events and fire javascript.
521 *
522 * @method domAppend
523 * @private
524 * @param {jQuery|String} element - Element or selector to append HTML to
525 * @param {String} html - HTML to append
526 * @param {String} js - Javascript to run after we append the html
527 */
528 var domAppend = function(element, html, js) {
529 var node = $(element);
530 if (node.length) {
531 // Append the html.
532 node.append(html);
533 // Run any javascript associated with the new HTML.
534 runTemplateJS(js);
535 // Notify all filters about the new content.
536 event.notifyFilterContentUpdated(node);
537 }
538 };
539
9bdcf579
DW
540 return /** @alias module:core/templates */ {
541 // Public variables and functions.
542 /**
8d00afb1
DW
543 * Every call to render creates a new instance of the class and calls render on it. This
544 * means each render call has it's own class variables.
9bdcf579
DW
545 *
546 * @method render
547 * @private
548 * @param {string} templateName - should consist of the component and the name of the template like this:
549 * core/menu (lib/templates/menu.mustache) or
550 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
551 * @param {Object} context - Could be array, string or simple value for the context of the template.
552 * @param {string} themeName - Name of the current theme.
553 * @return {Promise} JQuery promise object resolved when the template has been rendered.
554 */
555 render: function(templateName, context, themeName) {
8d00afb1
DW
556 var renderer = new Renderer();
557 return renderer.render(templateName, context, themeName);
9bdcf579
DW
558 },
559
560 /**
561 * Execute a block of JS returned from a template.
562 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
563 *
564 * @method runTemplateJS
9bdcf579
DW
565 * @param {string} source - A block of javascript.
566 */
28de7771
DW
567 runTemplateJS: runTemplateJS,
568
569 /**
570 * Replace a node in the page with some new HTML and run the JS.
571 *
572 * @method replaceNodeContents
c96f55e6
DP
573 * @param {JQuery} element - Element or selector to replace.
574 * @param {String} newHTML - HTML to insert / replace.
575 * @param {String} newJS - Javascript to run after the insertion.
28de7771
DW
576 */
577 replaceNodeContents: function(element, newHTML, newJS) {
c96f55e6 578 domReplace(element, newHTML, newJS, true);
28de7771
DW
579 },
580
581 /**
582 * Insert a node in the page with some new HTML and run the JS.
583 *
584 * @method replaceNode
c96f55e6
DP
585 * @param {JQuery} element - Element or selector to replace.
586 * @param {String} newHTML - HTML to insert / replace.
587 * @param {String} newJS - Javascript to run after the insertion.
28de7771
DW
588 */
589 replaceNode: function(element, newHTML, newJS) {
c96f55e6 590 domReplace(element, newHTML, newJS, false);
f7775c9a
MN
591 },
592
90525930
MN
593 /**
594 * Prepend some HTML to a node and trigger events and fire javascript.
595 *
596 * @method prependNodeContents
597 * @param {jQuery|String} element - Element or selector to prepend HTML to
598 * @param {String} html - HTML to prepend
599 * @param {String} js - Javascript to run after we prepend the html
600 */
601 prependNodeContents: function(element, html, js) {
602 domPrepend(element, html, js);
603 },
604
f7775c9a
MN
605 /**
606 * Append some HTML to a node and trigger events and fire javascript.
607 *
608 * @method appendNodeContents
609 * @param {jQuery|String} element - Element or selector to append HTML to
610 * @param {String} html - HTML to append
611 * @param {String} js - Javascript to run after we append the html
612 */
613 appendNodeContents: function(element, html, js) {
614 domAppend(element, html, js);
9bdcf579
DW
615 }
616 };
617});