Merge branch 'MDL-54915-master' of git://github.com/andrewnicols/moodle
[moodle.git] / lib / amd / src / templates.js
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/>.
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  */
26 define(['core/mustache',
27          'jquery',
28          'core/ajax',
29          'core/str',
30          'core/notification',
31          'core/url',
32          'core/config',
33          'core/localstorage',
34          'core/event',
35          'core/yui',
36          'core/log'
37        ],
38        function(mustache, $, ajax, str, notification, coreurl, config, storage, event, Y, Log) {
40     // Private variables and functions.
42     /** @var {string[]} templateCache - Cache of already loaded templates */
43     var templateCache = {};
45     /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
46     var requiredStrings = [];
48     /** @var {Number} uniqid Incrementing value that is changed for every call to render */
49     var uniqid = 1;
51     /** @var {String} themeName for the current render */
52     var currentThemeName = '';
54     /**
55      * Load a template from the cache or local storage or ajax request.
56      *
57      * @method getTemplate
58      * @private
59      * @param {string} templateName - should consist of the component and the name of the template like this:
60      *                              core/menu (lib/templates/menu.mustache) or
61      *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
62      * @param {Boolean} async If false - this function will not return until the promises are resolved.
63      * @return {Promise} JQuery promise object resolved when the template has been fetched.
64      */
65     var getTemplate = function(templateName, async) {
66         var deferred = $.Deferred();
67         var parts = templateName.split('/');
68         var component = parts.shift();
69         var name = parts.shift();
71         var searchKey = currentThemeName + '/' + templateName;
73         // First try request variables.
74         if (searchKey in templateCache) {
75             deferred.resolve(templateCache[searchKey]);
76             return deferred.promise();
77         }
79         // Now try local storage.
80         var cached = storage.get('core_template/' + searchKey);
82         if (cached) {
83             deferred.resolve(cached);
84             templateCache[searchKey] = cached;
85             return deferred.promise();
86         }
88         // Oh well - load via ajax.
89         var promises = ajax.call([{
90             methodname: 'core_output_load_template',
91             args: {
92                 component: component,
93                 template: name,
94                 themename: currentThemeName
95             }
96         }], async, false);
98         promises[0].done(
99             function(templateSource) {
100                 storage.set('core_template/' + searchKey, templateSource);
101                 templateCache[searchKey] = templateSource;
102                 deferred.resolve(templateSource);
103             }
104         ).fail(
105             function(ex) {
106                 deferred.reject(ex);
107             }
108         );
109         return deferred.promise();
110     };
112     /**
113      * Load a partial from the cache or ajax.
114      *
115      * @method partialHelper
116      * @private
117      * @param {string} name The partial name to load.
118      * @return {string}
119      */
120     var partialHelper = function(name) {
121         var template = '';
123         getTemplate(name, false).done(
124             function(source) {
125                 template = source;
126             }
127         ).fail(notification.exception);
129         return template;
130     };
132     /**
133      * Render image icons.
134      *
135      * @method pixHelper
136      * @private
137      * @param {string} sectionText The text to parse arguments from.
138      * @param {function} helper Used to render the alt attribute of the text.
139      * @return {string}
140      */
141     var pixHelper = function(sectionText, helper) {
142         var parts = sectionText.split(',');
143         var key = '';
144         var component = '';
145         var text = '';
146         var result;
148         if (parts.length > 0) {
149             key = parts.shift().trim();
150         }
151         if (parts.length > 0) {
152             component = parts.shift().trim();
153         }
154         if (parts.length > 0) {
155             text = parts.join(',').trim();
156         }
157         var url = coreurl.imageUrl(key, component);
159         var templatecontext = {
160             attributes: [
161                 {name: 'src', value: url},
162                 {name: 'alt', value: helper(text)},
163                 {name: 'class', value: 'smallicon'}
164             ]
165         };
166         // We forced loading of this early, so it will be in the cache.
167         var template = templateCache[currentThemeName + '/core/pix_icon'];
168         result = mustache.render(template, templatecontext, partialHelper);
169         return result.trim();
170     };
172     /**
173      * Render blocks of javascript and save them in an array.
174      *
175      * @method jsHelper
176      * @private
177      * @param {string} sectionText The text to save as a js block.
178      * @param {function} helper Used to render the block.
179      * @return {string}
180      */
181     var jsHelper = function(sectionText, helper) {
182         this.jsBlocks.push(helper(sectionText, this));
183         return '';
184     };
186     /**
187      * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
188      * into a get_string call.
189      *
190      * @method stringHelper
191      * @private
192      * @param {string} sectionText The text to parse the arguments from.
193      * @param {function} helper Used to render subsections of the text.
194      * @return {string}
195      */
196     var stringHelper = function(sectionText, helper) {
197         var parts = sectionText.split(',');
198         var key = '';
199         var component = '';
200         var param = '';
201         if (parts.length > 0) {
202             key = parts.shift().trim();
203         }
204         if (parts.length > 0) {
205             component = parts.shift().trim();
206         }
207         if (parts.length > 0) {
208             param = parts.join(',').trim();
209         }
211         if (param !== '') {
212             // Allow variable expansion in the param part only.
213             param = helper(param, this);
214         }
215         // Allow json formatted $a arguments.
216         if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
217             param = JSON.parse(param);
218         }
220         var index = requiredStrings.length;
221         requiredStrings.push({key: key, component: component, param: param});
222         return '{{_s' + index + '}}';
223     };
225     /**
226      * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
227      *
228      * @method quoteHelper
229      * @private
230      * @param {string} sectionText The text to parse the arguments from.
231      * @param {function} helper Used to render subsections of the text.
232      * @return {string}
233      */
234     var quoteHelper = function(sectionText, helper) {
235         var content = helper(sectionText.trim(), this);
237         // Escape the {{ and the ".
238         // This involves wrapping {{, and }} in change delimeter tags.
239         content = content
240             .replace('"', '\\"')
241             .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
242             ;
243         return '"' + content + '"';
244     };
246     /**
247      * Add some common helper functions to all context objects passed to templates.
248      * These helpers match exactly the helpers available in php.
249      *
250      * @method addHelpers
251      * @private
252      * @param {Object} context Simple types used as the context for the template.
253      * @param {String} themeName We set this multiple times, because there are async calls.
254      */
255     var addHelpers = function(context, themeName) {
256         currentThemeName = themeName;
257         requiredStrings = [];
258         context.uniqid = uniqid++;
259         context.str = function() {
260           return stringHelper;
261         };
262         context.pix = function() {
263           return pixHelper;
264         };
265         context.js = function() {
266           return jsHelper;
267         };
268         context.quote = function() {
269           return quoteHelper;
270         };
271         context.globals = {config: config};
272         context.jsBlocks = [];
273         context.currentTheme = themeName;
274     };
276     /**
277      * Get all the JS blocks from the last rendered template.
278      *
279      * @method getJS
280      * @private
281      * @param {string[]} strings Replacement strings.
282      * @return {string}
283      */
284     var getJS = function(strings) {
285         var js = '';
286         if (this.jsBlocks.length > 0) {
287             js = this.jsBlocks.join(";\n");
288         }
290         // Re-render to get the final strings.
291         return treatStringsInContent(js, strings);
292     };
294     /**
295      * Treat strings in content.
296      *
297      * The purpose of this method is to replace the placeholders found in a string
298      * with the their respective translated strings.
299      *
300      * Previously we were relying on String.replace() but the complexity increased with
301      * the numbers of strings to replace. Now we manually walk the string and stop at each
302      * placeholder we find, only then we replace it. Most of the time we will
303      * replace all the placeholders in a single run, at times we will need a few
304      * more runs when placeholders are replaced with strings that contain placeholders
305      * themselves.
306      *
307      * @param {String} content The content in which string placeholders are to be found.
308      * @param {Array} strings The strings to replace with.
309      * @return {String} The treated content.
310      */
311     var treatStringsInContent = function(content, strings) {
312         var pattern = /{{_s\d+}}/,
313             treated,
314             index,
315             strIndex,
316             walker,
317             char,
318             strFinal;
320         do {
321             treated = '';
322             index = content.search(pattern);
323             while (index > -1) {
325                 // Copy the part prior to the placeholder to the treated string.
326                 treated += content.substring(0, index);
327                 content = content.substr(index);
328                 strIndex = '';
329                 walker = 4;  // 4 is the length of '{{_s'.
331                 // Walk the characters to manually extract the index of the string from the placeholder.
332                 char = content.substr(walker, 1);
333                 do {
334                     strIndex += char;
335                     walker++;
336                     char = content.substr(walker, 1);
337                 } while (char != '}');
339                 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
340                 strFinal = strings[parseInt(strIndex, 10)];
341                 if (typeof strFinal === 'undefined') {
342                     Log.debug('Could not find string for pattern {{_s' + strIndex + '}}.');
343                     strFinal = '';
344                 }
345                 treated += strFinal;
346                 content = content.substr(6 + strIndex.length);  // 6 is the length of the placeholder without the index: '{{_s}}'.
348                 // Find the next placeholder.
349                 index = content.search(pattern);
350             }
352             // The content becomes the treated part with the rest of the content.
353             content = treated + content;
355             // Check if we need to walk the content again, in case strings contained placeholders.
356             index = content.search(pattern);
358         } while (index > -1);
360         return content;
361     };
363     /**
364      * Render a template and then call the callback with the result.
365      *
366      * @method doRender
367      * @private
368      * @param {string} templateSource The mustache template to render.
369      * @param {Object} context Simple types used as the context for the template.
370      * @param {String} themeName Name of the current theme.
371      * @return {Promise} object
372      */
373     var doRender = function(templateSource, context, themeName) {
374         var deferred = $.Deferred();
376         currentThemeName = themeName;
378         // Make sure we fetch this first.
379         var loadPixTemplate = getTemplate('core/pix_icon', true);
381         loadPixTemplate.done(
382             function() {
383                 addHelpers(context, themeName);
384                 var result = '';
385                 try {
386                     result = mustache.render(templateSource, context, partialHelper);
387                 } catch (ex) {
388                     deferred.reject(ex);
389                 }
391                 if (requiredStrings.length > 0) {
392                     str.get_strings(requiredStrings)
393                     .then(function(strings) {
395                         // Why do we not do another call the render here?
396                         //
397                         // Because that would expose DOS holes. E.g.
398                         // I create an assignment called "{{fish" which
399                         // would get inserted in the template in the first pass
400                         // and cause the template to die on the second pass (unbalanced).
402                         result = treatStringsInContent(result, strings);
403                         deferred.resolve(result, getJS.bind(context)(strings));
404                     })
405                     .fail(deferred.reject);
406                 } else {
407                     deferred.resolve(result.trim(), getJS.bind(context)([]));
408                 }
409             }
410         ).fail(deferred.reject);
411         return deferred.promise();
412     };
414     /**
415      * Execute a block of JS returned from a template.
416      * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
417      *
418      * @method runTemplateJS
419      * @param {string} source - A block of javascript.
420      */
421     var runTemplateJS = function(source) {
422         if (source.trim() !== '') {
423             var newscript = $('<script>').attr('type', 'text/javascript').html(source);
424             $('head').append(newscript);
425         }
426     };
428     /**
429      * Do some DOM replacement and trigger correct events and fire javascript.
430      *
431      * @method domReplace
432      * @private
433      * @param {JQuery} element - Element or selector to replace.
434      * @param {String} newHTML - HTML to insert / replace.
435      * @param {String} newJS - Javascript to run after the insertion.
436      * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
437      */
438     var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
439         var replaceNode = $(element);
440         if (replaceNode.length) {
441             // First create the dom nodes so we have a reference to them.
442             var newNodes = $(newHTML);
443             var yuiNodes = null;
444             // Do the replacement in the page.
445             if (replaceChildNodes) {
446                 // Cleanup any YUI event listeners attached to any of these nodes.
447                 yuiNodes = new Y.NodeList(replaceNode.children().get());
448                 yuiNodes.destroy(true);
450                 // JQuery will cleanup after itself.
451                 replaceNode.empty();
452                 replaceNode.append(newNodes);
453             } else {
454                 // Cleanup any YUI event listeners attached to any of these nodes.
455                 yuiNodes = new Y.NodeList(replaceNode.get());
456                 yuiNodes.destroy(true);
458                 // JQuery will cleanup after itself.
459                 replaceNode.replaceWith(newNodes);
460             }
461             // Run any javascript associated with the new HTML.
462             runTemplateJS(newJS);
463             // Notify all filters about the new content.
464             event.notifyFilterContentUpdated(newNodes);
465         }
466     };
469     return /** @alias module:core/templates */ {
470         // Public variables and functions.
471         /**
472          * Load a template and call doRender on it.
473          *
474          * @method render
475          * @private
476          * @param {string} templateName - should consist of the component and the name of the template like this:
477          *                              core/menu (lib/templates/menu.mustache) or
478          *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
479          * @param {Object} context - Could be array, string or simple value for the context of the template.
480          * @param {string} themeName - Name of the current theme.
481          * @return {Promise} JQuery promise object resolved when the template has been rendered.
482          */
483         render: function(templateName, context, themeName) {
484             var deferred = $.Deferred();
486             if (typeof (themeName) === "undefined") {
487                 // System context by default.
488                 themeName = config.theme;
489             }
491             currentThemeName = themeName;
493             var loadTemplate = getTemplate(templateName, true);
495             loadTemplate.done(
496                 function(templateSource) {
497                     var renderPromise = doRender(templateSource, context, themeName);
499                     renderPromise.done(
500                         function(result, js) {
501                             deferred.resolve(result, js);
502                         }
503                     ).fail(
504                         function(ex) {
505                             deferred.reject(ex);
506                         }
507                     );
508                 }
509             ).fail(
510                 function(ex) {
511                     deferred.reject(ex);
512                 }
513             );
514             return deferred.promise();
515         },
517         /**
518          * Execute a block of JS returned from a template.
519          * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
520          *
521          * @method runTemplateJS
522          * @param {string} source - A block of javascript.
523          */
524         runTemplateJS: runTemplateJS,
526         /**
527          * Replace a node in the page with some new HTML and run the JS.
528          *
529          * @method replaceNodeContents
530          * @param {JQuery} element - Element or selector to replace.
531          * @param {String} newHTML - HTML to insert / replace.
532          * @param {String} newJS - Javascript to run after the insertion.
533          */
534         replaceNodeContents: function(element, newHTML, newJS) {
535             domReplace(element, newHTML, newJS, true);
536         },
538         /**
539          * Insert a node in the page with some new HTML and run the JS.
540          *
541          * @method replaceNode
542          * @param {JQuery} element - Element or selector to replace.
543          * @param {String} newHTML - HTML to insert / replace.
544          * @param {String} newJS - Javascript to run after the insertion.
545          */
546         replaceNode: function(element, newHTML, newJS) {
547             domReplace(element, newHTML, newJS, false);
548         }
549     };
550 });