1 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
17 * Template renderer for Moodle. Load and render Moodle templates with Mustache.
19 * @module core/templates
22 * @copyright 2015 Damyon Wiese <damyon@moodle.com>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26 define(['core/mustache',
38 function(mustache, $, ajax, str, notification, coreurl, config, storage, event, Y, Log) {
41 /** @var {Number} uniqInstances Count of times this constructor has been called. */
42 var uniqInstances = 0;
44 /** @var {string[]} templateCache - Cache of already loaded templates */
45 var templateCache = {};
50 * Each call to templates.render gets it's own instance of this class.
52 var Renderer = function() {
53 this.requiredStrings = [];
55 this.currentThemeName = '';
57 // Class variables and functions.
59 /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
60 Renderer.prototype.requiredStrings = null;
62 /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
63 Renderer.prototype.requiredJS = null;
65 /** @var {String} themeName for the current render */
66 Renderer.prototype.currentThemeName = '';
69 * Load a template from the cache or local storage or ajax request.
73 * @param {string} templateName - should consist of the component and the name of the template like this:
74 * core/menu (lib/templates/menu.mustache) or
75 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
76 * @param {Boolean} async If false - this function will not return until the promises are resolved.
77 * @return {Promise} JQuery promise object resolved when the template has been fetched.
79 Renderer.prototype.getTemplate = function(templateName, async) {
80 var deferred = $.Deferred();
81 var parts = templateName.split('/');
82 var component = parts.shift();
83 var name = parts.shift();
85 var searchKey = this.currentThemeName + '/' + templateName;
87 // First try request variables.
88 if (searchKey in templateCache) {
89 deferred.resolve(templateCache[searchKey]);
90 return deferred.promise();
93 // Now try local storage.
94 var cached = storage.get('core_template/' + searchKey);
97 deferred.resolve(cached);
98 templateCache[searchKey] = cached;
99 return deferred.promise();
102 // Oh well - load via ajax.
103 var promises = ajax.call([{
104 methodname: 'core_output_load_template',
106 component: component,
108 themename: this.currentThemeName
113 function(templateSource) {
114 storage.set('core_template/' + searchKey, templateSource);
115 templateCache[searchKey] = templateSource;
116 deferred.resolve(templateSource);
123 return deferred.promise();
127 * Load a partial from the cache or ajax.
129 * @method partialHelper
131 * @param {string} name The partial name to load.
134 Renderer.prototype.partialHelper = function(name) {
137 this.getTemplate(name, false).done(
141 ).fail(notification.exception);
147 * Render image icons.
151 * @param {object} context The mustache context
152 * @param {string} sectionText The text to parse arguments from.
153 * @param {function} helper Used to render the alt attribute of the text.
156 Renderer.prototype.pixHelper = function(context, sectionText, helper) {
157 var parts = sectionText.split(',');
163 if (parts.length > 0) {
164 key = parts.shift().trim();
166 if (parts.length > 0) {
167 component = parts.shift().trim();
169 if (parts.length > 0) {
170 text = parts.join(',').trim();
172 var url = coreurl.imageUrl(key, component);
174 var templatecontext = {
176 {name: 'src', value: url},
177 {name: 'alt', value: helper(text)},
178 {name: 'class', value: 'smallicon'}
181 // We forced loading of this early, so it will be in the cache.
182 var template = templateCache[this.currentThemeName + '/core/pix_icon'];
183 result = mustache.render(template, templatecontext, this.partialHelper.bind(this));
184 return result.trim();
188 * Render blocks of javascript and save them in an array.
192 * @param {object} context The current mustache context.
193 * @param {string} sectionText The text to save as a js block.
194 * @param {function} helper Used to render the block.
197 Renderer.prototype.jsHelper = function(context, sectionText, helper) {
198 this.requiredJS.push(helper(sectionText, context));
203 * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
204 * into a get_string call.
206 * @method stringHelper
208 * @param {object} context The current mustache context.
209 * @param {string} sectionText The text to parse the arguments from.
210 * @param {function} helper Used to render subsections of the text.
213 Renderer.prototype.stringHelper = function(context, sectionText, helper) {
214 var parts = sectionText.split(',');
218 if (parts.length > 0) {
219 key = parts.shift().trim();
221 if (parts.length > 0) {
222 component = parts.shift().trim();
224 if (parts.length > 0) {
225 param = parts.join(',').trim();
229 // Allow variable expansion in the param part only.
230 param = helper(param, context);
232 // Allow json formatted $a arguments.
233 if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
234 param = JSON.parse(param);
237 var index = this.requiredStrings.length;
238 this.requiredStrings.push({key: key, component: component, param: param});
239 return '{{_s' + index + '}}';
243 * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
245 * @method quoteHelper
247 * @param {object} context The current mustache context.
248 * @param {string} sectionText The text to parse the arguments from.
249 * @param {function} helper Used to render subsections of the text.
252 Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
253 var content = helper(sectionText.trim(), context);
255 // Escape the {{ and the ".
256 // This involves wrapping {{, and }} in change delimeter tags.
259 .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
261 return '"' + content + '"';
265 * Add some common helper functions to all context objects passed to templates.
266 * These helpers match exactly the helpers available in php.
270 * @param {Object} context Simple types used as the context for the template.
271 * @param {String} themeName We set this multiple times, because there are async calls.
273 Renderer.prototype.addHelpers = function(context, themeName) {
274 this.currentThemeName = themeName;
275 this.requiredStrings = [];
276 this.requiredJS = [];
277 context.uniqid = (uniqInstances++);
278 context.str = function() {
279 return this.stringHelper.bind(this, context);
281 context.pix = function() {
282 return this.pixHelper.bind(this, context);
284 context.js = function() {
285 return this.jsHelper.bind(this, context);
287 context.quote = function() {
288 return this.quoteHelper.bind(this, context);
290 context.globals = {config: config};
291 context.currentTheme = themeName;
295 * Get all the JS blocks from the last rendered template.
299 * @param {string[]} strings Replacement strings.
302 Renderer.prototype.getJS = function(strings) {
304 if (this.requiredJS.length > 0) {
305 js = this.requiredJS.join(";\n");
308 // Re-render to get the final strings.
309 return this.treatStringsInContent(js, strings);
313 * Treat strings in content.
315 * The purpose of this method is to replace the placeholders found in a string
316 * with the their respective translated strings.
318 * Previously we were relying on String.replace() but the complexity increased with
319 * the numbers of strings to replace. Now we manually walk the string and stop at each
320 * placeholder we find, only then we replace it. Most of the time we will
321 * replace all the placeholders in a single run, at times we will need a few
322 * more runs when placeholders are replaced with strings that contain placeholders
325 * @param {String} content The content in which string placeholders are to be found.
326 * @param {Array} strings The strings to replace with.
327 * @return {String} The treated content.
329 Renderer.prototype.treatStringsInContent = function(content, strings) {
330 var pattern = /{{_s\d+}}/,
340 index = content.search(pattern);
343 // Copy the part prior to the placeholder to the treated string.
344 treated += content.substring(0, index);
345 content = content.substr(index);
347 walker = 4; // 4 is the length of '{{_s'.
349 // Walk the characters to manually extract the index of the string from the placeholder.
350 char = content.substr(walker, 1);
354 char = content.substr(walker, 1);
355 } while (char != '}');
357 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
358 strFinal = strings[parseInt(strIndex, 10)];
359 if (typeof strFinal === 'undefined') {
360 Log.debug('Could not find string for pattern {{_s' + strIndex + '}}.');
364 content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '{{_s}}'.
366 // Find the next placeholder.
367 index = content.search(pattern);
370 // The content becomes the treated part with the rest of the content.
371 content = treated + content;
373 // Check if we need to walk the content again, in case strings contained placeholders.
374 index = content.search(pattern);
376 } while (index > -1);
382 * Render a template and then call the callback with the result.
386 * @param {string} templateSource The mustache template to render.
387 * @param {Object} context Simple types used as the context for the template.
388 * @param {String} themeName Name of the current theme.
389 * @return {Promise} object
391 Renderer.prototype.doRender = function(templateSource, context, themeName) {
392 var deferred = $.Deferred();
394 this.currentThemeName = themeName;
396 // Make sure we fetch this first.
397 var loadPixTemplate = this.getTemplate('core/pix_icon', true);
399 loadPixTemplate.done(
401 this.addHelpers(context, themeName);
404 result = mustache.render(templateSource, context, this.partialHelper.bind(this));
409 if (this.requiredStrings.length > 0) {
410 str.get_strings(this.requiredStrings)
411 .then(function(strings) {
413 // Why do we not do another call the render here?
415 // Because that would expose DOS holes. E.g.
416 // I create an assignment called "{{fish" which
417 // would get inserted in the template in the first pass
418 // and cause the template to die on the second pass (unbalanced).
420 result = this.treatStringsInContent(result, strings);
421 deferred.resolve(result, this.getJS(strings));
423 .fail(deferred.reject);
425 deferred.resolve(result.trim(), this.getJS([]));
428 ).fail(deferred.reject);
429 return deferred.promise();
433 * Execute a block of JS returned from a template.
434 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
436 * @method runTemplateJS
437 * @param {string} source - A block of javascript.
439 var runTemplateJS = function(source) {
440 if (source.trim() !== '') {
441 var newscript = $('<script>').attr('type', 'text/javascript').html(source);
442 $('head').append(newscript);
447 * Do some DOM replacement and trigger correct events and fire javascript.
451 * @param {JQuery} element - Element or selector to replace.
452 * @param {String} newHTML - HTML to insert / replace.
453 * @param {String} newJS - Javascript to run after the insertion.
454 * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
456 var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
457 var replaceNode = $(element);
458 if (replaceNode.length) {
459 // First create the dom nodes so we have a reference to them.
460 var newNodes = $(newHTML);
462 // Do the replacement in the page.
463 if (replaceChildNodes) {
464 // Cleanup any YUI event listeners attached to any of these nodes.
465 yuiNodes = new Y.NodeList(replaceNode.children().get());
466 yuiNodes.destroy(true);
468 // JQuery will cleanup after itself.
470 replaceNode.append(newNodes);
472 // Cleanup any YUI event listeners attached to any of these nodes.
473 yuiNodes = new Y.NodeList(replaceNode.get());
474 yuiNodes.destroy(true);
476 // JQuery will cleanup after itself.
477 replaceNode.replaceWith(newNodes);
479 // Run any javascript associated with the new HTML.
480 runTemplateJS(newJS);
481 // Notify all filters about the new content.
482 event.notifyFilterContentUpdated(newNodes);
487 * Load a template and call doRender on it.
491 * @param {string} templateName - should consist of the component and the name of the template like this:
492 * core/menu (lib/templates/menu.mustache) or
493 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
494 * @param {Object} context - Could be array, string or simple value for the context of the template.
495 * @param {string} themeName - Name of the current theme.
496 * @return {Promise} JQuery promise object resolved when the template has been rendered.
498 Renderer.prototype.render = function(templateName, context, themeName) {
499 var deferred = $.Deferred();
501 if (typeof (themeName) === "undefined") {
502 // System context by default.
503 themeName = config.theme;
506 this.currentThemeName = themeName;
508 var loadTemplate = this.getTemplate(templateName, true);
511 function(templateSource) {
512 var renderPromise = this.doRender(templateSource, context, themeName);
515 function(result, js) {
516 deferred.resolve(result, js);
529 return deferred.promise();
533 return /** @alias module:core/templates */ {
534 // Public variables and functions.
536 * Every call to render creates a new instance of the class and calls render on it. This
537 * means each render call has it's own class variables.
541 * @param {string} templateName - should consist of the component and the name of the template like this:
542 * core/menu (lib/templates/menu.mustache) or
543 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
544 * @param {Object} context - Could be array, string or simple value for the context of the template.
545 * @param {string} themeName - Name of the current theme.
546 * @return {Promise} JQuery promise object resolved when the template has been rendered.
548 render: function(templateName, context, themeName) {
549 var renderer = new Renderer();
550 return renderer.render(templateName, context, themeName);
554 * Execute a block of JS returned from a template.
555 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
557 * @method runTemplateJS
558 * @param {string} source - A block of javascript.
560 runTemplateJS: runTemplateJS,
563 * Replace a node in the page with some new HTML and run the JS.
565 * @method replaceNodeContents
566 * @param {JQuery} element - Element or selector to replace.
567 * @param {String} newHTML - HTML to insert / replace.
568 * @param {String} newJS - Javascript to run after the insertion.
570 replaceNodeContents: function(element, newHTML, newJS) {
571 domReplace(element, newHTML, newJS, true);
575 * Insert a node in the page with some new HTML and run the JS.
577 * @method replaceNode
578 * @param {JQuery} element - Element or selector to replace.
579 * @param {String} newHTML - HTML to insert / replace.
580 * @param {String} newJS - Javascript to run after the insertion.
582 replaceNode: function(element, newHTML, newJS) {
583 domReplace(element, newHTML, newJS, false);