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
43 function(mustache, $, ajax, str, notification, coreurl, config, storage, IconSystem, event, Y, Log, Truncate, UserDate,
47 /** @var {Number} uniqInstances Count of times this constructor has been called. */
48 var uniqInstances = 0;
50 /** @var {String[]} templateCache - Cache of already loaded template strings */
51 var templateCache = {};
53 /** @var {Promise[]} templatePromises - Cache of already loaded template promises */
54 var templatePromises = {};
56 /** @var {Promise[]} cachePartialPromises - Cache of already loaded template partial promises */
57 var cachePartialPromises = {};
59 /** @var {Object} iconSystem - Object extending core/iconsystem */
65 * Each call to templates.render gets it's own instance of this class.
67 var Renderer = function() {
68 this.requiredStrings = [];
70 this.requiredDates = [];
71 this.currentThemeName = '';
73 // Class variables and functions.
75 /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
76 Renderer.prototype.requiredStrings = null;
78 /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */
79 Renderer.prototype.requiredDates = [];
81 /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
82 Renderer.prototype.requiredJS = null;
84 /** @var {String} themeName for the current render */
85 Renderer.prototype.currentThemeName = '';
88 * Load a template from the cache or local storage or ajax request.
92 * @param {string} templateName - should consist of the component and the name of the template like this:
93 * core/menu (lib/templates/menu.mustache) or
94 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
95 * @return {Promise} JQuery promise object resolved when the template has been fetched.
97 Renderer.prototype.getTemplate = function(templateName) {
98 var parts = templateName.split('/');
99 var component = parts.shift();
100 var name = parts.shift();
102 var searchKey = this.currentThemeName + '/' + templateName;
104 // First try request variables.
105 if (searchKey in templatePromises) {
106 return templatePromises[searchKey];
109 // Now try local storage.
110 var cached = storage.get('core_template/' + searchKey);
113 templateCache[searchKey] = cached;
114 templatePromises[searchKey] = $.Deferred().resolve(cached).promise();
115 return templatePromises[searchKey];
118 // Oh well - load via ajax.
119 var promises = ajax.call([{
120 methodname: 'core_output_load_template',
122 component: component,
124 themename: this.currentThemeName
128 templatePromises[searchKey] = promises[0].then(
129 function(templateSource) {
130 templateCache[searchKey] = templateSource;
131 storage.set('core_template/' + searchKey, templateSource);
132 return templateSource;
135 return templatePromises[searchKey];
139 * Load a partial from the cache or ajax.
141 * @method partialHelper
143 * @param {string} name The partial name to load.
146 Renderer.prototype.partialHelper = function(name) {
148 var searchKey = this.currentThemeName + '/' + name;
150 if (!(searchKey in templateCache)) {
151 notification.exception(new Error('Failed to pre-fetch the template: ' + name));
154 return templateCache[searchKey];
158 * Render a single image icon.
162 * @param {string} key The icon key.
163 * @param {string} component The component name.
164 * @param {string} title The icon title
167 Renderer.prototype.renderIcon = function(key, component, title) {
168 // Preload the module to do the icon rendering based on the theme iconsystem.
169 var modulename = config.iconsystemmodule;
171 // RequireJS does not return a promise.
172 var ready = $.Deferred();
173 require([modulename], function(System) {
174 var system = new System();
175 if (!(system instanceof IconSystem)) {
176 ready.reject('Invalid icon system specified' + config.iconsystemmodule);
179 system.init().then(ready.resolve).catch(notification.exception);
183 return ready.then(function(iconSystem) {
184 return this.getTemplate(iconSystem.getTemplateName());
185 }.bind(this)).then(function(template) {
186 return iconSystem.renderIcon(key, component, title, template);
191 * Render image icons.
195 * @param {object} context The mustache context
196 * @param {string} sectionText The text to parse arguments from.
197 * @param {function} helper Used to render the alt attribute of the text.
200 Renderer.prototype.pixHelper = function(context, sectionText, helper) {
201 var parts = sectionText.split(',');
206 if (parts.length > 0) {
207 key = helper(parts.shift().trim(), context);
209 if (parts.length > 0) {
210 component = helper(parts.shift().trim(), context);
212 if (parts.length > 0) {
213 text = helper(parts.join(',').trim(), context);
216 var templateName = iconSystem.getTemplateName();
218 var searchKey = this.currentThemeName + '/' + templateName;
219 var template = templateCache[searchKey];
221 // The key might have been escaped by the JS Mustache engine which
222 // converts forward slashes to HTML entities. Let us undo that here.
223 key = key.replace(///gi, '/');
225 return iconSystem.renderIcon(key, component, text, template);
229 * Render blocks of javascript and save them in an array.
233 * @param {object} context The current mustache context.
234 * @param {string} sectionText The text to save as a js block.
235 * @param {function} helper Used to render the block.
238 Renderer.prototype.jsHelper = function(context, sectionText, helper) {
239 this.requiredJS.push(helper(sectionText, context));
244 * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
245 * into a get_string call.
247 * @method stringHelper
249 * @param {object} context The current mustache context.
250 * @param {string} sectionText The text to parse the arguments from.
251 * @param {function} helper Used to render subsections of the text.
254 Renderer.prototype.stringHelper = function(context, sectionText, helper) {
255 var parts = sectionText.split(',');
259 if (parts.length > 0) {
260 key = parts.shift().trim();
262 if (parts.length > 0) {
263 component = parts.shift().trim();
265 if (parts.length > 0) {
266 param = parts.join(',').trim();
270 // Allow variable expansion in the param part only.
271 param = helper(param, context);
273 // Allow json formatted $a arguments.
274 if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
275 param = JSON.parse(param);
278 var index = this.requiredStrings.length;
279 this.requiredStrings.push({key: key, component: component, param: param});
281 // The placeholder must not use {{}} as those can be misinterpreted by the engine.
282 return '[[_s' + index + ']]';
286 * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
288 * @method quoteHelper
290 * @param {object} context The current mustache context.
291 * @param {string} sectionText The text to parse the arguments from.
292 * @param {function} helper Used to render subsections of the text.
295 Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
296 var content = helper(sectionText.trim(), context);
298 // Escape the {{ and the ".
299 // This involves wrapping {{, and }} in change delimeter tags.
302 .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
304 return '"' + content + '"';
308 * Shorten text helper to truncate text and append a trailing ellipsis.
310 * @method shortenTextHelper
312 * @param {object} context The current mustache context.
313 * @param {string} sectionText The text to parse the arguments from.
314 * @param {function} helper Used to render subsections of the text.
317 Renderer.prototype.shortenTextHelper = function(context, sectionText, helper) {
318 // Non-greedy split on comma to grab section text into the length and
320 var regex = /(.*?),(.*)/;
321 var parts = sectionText.match(regex);
322 // The length is the part matched in the first set of parethesis.
323 var length = parts[1].trim();
324 // The length is the part matched in the second set of parethesis.
325 var text = parts[2].trim();
326 var content = helper(text, context);
327 return Truncate.truncate(content, {
335 * User date helper to render user dates from timestamps.
337 * @method userDateHelper
339 * @param {object} context The current mustache context.
340 * @param {string} sectionText The text to parse the arguments from.
341 * @param {function} helper Used to render subsections of the text.
344 Renderer.prototype.userDateHelper = function(context, sectionText, helper) {
345 // Non-greedy split on comma to grab the timestamp and format.
346 var regex = /(.*?),(.*)/;
347 var parts = sectionText.match(regex);
348 var timestamp = helper(parts[1].trim(), context);
349 var format = helper(parts[2].trim(), context);
350 var index = this.requiredDates.length;
352 this.requiredDates.push({
353 timestamp: timestamp,
357 return '[[_t_' + index + ']]';
361 * Add some common helper functions to all context objects passed to templates.
362 * These helpers match exactly the helpers available in php.
366 * @param {Object} context Simple types used as the context for the template.
367 * @param {String} themeName We set this multiple times, because there are async calls.
369 Renderer.prototype.addHelpers = function(context, themeName) {
370 this.currentThemeName = themeName;
371 this.requiredStrings = [];
372 this.requiredJS = [];
373 context.uniqid = (uniqInstances++);
374 context.str = function() {
375 return this.stringHelper.bind(this, context);
377 context.pix = function() {
378 return this.pixHelper.bind(this, context);
380 context.js = function() {
381 return this.jsHelper.bind(this, context);
383 context.quote = function() {
384 return this.quoteHelper.bind(this, context);
386 context.shortentext = function() {
387 return this.shortenTextHelper.bind(this, context);
389 context.userdate = function() {
390 return this.userDateHelper.bind(this, context);
392 context.globals = {config: config};
393 context.currentTheme = themeName;
397 * Get all the JS blocks from the last rendered template.
403 Renderer.prototype.getJS = function() {
405 if (this.requiredJS.length > 0) {
406 js = this.requiredJS.join(";\n");
413 * Treat strings in content.
415 * The purpose of this method is to replace the placeholders found in a string
416 * with the their respective translated strings.
418 * Previously we were relying on String.replace() but the complexity increased with
419 * the numbers of strings to replace. Now we manually walk the string and stop at each
420 * placeholder we find, only then we replace it. Most of the time we will
421 * replace all the placeholders in a single run, at times we will need a few
422 * more runs when placeholders are replaced with strings that contain placeholders
425 * @param {String} content The content in which string placeholders are to be found.
426 * @param {Array} strings The strings to replace with.
427 * @return {String} The treated content.
429 Renderer.prototype.treatStringsInContent = function(content, strings) {
430 var pattern = /\[\[_s\d+\]\]/,
440 index = content.search(pattern);
443 // Copy the part prior to the placeholder to the treated string.
444 treated += content.substring(0, index);
445 content = content.substr(index);
447 walker = 4; // 4 is the length of '[[_s'.
449 // Walk the characters to manually extract the index of the string from the placeholder.
450 char = content.substr(walker, 1);
454 char = content.substr(walker, 1);
455 } while (char != ']');
457 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
458 strFinal = strings[parseInt(strIndex, 10)];
459 if (typeof strFinal === 'undefined') {
460 Log.debug('Could not find string for pattern [[_s' + strIndex + ']].');
464 content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '[[_s]]'.
466 // Find the next placeholder.
467 index = content.search(pattern);
470 // The content becomes the treated part with the rest of the content.
471 content = treated + content;
473 // Check if we need to walk the content again, in case strings contained placeholders.
474 index = content.search(pattern);
476 } while (index > -1);
482 * Treat strings in content.
484 * The purpose of this method is to replace the date placeholders found in the
485 * content with the their respective translated dates.
487 * @param {String} content The content in which string placeholders are to be found.
488 * @param {Array} strings The strings to replace with.
489 * @return {String} The treated content.
491 Renderer.prototype.treatDatesInContent = function(content, dates) {
492 dates.forEach(function(date, index) {
493 var key = '\\[\\[_t_' + index + '\\]\\]';
494 var re = new RegExp(key, 'g');
495 content = content.replace(re, date);
502 * Render a template and then call the callback with the result.
506 * @param {string} templateSource The mustache template to render.
507 * @param {Object} context Simple types used as the context for the template.
508 * @param {String} themeName Name of the current theme.
509 * @return {Promise} object
511 Renderer.prototype.doRender = function(templateSource, context, themeName) {
512 this.currentThemeName = themeName;
513 var iconTemplate = iconSystem.getTemplateName();
515 var pendingPromise = new Pending('core/templates:doRender');
516 return this.getTemplate(iconTemplate).then(function() {
517 this.addHelpers(context, themeName);
518 var result = mustache.render(templateSource, context, this.partialHelper.bind(this));
519 return $.Deferred().resolve(result.trim(), this.getJS()).promise();
521 .then(function(html, js) {
522 if (this.requiredStrings.length > 0) {
523 return str.get_strings(this.requiredStrings).then(function(strings) {
525 // Make sure string substitutions are done for the userdate
527 this.requiredDates = this.requiredDates.map(function(date) {
529 timestamp: this.treatStringsInContent(date.timestamp, strings),
530 format: this.treatStringsInContent(date.format, strings)
534 // Why do we not do another call the render here?
536 // Because that would expose DOS holes. E.g.
537 // I create an assignment called "{{fish" which
538 // would get inserted in the template in the first pass
539 // and cause the template to die on the second pass (unbalanced).
540 html = this.treatStringsInContent(html, strings);
541 js = this.treatStringsInContent(js, strings);
542 return $.Deferred().resolve(html, js).promise();
546 return $.Deferred().resolve(html, js).promise();
548 .then(function(html, js) {
549 // This has to happen after the strings replacement because you can
550 // use the string helper in content for the user date helper.
551 if (this.requiredDates.length > 0) {
552 return UserDate.get(this.requiredDates).then(function(dates) {
553 html = this.treatDatesInContent(html, dates);
554 js = this.treatDatesInContent(js, dates);
555 return $.Deferred().resolve(html, js).promise();
559 return $.Deferred().resolve(html, js).promise();
561 .then(function(html, js) {
562 pendingPromise.resolve();
563 return $.Deferred().resolve(html, js).promise();
568 * Execute a block of JS returned from a template.
569 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
571 * @method runTemplateJS
572 * @param {string} source - A block of javascript.
574 var runTemplateJS = function(source) {
575 if (source.trim() !== '') {
576 var newscript = $('<script>').attr('type', 'text/javascript').html(source);
577 $('head').append(newscript);
582 * Do some DOM replacement and trigger correct events and fire javascript.
586 * @param {JQuery} element - Element or selector to replace.
587 * @param {String} newHTML - HTML to insert / replace.
588 * @param {String} newJS - Javascript to run after the insertion.
589 * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
591 var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
592 var replaceNode = $(element);
593 if (replaceNode.length) {
594 // First create the dom nodes so we have a reference to them.
595 var newNodes = $(newHTML);
597 // Do the replacement in the page.
598 if (replaceChildNodes) {
599 // Cleanup any YUI event listeners attached to any of these nodes.
600 yuiNodes = new Y.NodeList(replaceNode.children().get());
601 yuiNodes.destroy(true);
603 // JQuery will cleanup after itself.
605 replaceNode.append(newNodes);
607 // Cleanup any YUI event listeners attached to any of these nodes.
608 yuiNodes = new Y.NodeList(replaceNode.get());
609 yuiNodes.destroy(true);
611 // JQuery will cleanup after itself.
612 replaceNode.replaceWith(newNodes);
614 // Run any javascript associated with the new HTML.
615 runTemplateJS(newJS);
616 // Notify all filters about the new content.
617 event.notifyFilterContentUpdated(newNodes);
622 * Scan a template source for partial tags and return a list of the found partials.
624 * @method scanForPartials
626 * @param {string} templateSource - source template to scan.
627 * @return {Array} List of partials.
629 Renderer.prototype.scanForPartials = function(templateSource) {
630 var tokens = mustache.parse(templateSource),
633 var findPartial = function(tokens, partials) {
635 for (i = 0; i < tokens.length; i++) {
637 if (token[0] == '>' || token[0] == '<') {
638 partials.push(token[1]);
640 if (token.length > 4) {
641 findPartial(token[4], partials);
646 findPartial(tokens, partials);
652 * Load a template and scan it for partials. Recursively fetch the partials.
654 * @method cachePartials
656 * @param {string} templateName - should consist of the component and the name of the template like this:
657 * core/menu (lib/templates/menu.mustache) or
658 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
659 * @return {Promise} JQuery promise object resolved when all partials are in the cache.
661 Renderer.prototype.cachePartials = function(templateName) {
662 var searchKey = this.currentThemeName + '/' + templateName;
664 if (searchKey in cachePartialPromises) {
665 return cachePartialPromises[searchKey];
668 // This promise will not be resolved until all child partials are also resolved and ready.
669 // We create it here to allow us to check for recursive inclusion of templates.
670 cachePartialPromises[searchKey] = $.Deferred();
672 this.getTemplate(templateName)
673 .then(function(templateSource) {
674 var partials = this.scanForPartials(templateSource);
675 var uniquePartials = partials.filter(function(partialName) {
676 // Check for recursion.
678 if (typeof cachePartialPromises[this.currentThemeName + '/' + partialName] !== 'undefined') {
679 // Ignore templates which include their parent.
683 // Ignore templates that include themselves.
684 return partialName != templateName;
687 // Fetch any partial which has not already been fetched.
688 var fetchThemAll = uniquePartials.map(function(partialName) {
689 return this.cachePartials(partialName);
692 // Resolve the templateName promise when all of the children are resolved.
693 return $.when.apply($, fetchThemAll)
695 return cachePartialPromises[searchKey].resolve(templateSource);
698 .catch(cachePartialPromises[searchKey].reject);
700 return cachePartialPromises[searchKey];
704 * Load a template and call doRender on it.
708 * @param {string} templateName - should consist of the component and the name of the template like this:
709 * core/menu (lib/templates/menu.mustache) or
710 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
711 * @param {Object} context - Could be array, string or simple value for the context of the template.
712 * @param {string} themeName - Name of the current theme.
713 * @return {Promise} JQuery promise object resolved when the template has been rendered.
715 Renderer.prototype.render = function(templateName, context, themeName) {
716 if (typeof (themeName) === "undefined") {
717 // System context by default.
718 themeName = config.theme;
721 this.currentThemeName = themeName;
723 // Preload the module to do the icon rendering based on the theme iconsystem.
724 var modulename = config.iconsystemmodule;
726 var ready = $.Deferred();
727 require([modulename], function(System) {
728 var system = new System();
729 if (!(system instanceof IconSystem)) {
730 ready.reject('Invalid icon system specified' + config.iconsystem);
733 system.init().then(ready.resolve).catch(notification.exception);
737 return ready.then(function() {
738 return this.cachePartials(templateName);
739 }.bind(this)).then(function(templateSource) {
740 return this.doRender(templateSource, context, themeName);
745 * Prepend some HTML to a node and trigger events and fire javascript.
749 * @param {jQuery|String} element - Element or selector to prepend HTML to
750 * @param {String} html - HTML to prepend
751 * @param {String} js - Javascript to run after we prepend the html
753 var domPrepend = function(element, html, js) {
754 var node = $(element);
758 // Run any javascript associated with the new HTML.
760 // Notify all filters about the new content.
761 event.notifyFilterContentUpdated(node);
766 * Append some HTML to a node and trigger events and fire javascript.
770 * @param {jQuery|String} element - Element or selector to append HTML to
771 * @param {String} html - HTML to append
772 * @param {String} js - Javascript to run after we append the html
774 var domAppend = function(element, html, js) {
775 var node = $(element);
779 // Run any javascript associated with the new HTML.
781 // Notify all filters about the new content.
782 event.notifyFilterContentUpdated(node);
786 return /** @alias module:core/templates */ {
787 // Public variables and functions.
789 * Every call to render creates a new instance of the class and calls render on it. This
790 * means each render call has it's own class variables.
794 * @param {string} templateName - should consist of the component and the name of the template like this:
795 * core/menu (lib/templates/menu.mustache) or
796 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
797 * @param {Object} context - Could be array, string or simple value for the context of the template.
798 * @param {string} themeName - Name of the current theme.
799 * @return {Promise} JQuery promise object resolved when the template has been rendered.
801 render: function(templateName, context, themeName) {
802 var renderer = new Renderer();
803 return renderer.render(templateName, context, themeName);
807 * Every call to renderIcon creates a new instance of the class and calls renderIcon on it. This
808 * means each render call has it's own class variables.
812 * @param {string} key - Icon key.
813 * @param {string} component - Icon component
814 * @param {string} title - Icon title
815 * @return {Promise} JQuery promise object resolved when the pix has been rendered.
817 renderPix: function(key, component, title) {
818 var renderer = new Renderer();
819 return renderer.renderIcon(key, component, title);
823 * Execute a block of JS returned from a template.
824 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
826 * @method runTemplateJS
827 * @param {string} source - A block of javascript.
829 runTemplateJS: runTemplateJS,
832 * Replace a node in the page with some new HTML and run the JS.
834 * @method replaceNodeContents
835 * @param {JQuery} element - Element or selector to replace.
836 * @param {String} newHTML - HTML to insert / replace.
837 * @param {String} newJS - Javascript to run after the insertion.
839 replaceNodeContents: function(element, newHTML, newJS) {
840 domReplace(element, newHTML, newJS, true);
844 * Insert a node in the page with some new HTML and run the JS.
846 * @method replaceNode
847 * @param {JQuery} element - Element or selector to replace.
848 * @param {String} newHTML - HTML to insert / replace.
849 * @param {String} newJS - Javascript to run after the insertion.
851 replaceNode: function(element, newHTML, newJS) {
852 domReplace(element, newHTML, newJS, false);
856 * Prepend some HTML to a node and trigger events and fire javascript.
858 * @method prependNodeContents
859 * @param {jQuery|String} element - Element or selector to prepend HTML to
860 * @param {String} html - HTML to prepend
861 * @param {String} js - Javascript to run after we prepend the html
863 prependNodeContents: function(element, html, js) {
864 domPrepend(element, html, js);
868 * Append some HTML to a node and trigger events and fire javascript.
870 * @method appendNodeContents
871 * @param {jQuery|String} element - Element or selector to append HTML to
872 * @param {String} html - HTML to append
873 * @param {String} js - Javascript to run after we append the html
875 appendNodeContents: function(element, html, js) {
876 domAppend(element, html, js);