MDL-56341 mustache: JS engines handles strings wrapped in quote
[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     // Module variables.
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 = {};
47     /**
48      * Constructor
49      *
50      * Each call to templates.render gets it's own instance of this class.
51      */
52     var Renderer = function() {
53         this.requiredStrings = [];
54         this.requiredJS = [];
55         this.currentThemeName = '';
56     };
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 = '';
68     /**
69      * Load a template from the cache or local storage or ajax request.
70      *
71      * @method getTemplate
72      * @private
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.
78      */
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();
91         }
93         // Now try local storage.
94         var cached = storage.get('core_template/' + searchKey);
96         if (cached) {
97             deferred.resolve(cached);
98             templateCache[searchKey] = cached;
99             return deferred.promise();
100         }
102         // Oh well - load via ajax.
103         var promises = ajax.call([{
104             methodname: 'core_output_load_template',
105             args: {
106                 component: component,
107                 template: name,
108                 themename: this.currentThemeName
109             }
110         }], async, false);
112         promises[0].done(
113             function(templateSource) {
114                 storage.set('core_template/' + searchKey, templateSource);
115                 templateCache[searchKey] = templateSource;
116                 deferred.resolve(templateSource);
117             }
118         ).fail(
119             function(ex) {
120                 deferred.reject(ex);
121             }
122         );
123         return deferred.promise();
124     };
126     /**
127      * Load a partial from the cache or ajax.
128      *
129      * @method partialHelper
130      * @private
131      * @param {string} name The partial name to load.
132      * @return {string}
133      */
134     Renderer.prototype.partialHelper = function(name) {
135         var template = '';
137         this.getTemplate(name, false).done(
138             function(source) {
139                 template = source;
140             }
141         ).fail(notification.exception);
143         return template;
144     };
146     /**
147      * Render image icons.
148      *
149      * @method pixHelper
150      * @private
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.
154      * @return {string}
155      */
156     Renderer.prototype.pixHelper = function(context, sectionText, helper) {
157         var parts = sectionText.split(',');
158         var key = '';
159         var component = '';
160         var text = '';
161         var result;
163         if (parts.length > 0) {
164             key = parts.shift().trim();
165         }
166         if (parts.length > 0) {
167             component = parts.shift().trim();
168         }
169         if (parts.length > 0) {
170             text = parts.join(',').trim();
171         }
172         var url = coreurl.imageUrl(key, component);
174         var templatecontext = {
175             attributes: [
176                 {name: 'src', value: url},
177                 {name: 'alt', value: helper(text)},
178                 {name: 'title', value: helper(text)},
179                 {name: 'class', value: 'smallicon'}
180             ]
181         };
182         // We forced loading of this early, so it will be in the cache.
183         var template = templateCache[this.currentThemeName + '/core/pix_icon'];
184         result = mustache.render(template, templatecontext, this.partialHelper.bind(this));
185         return result.trim();
186     };
188     /**
189      * Render blocks of javascript and save them in an array.
190      *
191      * @method jsHelper
192      * @private
193      * @param {object} context The current mustache context.
194      * @param {string} sectionText The text to save as a js block.
195      * @param {function} helper Used to render the block.
196      * @return {string}
197      */
198     Renderer.prototype.jsHelper = function(context, sectionText, helper) {
199         this.requiredJS.push(helper(sectionText, context));
200         return '';
201     };
203     /**
204      * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
205      * into a get_string call.
206      *
207      * @method stringHelper
208      * @private
209      * @param {object} context The current mustache context.
210      * @param {string} sectionText The text to parse the arguments from.
211      * @param {function} helper Used to render subsections of the text.
212      * @return {string}
213      */
214     Renderer.prototype.stringHelper = function(context, sectionText, helper) {
215         var parts = sectionText.split(',');
216         var key = '';
217         var component = '';
218         var param = '';
219         if (parts.length > 0) {
220             key = parts.shift().trim();
221         }
222         if (parts.length > 0) {
223             component = parts.shift().trim();
224         }
225         if (parts.length > 0) {
226             param = parts.join(',').trim();
227         }
229         if (param !== '') {
230             // Allow variable expansion in the param part only.
231             param = helper(param, context);
232         }
233         // Allow json formatted $a arguments.
234         if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
235             param = JSON.parse(param);
236         }
238         var index = this.requiredStrings.length;
239         this.requiredStrings.push({key: key, component: component, param: param});
241         // The placeholder must not use {{}} as those can be misinterpreted by the engine.
242         return '[[_s' + index + ']]';
243     };
245     /**
246      * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
247      *
248      * @method quoteHelper
249      * @private
250      * @param {object} context The current mustache context.
251      * @param {string} sectionText The text to parse the arguments from.
252      * @param {function} helper Used to render subsections of the text.
253      * @return {string}
254      */
255     Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
256         var content = helper(sectionText.trim(), context);
258         // Escape the {{ and the ".
259         // This involves wrapping {{, and }} in change delimeter tags.
260         content = content
261             .replace('"', '\\"')
262             .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
263             ;
264         return '"' + content + '"';
265     };
267     /**
268      * Add some common helper functions to all context objects passed to templates.
269      * These helpers match exactly the helpers available in php.
270      *
271      * @method addHelpers
272      * @private
273      * @param {Object} context Simple types used as the context for the template.
274      * @param {String} themeName We set this multiple times, because there are async calls.
275      */
276     Renderer.prototype.addHelpers = function(context, themeName) {
277         this.currentThemeName = themeName;
278         this.requiredStrings = [];
279         this.requiredJS = [];
280         context.uniqid = (uniqInstances++);
281         context.str = function() {
282           return this.stringHelper.bind(this, context);
283         }.bind(this);
284         context.pix = function() {
285           return this.pixHelper.bind(this, context);
286         }.bind(this);
287         context.js = function() {
288           return this.jsHelper.bind(this, context);
289         }.bind(this);
290         context.quote = function() {
291           return this.quoteHelper.bind(this, context);
292         }.bind(this);
293         context.globals = {config: config};
294         context.currentTheme = themeName;
295     };
297     /**
298      * Get all the JS blocks from the last rendered template.
299      *
300      * @method getJS
301      * @private
302      * @param {string[]} strings Replacement strings.
303      * @return {string}
304      */
305     Renderer.prototype.getJS = function(strings) {
306         var js = '';
307         if (this.requiredJS.length > 0) {
308             js = this.requiredJS.join(";\n");
309         }
311         // Re-render to get the final strings.
312         return this.treatStringsInContent(js, strings);
313     };
315     /**
316      * Treat strings in content.
317      *
318      * The purpose of this method is to replace the placeholders found in a string
319      * with the their respective translated strings.
320      *
321      * Previously we were relying on String.replace() but the complexity increased with
322      * the numbers of strings to replace. Now we manually walk the string and stop at each
323      * placeholder we find, only then we replace it. Most of the time we will
324      * replace all the placeholders in a single run, at times we will need a few
325      * more runs when placeholders are replaced with strings that contain placeholders
326      * themselves.
327      *
328      * @param {String} content The content in which string placeholders are to be found.
329      * @param {Array} strings The strings to replace with.
330      * @return {String} The treated content.
331      */
332     Renderer.prototype.treatStringsInContent = function(content, strings) {
333         var pattern = /\[\[_s\d+\]\]/,
334             treated,
335             index,
336             strIndex,
337             walker,
338             char,
339             strFinal;
341         do {
342             treated = '';
343             index = content.search(pattern);
344             while (index > -1) {
346                 // Copy the part prior to the placeholder to the treated string.
347                 treated += content.substring(0, index);
348                 content = content.substr(index);
349                 strIndex = '';
350                 walker = 4;  // 4 is the length of '[[_s'.
352                 // Walk the characters to manually extract the index of the string from the placeholder.
353                 char = content.substr(walker, 1);
354                 do {
355                     strIndex += char;
356                     walker++;
357                     char = content.substr(walker, 1);
358                 } while (char != ']');
360                 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
361                 strFinal = strings[parseInt(strIndex, 10)];
362                 if (typeof strFinal === 'undefined') {
363                     Log.debug('Could not find string for pattern [[_s' + strIndex + ']].');
364                     strFinal = '';
365                 }
366                 treated += strFinal;
367                 content = content.substr(6 + strIndex.length);  // 6 is the length of the placeholder without the index: '[[_s]]'.
369                 // Find the next placeholder.
370                 index = content.search(pattern);
371             }
373             // The content becomes the treated part with the rest of the content.
374             content = treated + content;
376             // Check if we need to walk the content again, in case strings contained placeholders.
377             index = content.search(pattern);
379         } while (index > -1);
381         return content;
382     };
384     /**
385      * Render a template and then call the callback with the result.
386      *
387      * @method doRender
388      * @private
389      * @param {string} templateSource The mustache template to render.
390      * @param {Object} context Simple types used as the context for the template.
391      * @param {String} themeName Name of the current theme.
392      * @return {Promise} object
393      */
394     Renderer.prototype.doRender = function(templateSource, context, themeName) {
395         var deferred = $.Deferred();
397         this.currentThemeName = themeName;
399         // Make sure we fetch this first.
400         var loadPixTemplate = this.getTemplate('core/pix_icon', true);
402         loadPixTemplate.done(
403             function() {
404                 this.addHelpers(context, themeName);
405                 var result = '';
406                 try {
407                     result = mustache.render(templateSource, context, this.partialHelper.bind(this));
408                 } catch (ex) {
409                     deferred.reject(ex);
410                 }
412                 if (this.requiredStrings.length > 0) {
413                     str.get_strings(this.requiredStrings)
414                     .then(function(strings) {
416                         // Why do we not do another call the render here?
417                         //
418                         // Because that would expose DOS holes. E.g.
419                         // I create an assignment called "{{fish" which
420                         // would get inserted in the template in the first pass
421                         // and cause the template to die on the second pass (unbalanced).
423                         result = this.treatStringsInContent(result, strings);
424                         deferred.resolve(result, this.getJS(strings));
425                     }.bind(this))
426                     .fail(deferred.reject);
427                 } else {
428                     deferred.resolve(result.trim(), this.getJS([]));
429                 }
430             }.bind(this)
431         ).fail(deferred.reject);
432         return deferred.promise();
433     };
435     /**
436      * Execute a block of JS returned from a template.
437      * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
438      *
439      * @method runTemplateJS
440      * @param {string} source - A block of javascript.
441      */
442     var runTemplateJS = function(source) {
443         if (source.trim() !== '') {
444             var newscript = $('<script>').attr('type', 'text/javascript').html(source);
445             $('head').append(newscript);
446         }
447     };
449     /**
450      * Do some DOM replacement and trigger correct events and fire javascript.
451      *
452      * @method domReplace
453      * @private
454      * @param {JQuery} element - Element or selector to replace.
455      * @param {String} newHTML - HTML to insert / replace.
456      * @param {String} newJS - Javascript to run after the insertion.
457      * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
458      */
459     var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
460         var replaceNode = $(element);
461         if (replaceNode.length) {
462             // First create the dom nodes so we have a reference to them.
463             var newNodes = $(newHTML);
464             var yuiNodes = null;
465             // Do the replacement in the page.
466             if (replaceChildNodes) {
467                 // Cleanup any YUI event listeners attached to any of these nodes.
468                 yuiNodes = new Y.NodeList(replaceNode.children().get());
469                 yuiNodes.destroy(true);
471                 // JQuery will cleanup after itself.
472                 replaceNode.empty();
473                 replaceNode.append(newNodes);
474             } else {
475                 // Cleanup any YUI event listeners attached to any of these nodes.
476                 yuiNodes = new Y.NodeList(replaceNode.get());
477                 yuiNodes.destroy(true);
479                 // JQuery will cleanup after itself.
480                 replaceNode.replaceWith(newNodes);
481             }
482             // Run any javascript associated with the new HTML.
483             runTemplateJS(newJS);
484             // Notify all filters about the new content.
485             event.notifyFilterContentUpdated(newNodes);
486         }
487     };
489     /**
490      * Load a template and call doRender on it.
491      *
492      * @method render
493      * @private
494      * @param {string} templateName - should consist of the component and the name of the template like this:
495      *                              core/menu (lib/templates/menu.mustache) or
496      *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
497      * @param {Object} context - Could be array, string or simple value for the context of the template.
498      * @param {string} themeName - Name of the current theme.
499      * @return {Promise} JQuery promise object resolved when the template has been rendered.
500      */
501     Renderer.prototype.render = function(templateName, context, themeName) {
502         var deferred = $.Deferred();
504         if (typeof (themeName) === "undefined") {
505             // System context by default.
506             themeName = config.theme;
507         }
509         this.currentThemeName = themeName;
511         var loadTemplate = this.getTemplate(templateName, true);
513         loadTemplate.done(
514             function(templateSource) {
515                 var renderPromise = this.doRender(templateSource, context, themeName);
517                 renderPromise.done(
518                     function(result, js) {
519                         deferred.resolve(result, js);
520                     }
521                 ).fail(
522                     function(ex) {
523                         deferred.reject(ex);
524                     }
525                 );
526             }.bind(this)
527         ).fail(
528             function(ex) {
529                 deferred.reject(ex);
530             }
531         );
532         return deferred.promise();
533     };
536     return /** @alias module:core/templates */ {
537         // Public variables and functions.
538         /**
539          * Every call to render creates a new instance of the class and calls render on it. This
540          * means each render call has it's own class variables.
541          *
542          * @method render
543          * @private
544          * @param {string} templateName - should consist of the component and the name of the template like this:
545          *                              core/menu (lib/templates/menu.mustache) or
546          *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
547          * @param {Object} context - Could be array, string or simple value for the context of the template.
548          * @param {string} themeName - Name of the current theme.
549          * @return {Promise} JQuery promise object resolved when the template has been rendered.
550          */
551         render: function(templateName, context, themeName) {
552             var renderer = new Renderer();
553             return renderer.render(templateName, context, themeName);
554         },
556         /**
557          * Execute a block of JS returned from a template.
558          * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
559          *
560          * @method runTemplateJS
561          * @param {string} source - A block of javascript.
562          */
563         runTemplateJS: runTemplateJS,
565         /**
566          * Replace a node in the page with some new HTML and run the JS.
567          *
568          * @method replaceNodeContents
569          * @param {JQuery} element - Element or selector to replace.
570          * @param {String} newHTML - HTML to insert / replace.
571          * @param {String} newJS - Javascript to run after the insertion.
572          */
573         replaceNodeContents: function(element, newHTML, newJS) {
574             domReplace(element, newHTML, newJS, true);
575         },
577         /**
578          * Insert a node in the page with some new HTML and run the JS.
579          *
580          * @method replaceNode
581          * @param {JQuery} element - Element or selector to replace.
582          * @param {String} newHTML - HTML to insert / replace.
583          * @param {String} newJS - Javascript to run after the insertion.
584          */
585         replaceNode: function(element, newHTML, newJS) {
586             domReplace(element, newHTML, newJS, false);
587         }
588     };
589 });