MDL-68677 core: Correct usage of templaterev for caching templates
[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([
27         'core/mustache',
28         'jquery',
29         'core/ajax',
30         'core/str',
31         'core/notification',
32         'core/url',
33         'core/config',
34         'core/localstorage',
35         'core/icon_system',
36         'core/event',
37         'core/yui',
38         'core/log',
39         'core/truncate',
40         'core/user_date',
41         'core/pending',
42     ],
43     function(mustache, $, ajax, str, notification, coreurl, config, storage, IconSystem, event, Y, Log, Truncate, UserDate,
44         Pending) {
46     // Module variables.
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 */
60     var iconSystem = {};
62     /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
63     var loadTemplateBuffer = [];
65     /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
66     var isLoadingTemplates = false;
68     /** @var {Array} blacklistedNestedHelpers - List of helpers that can't be called within other helpers */
69     var blacklistedNestedHelpers = ['js'];
71     /**
72      * Search the various caches for a template promise for the given search key.
73      * The search key should be in the format <theme>/<component>/<template> e.g. boost/core/modal.
74      *
75      * If the template is found in any of the caches it will populate the other caches with
76      * the same data as well.
77      *
78      * @param {String} searchKey The template search key in the format <theme>/<component>/<template> e.g. boost/core/modal
79      * @return {Object} jQuery promise resolved with the template source
80      */
81     var getTemplatePromiseFromCache = function(searchKey) {
82         // First try the cache of promises.
83         if (searchKey in templatePromises) {
84             return templatePromises[searchKey];
85         }
87         // Check the module cache.
88         if (searchKey in templateCache) {
89             // Add this to the promises cache for future.
90             templatePromises[searchKey] = $.Deferred().resolve(templateCache[searchKey]).promise();
91             return templatePromises[searchKey];
92         }
94         if (M.cfg.templaterev <= 0) {
95             // Template caching is disabled. Do not store in persistent storage.
96             return null;
97         }
99         // Now try local storage.
100         var cached = storage.get('core_template/' + M.cfg.templaterev + ':' + searchKey);
101         if (cached) {
102             // Add this to the module cache for future.
103             templateCache[searchKey] = cached;
104             // Add this to the promises cache for future.
105             templatePromises[searchKey] = $.Deferred().resolve(cached).promise();
106             return templatePromises[searchKey];
107         }
109         return null;
110     };
112     /**
113      * Take all of the templates waiting in the buffer and load them from the server
114      * or from the cache.
115      *
116      * All of the templates that need to be loaded from the server will be batched up
117      * and sent in a single network request.
118      */
119     var processLoadTemplateBuffer = function() {
120         if (!loadTemplateBuffer.length) {
121             return;
122         }
124         if (isLoadingTemplates) {
125             return;
126         }
128         isLoadingTemplates = true;
129         // Grab any templates waiting in the buffer.
130         var templatesToLoad = loadTemplateBuffer.slice();
131         // This will be resolved with the list of promises for the server request.
132         var serverRequestsDeferred = $.Deferred();
133         var requests = [];
134         // Get a list of promises for each of the templates we need to load.
135         var templatePromises = templatesToLoad.map(function(templateData) {
136             var component = templateData.component;
137             var name = templateData.name;
138             var searchKey = templateData.searchKey;
139             var theme = templateData.theme;
140             var templateDeferred = templateData.deferred;
141             var promise = null;
143             // Double check to see if this template happened to have landed in the
144             // cache as a dependency of an earlier template.
145             var cachedPromise = getTemplatePromiseFromCache(searchKey);
146             if (cachedPromise) {
147                 // We've seen this template so immediately resolve the existing promise.
148                 promise = cachedPromise;
149             } else {
150                 // We haven't seen this template yet so we need to request it from
151                 // the server.
152                 requests.push({
153                     methodname: 'core_output_load_template_with_dependencies',
154                     args: {
155                         component: component,
156                         template: name,
157                         themename: theme,
158                         lang: $('html').attr('lang').replace(/-/g, '_')
159                     }
160                 });
161                 // Remember the index in the requests list for this template so that
162                 // we can get the appropriate promise back.
163                 var index = requests.length - 1;
165                 // The server deferred will be resolved with a list of all of the promises
166                 // that were sent in the order that they were added to the requests array.
167                 promise = serverRequestsDeferred.promise()
168                     .then(function(promises) {
169                         // The promise for this template will be the one that matches the index
170                         // for it's entry in the requests array.
171                         //
172                         // Make sure the promise is added to the promises cache for this template
173                         // search key so that we don't request it again.
174                         templatePromises[searchKey] = promises[index].then(function(response) {
175                             var templateSource = null;
177                             // Process all of the template dependencies for this template and add
178                             // them to the caches so that we don't request them again later.
179                             response.templates.forEach(function(data) {
180                                 // Generate the search key for this template in the response so that we
181                                 // can add it to the caches.
182                                 var tempSearchKey = [theme, data.component, data.name].join('/');
183                                 // Cache all of the dependent templates because we'll need them to render
184                                 // the requested template.
185                                 templateCache[tempSearchKey] = data.value;
187                                 if (M.cfg.templaterev > 0) {
188                                     // The template cache is enabled - set the value there.
189                                     storage.set('core_template/' + M.cfg.templaterev + ':' + tempSearchKey, data.value);
190                                 }
192                                 if (data.component == component && data.name == name) {
193                                     // This is the original template that was requested so remember it to return.
194                                     templateSource = data.value;
195                                 }
196                             });
198                             if (response.strings.length) {
199                                 // If we have strings that the template needs then warm the string cache
200                                 // with them now so that we don't need to re-fetch them.
201                                 str.cache_strings(response.strings.map(function(data) {
202                                     return {
203                                         component: data.component,
204                                         key: data.name,
205                                         value: data.value
206                                     };
207                                 }));
208                             }
210                             // Return the original template source that the user requested.
211                             return templateSource;
212                         });
214                         return templatePromises[searchKey];
215                     });
216             }
218             return promise
219                 .then(function(source) {
220                     // When we've successfully loaded the template then resolve the deferred
221                     // in the buffer so that all of the calling code can proceed.
222                     return templateDeferred.resolve(source);
223                 })
224                 .catch(function(error) {
225                     // If there was an error loading the template then reject the deferred
226                     // in the buffer so that all of the calling code can proceed.
227                     templateDeferred.reject(error);
228                     // Rethrow for anyone else listening.
229                     throw error;
230                 });
231         });
233         if (requests.length) {
234             // We have requests to send so resolve the deferred with the promises.
235             serverRequestsDeferred.resolve(ajax.call(requests, true, false, false, 0, M.cfg.templaterev));
236         } else {
237             // Nothing to load so we can resolve our deferred.
238             serverRequestsDeferred.resolve();
239         }
241         // Once we've finished loading all of the templates then recurse to process
242         // any templates that may have been added to the buffer in the time that we
243         // were fetching.
244         $.when.apply(null, templatePromises)
245             .then(function() {
246                 // Remove the templates we've loaded from the buffer.
247                 loadTemplateBuffer.splice(0, templatesToLoad.length);
248                 isLoadingTemplates = false;
249                 processLoadTemplateBuffer();
250                 return;
251             })
252             .catch(function() {
253                 // Remove the templates we've loaded from the buffer.
254                 loadTemplateBuffer.splice(0, templatesToLoad.length);
255                 isLoadingTemplates = false;
256                 processLoadTemplateBuffer();
257             });
258     };
260     /**
261      * Constructor
262      *
263      * Each call to templates.render gets it's own instance of this class.
264      */
265     var Renderer = function() {
266         this.requiredStrings = [];
267         this.requiredJS = [];
268         this.requiredDates = [];
269         this.currentThemeName = '';
270     };
271     // Class variables and functions.
273     /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
274     Renderer.prototype.requiredStrings = null;
276     /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */
277     Renderer.prototype.requiredDates = [];
279     /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
280     Renderer.prototype.requiredJS = null;
282     /** @var {String} themeName for the current render */
283     Renderer.prototype.currentThemeName = '';
285     /**
286      * Load a template.
287      *
288      * @method getTemplate
289      * @private
290      * @param {string} templateName - should consist of the component and the name of the template like this:
291      *                              core/menu (lib/templates/menu.mustache) or
292      *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
293      * @return {Promise} JQuery promise object resolved when the template has been fetched.
294      */
295     Renderer.prototype.getTemplate = function(templateName) {
296         var currentTheme = this.currentThemeName;
297         var searchKey = currentTheme + '/' + templateName;
299         // If we haven't already seen this template then buffer it.
300         var cachedPromise = getTemplatePromiseFromCache(searchKey);
301         if (cachedPromise) {
302             return cachedPromise;
303         }
305         // Check the buffer to see if this template has already been added.
306         var existingBufferRecords = loadTemplateBuffer.filter(function(record) {
307             return record.searchKey == searchKey;
308         });
309         if (existingBufferRecords.length) {
310             // This template is already in the buffer so just return the existing
311             // promise. No need to add it to the buffer again.
312             return existingBufferRecords[0].deferred.promise();
313         }
315         // This is the first time this has been requested so let's add it to the buffer
316         // to be loaded.
317         var parts = templateName.split('/');
318         var component = parts.shift();
319         var name = parts.join('/');
320         var deferred = $.Deferred();
322         // Add this template to the buffer to be loaded.
323         loadTemplateBuffer.push({
324             component: component,
325             name: name,
326             theme: currentTheme,
327             searchKey: searchKey,
328             deferred: deferred
329         });
331         // We know there is at least one thing in the buffer so kick off a processing run.
332         processLoadTemplateBuffer();
333         return deferred.promise();
334     };
336     /**
337      * Prefetch a set of templates without rendering them.
338      *
339      * @param {Array} templateNames The list of templates to fetch
340      * @param {String} currentTheme
341      */
342     Renderer.prototype.prefetchTemplates = function(templateNames, currentTheme) {
343         templateNames.forEach(function(templateName) {
344             var searchKey = currentTheme + '/' + templateName;
346             // If we haven't already seen this template then buffer it.
347             if (getTemplatePromiseFromCache(searchKey)) {
348                 return;
349             }
351             // Check the buffer to see if this template has already been added.
352             var existingBufferRecords = loadTemplateBuffer.filter(function(record) {
353                 return record.searchKey == searchKey;
354             });
356             if (existingBufferRecords.length) {
357                 // This template is already in the buffer so just return the existing promise.
358                 // No need to add it to the buffer again.
359                 return;
360             }
362             // This is the first time this has been requested so let's add it to the buffer to be loaded.
363             var parts = templateName.split('/');
364             var component = parts.shift();
365             var name = parts.join('/');
367             // Add this template to the buffer to be loaded.
368             loadTemplateBuffer.push({
369                 component: component,
370                 name: name,
371                 theme: currentTheme,
372                 searchKey: searchKey,
373                 deferred: $.Deferred(),
374             });
375         });
377         processLoadTemplateBuffer();
378     };
380     /**
381      * Load a partial from the cache or ajax.
382      *
383      * @method partialHelper
384      * @private
385      * @param {string} name The partial name to load.
386      * @return {string}
387      */
388     Renderer.prototype.partialHelper = function(name) {
390         var searchKey = this.currentThemeName + '/' + name;
392         if (!(searchKey in templateCache)) {
393             notification.exception(new Error('Failed to pre-fetch the template: ' + name));
394         }
396         return templateCache[searchKey];
397     };
399     /**
400      * Render a single image icon.
401      *
402      * @method renderIcon
403      * @private
404      * @param {string} key The icon key.
405      * @param {string} component The component name.
406      * @param {string} title The icon title
407      * @return {Promise}
408      */
409     Renderer.prototype.renderIcon = function(key, component, title) {
410         // Preload the module to do the icon rendering based on the theme iconsystem.
411         var modulename = config.iconsystemmodule;
413         // RequireJS does not return a promise.
414         var ready = $.Deferred();
415         require([modulename], function(System) {
416             var system = new System();
417             if (!(system instanceof IconSystem)) {
418                 ready.reject('Invalid icon system specified' + config.iconsystemmodule);
419             } else {
420                 iconSystem = system;
421                 system.init().then(ready.resolve).catch(notification.exception);
422             }
423         });
425         return ready.then(function(iconSystem) {
426             return this.getTemplate(iconSystem.getTemplateName());
427         }.bind(this)).then(function(template) {
428             return iconSystem.renderIcon(key, component, title, template);
429         });
430     };
432     /**
433      * Render image icons.
434      *
435      * @method pixHelper
436      * @private
437      * @param {object} context The mustache context
438      * @param {string} sectionText The text to parse arguments from.
439      * @param {function} helper Used to render the alt attribute of the text.
440      * @return {string}
441      */
442     Renderer.prototype.pixHelper = function(context, sectionText, helper) {
443         var parts = sectionText.split(',');
444         var key = '';
445         var component = '';
446         var text = '';
448         if (parts.length > 0) {
449             key = helper(parts.shift().trim(), context);
450         }
451         if (parts.length > 0) {
452             component = helper(parts.shift().trim(), context);
453         }
454         if (parts.length > 0) {
455             text = helper(parts.join(',').trim(), context);
456         }
458         var templateName = iconSystem.getTemplateName();
460         var searchKey = this.currentThemeName + '/' + templateName;
461         var template = templateCache[searchKey];
463         // The key might have been escaped by the JS Mustache engine which
464         // converts forward slashes to HTML entities. Let us undo that here.
465         key = key.replace(/&#x2F;/gi, '/');
467         return iconSystem.renderIcon(key, component, text, template);
468     };
470     /**
471      * Render blocks of javascript and save them in an array.
472      *
473      * @method jsHelper
474      * @private
475      * @param {object} context The current mustache context.
476      * @param {string} sectionText The text to save as a js block.
477      * @param {function} helper Used to render the block.
478      * @return {string}
479      */
480     Renderer.prototype.jsHelper = function(context, sectionText, helper) {
481         this.requiredJS.push(helper(sectionText, context));
482         return '';
483     };
485     /**
486      * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
487      * into a get_string call.
488      *
489      * @method stringHelper
490      * @private
491      * @param {object} context The current mustache context.
492      * @param {string} sectionText The text to parse the arguments from.
493      * @param {function} helper Used to render subsections of the text.
494      * @return {string}
495      */
496     Renderer.prototype.stringHelper = function(context, sectionText, helper) {
497         var parts = sectionText.split(',');
498         var key = '';
499         var component = '';
500         var param = '';
501         if (parts.length > 0) {
502             key = parts.shift().trim();
503         }
504         if (parts.length > 0) {
505             component = parts.shift().trim();
506         }
507         if (parts.length > 0) {
508             param = parts.join(',').trim();
509         }
511         if (param !== '') {
512             // Allow variable expansion in the param part only.
513             param = helper(param, context);
514         }
515         // Allow json formatted $a arguments.
516         if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
517             param = JSON.parse(param);
518         }
520         var index = this.requiredStrings.length;
521         this.requiredStrings.push({key: key, component: component, param: param});
523         // The placeholder must not use {{}} as those can be misinterpreted by the engine.
524         return '[[_s' + index + ']]';
525     };
527     /**
528      * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
529      *
530      * @method quoteHelper
531      * @private
532      * @param {object} context The current mustache context.
533      * @param {string} sectionText The text to parse the arguments from.
534      * @param {function} helper Used to render subsections of the text.
535      * @return {string}
536      */
537     Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
538         var content = helper(sectionText.trim(), context);
540         // Escape the {{ and the ".
541         // This involves wrapping {{, and }} in change delimeter tags.
542         content = content
543             .replace(/"/g, '\\"')
544             .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
545             .replace(/(\r\n|\r|\n)/g, '&#x0a;')
546             ;
547         return '"' + content + '"';
548     };
550     /**
551      * Shorten text helper to truncate text and append a trailing ellipsis.
552      *
553      * @method shortenTextHelper
554      * @private
555      * @param {object} context The current mustache context.
556      * @param {string} sectionText The text to parse the arguments from.
557      * @param {function} helper Used to render subsections of the text.
558      * @return {string}
559      */
560     Renderer.prototype.shortenTextHelper = function(context, sectionText, helper) {
561         // Non-greedy split on comma to grab section text into the length and
562         // text parts.
563         var regex = /(.*?),(.*)/;
564         var parts = sectionText.match(regex);
565         // The length is the part matched in the first set of parethesis.
566         var length = parts[1].trim();
567         // The length is the part matched in the second set of parethesis.
568         var text = parts[2].trim();
569         var content = helper(text, context);
570         return Truncate.truncate(content, {
571             length: length,
572             words: true,
573             ellipsis: '...'
574         });
575     };
577     /**
578      * User date helper to render user dates from timestamps.
579      *
580      * @method userDateHelper
581      * @private
582      * @param {object} context The current mustache context.
583      * @param {string} sectionText The text to parse the arguments from.
584      * @param {function} helper Used to render subsections of the text.
585      * @return {string}
586      */
587     Renderer.prototype.userDateHelper = function(context, sectionText, helper) {
588         // Non-greedy split on comma to grab the timestamp and format.
589         var regex = /(.*?),(.*)/;
590         var parts = sectionText.match(regex);
591         var timestamp = helper(parts[1].trim(), context);
592         var format = helper(parts[2].trim(), context);
593         var index = this.requiredDates.length;
595         this.requiredDates.push({
596             timestamp: timestamp,
597             format: format
598         });
600         return '[[_t_' + index + ']]';
601     };
603     /**
604      * Return a helper function to be added to the context for rendering the a
605      * template.
606      *
607      * This will parse the provided text before giving it to the helper function
608      * in order to remove any blacklisted nested helpers to prevent one helper
609      * from calling another.
610      *
611      * In particular to prevent the JS helper from being called from within another
612      * helper because it can lead to security issues when the JS portion is user
613      * provided.
614      *
615      * @param  {function} helperFunction The helper function to add
616      * @param  {object} context The template context for the helper function
617      * @return {Function} To be set in the context
618      */
619     Renderer.prototype.addHelperFunction = function(helperFunction, context) {
620         return function() {
621             return function(sectionText, helper) {
622                 // Override the blacklisted helpers in the template context with
623                 // a function that returns an empty string for use when executing
624                 // other helpers. This is to prevent these helpers from being
625                 // executed as part of the rendering of another helper in order to
626                 // prevent any potential security issues.
627                 var originalHelpers = blacklistedNestedHelpers.reduce(function(carry, name) {
628                     if (context.hasOwnProperty(name)) {
629                         carry[name] = context[name];
630                     }
632                     return carry;
633                 }, {});
635                 blacklistedNestedHelpers.forEach(function(helperName) {
636                     context[helperName] = function() {
637                         return '';
638                     };
639                 });
641                 // Execute the helper with the modified context that doesn't include
642                 // the blacklisted nested helpers. This prevents the blacklisted
643                 // helpers from being called from within other helpers.
644                 var result = helperFunction.apply(this, [context, sectionText, helper]);
646                 // Restore the original helper implementation in the context so that
647                 // any further rendering has access to them again.
648                 for (var name in originalHelpers) {
649                     context[name] = originalHelpers[name];
650                 }
652                 return result;
653             }.bind(this);
654         }.bind(this);
655     };
657     /**
658      * Add some common helper functions to all context objects passed to templates.
659      * These helpers match exactly the helpers available in php.
660      *
661      * @method addHelpers
662      * @private
663      * @param {Object} context Simple types used as the context for the template.
664      * @param {String} themeName We set this multiple times, because there are async calls.
665      */
666     Renderer.prototype.addHelpers = function(context, themeName) {
667         this.currentThemeName = themeName;
668         this.requiredStrings = [];
669         this.requiredJS = [];
670         context.uniqid = (uniqInstances++);
671         context.str = this.addHelperFunction(this.stringHelper, context);
672         context.pix = this.addHelperFunction(this.pixHelper, context);
673         context.js = this.addHelperFunction(this.jsHelper, context);
674         context.quote = this.addHelperFunction(this.quoteHelper, context);
675         context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);
676         context.userdate = this.addHelperFunction(this.userDateHelper, context);
677         context.globals = {config: config};
678         context.currentTheme = themeName;
679     };
681     /**
682      * Get all the JS blocks from the last rendered template.
683      *
684      * @method getJS
685      * @private
686      * @return {string}
687      */
688     Renderer.prototype.getJS = function() {
689         var js = '';
690         if (this.requiredJS.length > 0) {
691             js = this.requiredJS.join(";\n");
692         }
694         return js;
695     };
697     /**
698      * Treat strings in content.
699      *
700      * The purpose of this method is to replace the placeholders found in a string
701      * with the their respective translated strings.
702      *
703      * Previously we were relying on String.replace() but the complexity increased with
704      * the numbers of strings to replace. Now we manually walk the string and stop at each
705      * placeholder we find, only then we replace it. Most of the time we will
706      * replace all the placeholders in a single run, at times we will need a few
707      * more runs when placeholders are replaced with strings that contain placeholders
708      * themselves.
709      *
710      * @param {String} content The content in which string placeholders are to be found.
711      * @param {Array} strings The strings to replace with.
712      * @return {String} The treated content.
713      */
714     Renderer.prototype.treatStringsInContent = function(content, strings) {
715         var pattern = /\[\[_s\d+\]\]/,
716             treated,
717             index,
718             strIndex,
719             walker,
720             char,
721             strFinal;
723         do {
724             treated = '';
725             index = content.search(pattern);
726             while (index > -1) {
728                 // Copy the part prior to the placeholder to the treated string.
729                 treated += content.substring(0, index);
730                 content = content.substr(index);
731                 strIndex = '';
732                 walker = 4; // 4 is the length of '[[_s'.
734                 // Walk the characters to manually extract the index of the string from the placeholder.
735                 char = content.substr(walker, 1);
736                 do {
737                     strIndex += char;
738                     walker++;
739                     char = content.substr(walker, 1);
740                 } while (char != ']');
742                 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
743                 strFinal = strings[parseInt(strIndex, 10)];
744                 if (typeof strFinal === 'undefined') {
745                     Log.debug('Could not find string for pattern [[_s' + strIndex + ']].');
746                     strFinal = '';
747                 }
748                 treated += strFinal;
749                 content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '[[_s]]'.
751                 // Find the next placeholder.
752                 index = content.search(pattern);
753             }
755             // The content becomes the treated part with the rest of the content.
756             content = treated + content;
758             // Check if we need to walk the content again, in case strings contained placeholders.
759             index = content.search(pattern);
761         } while (index > -1);
763         return content;
764     };
766     /**
767      * Treat strings in content.
768      *
769      * The purpose of this method is to replace the date placeholders found in the
770      * content with the their respective translated dates.
771      *
772      * @param {String} content The content in which string placeholders are to be found.
773      * @param {Array} dates The dates to replace with.
774      * @return {String} The treated content.
775      */
776     Renderer.prototype.treatDatesInContent = function(content, dates) {
777         dates.forEach(function(date, index) {
778             var key = '\\[\\[_t_' + index + '\\]\\]';
779             var re = new RegExp(key, 'g');
780             content = content.replace(re, date);
781         });
783         return content;
784     };
786     /**
787      * Render a template and then call the callback with the result.
788      *
789      * @method doRender
790      * @private
791      * @param {string} templateSource The mustache template to render.
792      * @param {Object} context Simple types used as the context for the template.
793      * @param {String} themeName Name of the current theme.
794      * @return {Promise} object
795      */
796     Renderer.prototype.doRender = function(templateSource, context, themeName) {
797         this.currentThemeName = themeName;
798         var iconTemplate = iconSystem.getTemplateName();
800         var pendingPromise = new Pending('core/templates:doRender');
801         return this.getTemplate(iconTemplate).then(function() {
802             this.addHelpers(context, themeName);
803             var result = mustache.render(templateSource, context, this.partialHelper.bind(this));
804             return $.Deferred().resolve(result.trim(), this.getJS()).promise();
805         }.bind(this))
806         .then(function(html, js) {
807             if (this.requiredStrings.length > 0) {
808                 return str.get_strings(this.requiredStrings).then(function(strings) {
810                     // Make sure string substitutions are done for the userdate
811                     // values as well.
812                     this.requiredDates = this.requiredDates.map(function(date) {
813                         return {
814                             timestamp: this.treatStringsInContent(date.timestamp, strings),
815                             format: this.treatStringsInContent(date.format, strings)
816                         };
817                     }.bind(this));
819                     // Why do we not do another call the render here?
820                     //
821                     // Because that would expose DOS holes. E.g.
822                     // I create an assignment called "{{fish" which
823                     // would get inserted in the template in the first pass
824                     // and cause the template to die on the second pass (unbalanced).
825                     html = this.treatStringsInContent(html, strings);
826                     js = this.treatStringsInContent(js, strings);
827                     return $.Deferred().resolve(html, js).promise();
828                 }.bind(this));
829             }
831             return $.Deferred().resolve(html, js).promise();
832         }.bind(this))
833         .then(function(html, js) {
834             // This has to happen after the strings replacement because you can
835             // use the string helper in content for the user date helper.
836             if (this.requiredDates.length > 0) {
837                 return UserDate.get(this.requiredDates).then(function(dates) {
838                     html = this.treatDatesInContent(html, dates);
839                     js = this.treatDatesInContent(js, dates);
840                     return $.Deferred().resolve(html, js).promise();
841                 }.bind(this));
842             }
844             return $.Deferred().resolve(html, js).promise();
845         }.bind(this))
846         .then(function(html, js) {
847             pendingPromise.resolve();
848             return $.Deferred().resolve(html, js).promise();
849         });
850     };
852     /**
853      * Execute a block of JS returned from a template.
854      * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
855      *
856      * @method runTemplateJS
857      * @param {string} source - A block of javascript.
858      */
859     var runTemplateJS = function(source) {
860         if (source.trim() !== '') {
861             var newscript = $('<script>').attr('type', 'text/javascript').html(source);
862             $('head').append(newscript);
863         }
864     };
866     /**
867      * Do some DOM replacement and trigger correct events and fire javascript.
868      *
869      * @method domReplace
870      * @private
871      * @param {JQuery} element - Element or selector to replace.
872      * @param {String} newHTML - HTML to insert / replace.
873      * @param {String} newJS - Javascript to run after the insertion.
874      * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
875      */
876     var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
877         var replaceNode = $(element);
878         if (replaceNode.length) {
879             // First create the dom nodes so we have a reference to them.
880             var newNodes = $(newHTML);
881             var yuiNodes = null;
882             // Do the replacement in the page.
883             if (replaceChildNodes) {
884                 // Cleanup any YUI event listeners attached to any of these nodes.
885                 yuiNodes = new Y.NodeList(replaceNode.children().get());
886                 yuiNodes.destroy(true);
888                 // JQuery will cleanup after itself.
889                 replaceNode.empty();
890                 replaceNode.append(newNodes);
891             } else {
892                 // Cleanup any YUI event listeners attached to any of these nodes.
893                 yuiNodes = new Y.NodeList(replaceNode.get());
894                 yuiNodes.destroy(true);
896                 // JQuery will cleanup after itself.
897                 replaceNode.replaceWith(newNodes);
898             }
899             // Run any javascript associated with the new HTML.
900             runTemplateJS(newJS);
901             // Notify all filters about the new content.
902             event.notifyFilterContentUpdated(newNodes);
903         }
904     };
906     /**
907      * Scan a template source for partial tags and return a list of the found partials.
908      *
909      * @method scanForPartials
910      * @private
911      * @param {string} templateSource - source template to scan.
912      * @return {Array} List of partials.
913      */
914     Renderer.prototype.scanForPartials = function(templateSource) {
915         var tokens = mustache.parse(templateSource),
916             partials = [];
918         var findPartial = function(tokens, partials) {
919             var i, token;
920             for (i = 0; i < tokens.length; i++) {
921                 token = tokens[i];
922                 if (token[0] == '>' || token[0] == '<') {
923                     partials.push(token[1]);
924                 }
925                 if (token.length > 4) {
926                     findPartial(token[4], partials);
927                 }
928             }
929         };
931         findPartial(tokens, partials);
933         return partials;
934     };
936     /**
937      * Load a template and scan it for partials. Recursively fetch the partials.
938      *
939      * @method cachePartials
940      * @private
941      * @param {string} templateName - should consist of the component and the name of the template like this:
942      *                              core/menu (lib/templates/menu.mustache) or
943      *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
944      * @param {Array} parentage - A list of requested partials in this render chain.
945      * @return {Promise} JQuery promise object resolved when all partials are in the cache.
946      */
947     Renderer.prototype.cachePartials = function(templateName, parentage) {
948         var searchKey = this.currentThemeName + '/' + templateName;
950         if (searchKey in cachePartialPromises) {
951             return cachePartialPromises[searchKey];
952         }
954         // This promise will not be resolved until all child partials are also resolved and ready.
955         // We create it here to allow us to check for recursive inclusion of templates.
956         // Keep track of the requested partials in this chain.
957         parentage = parentage || [searchKey];
959         cachePartialPromises[searchKey] = $.Deferred();
961         this.getTemplate(templateName)
962         .then(function(templateSource) {
963             var partials = this.scanForPartials(templateSource);
964             var uniquePartials = partials.filter(function(partialName) {
965                 // Check for recursion.
967                 if (parentage.indexOf(this.currentThemeName + '/' + partialName) >= 0) {
968                     // Ignore templates which include a parent template already requested in the current chain.
969                     return false;
970                 }
972                 // Ignore templates that include themselves.
973                 return partialName != templateName;
974             }.bind(this));
976             // Fetch any partial which has not already been fetched.
977             var fetchThemAll = uniquePartials.map(function(partialName) {
978                 parentage.push(this.currentThemeName + '/' + partialName);
979                 return this.cachePartials(partialName, parentage);
980             }.bind(this));
982             // Resolve the templateName promise when all of the children are resolved.
983             return $.when.apply($, fetchThemAll)
984             .then(function() {
985                 return cachePartialPromises[searchKey].resolve(templateSource);
986             });
987         }.bind(this))
988         .catch(cachePartialPromises[searchKey].reject);
990         return cachePartialPromises[searchKey];
991     };
993     /**
994      * Load a template and call doRender on it.
995      *
996      * @method render
997      * @private
998      * @param {string} templateName - should consist of the component and the name of the template like this:
999      *                              core/menu (lib/templates/menu.mustache) or
1000      *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
1001      * @param {Object} context - Could be array, string or simple value for the context of the template.
1002      * @param {string} themeName - Name of the current theme.
1003      * @return {Promise} JQuery promise object resolved when the template has been rendered.
1004      */
1005     Renderer.prototype.render = function(templateName, context, themeName) {
1006         if (typeof (themeName) === "undefined") {
1007             // System context by default.
1008             themeName = config.theme;
1009         }
1011         this.currentThemeName = themeName;
1013         // Preload the module to do the icon rendering based on the theme iconsystem.
1014         var modulename = config.iconsystemmodule;
1016         var ready = $.Deferred();
1017         require([modulename], function(System) {
1018             var system = new System();
1019             if (!(system instanceof IconSystem)) {
1020                 ready.reject('Invalid icon system specified' + config.iconsystem);
1021             } else {
1022                 iconSystem = system;
1023                 system.init().then(ready.resolve).catch(notification.exception);
1024             }
1025         });
1027         return ready.then(function() {
1028                 return this.cachePartials(templateName);
1029             }.bind(this)).then(function(templateSource) {
1030                 return this.doRender(templateSource, context, themeName);
1031             }.bind(this));
1032     };
1034     /**
1035      * Prepend some HTML to a node and trigger events and fire javascript.
1036      *
1037      * @method domPrepend
1038      * @private
1039      * @param {jQuery|String} element - Element or selector to prepend HTML to
1040      * @param {String} html - HTML to prepend
1041      * @param {String} js - Javascript to run after we prepend the html
1042      */
1043     var domPrepend = function(element, html, js) {
1044         var node = $(element);
1045         if (node.length) {
1046             // Prepend the html.
1047             node.prepend(html);
1048             // Run any javascript associated with the new HTML.
1049             runTemplateJS(js);
1050             // Notify all filters about the new content.
1051             event.notifyFilterContentUpdated(node);
1052         }
1053     };
1055     /**
1056      * Append some HTML to a node and trigger events and fire javascript.
1057      *
1058      * @method domAppend
1059      * @private
1060      * @param {jQuery|String} element - Element or selector to append HTML to
1061      * @param {String} html - HTML to append
1062      * @param {String} js - Javascript to run after we append the html
1063      */
1064     var domAppend = function(element, html, js) {
1065         var node = $(element);
1066         if (node.length) {
1067             // Append the html.
1068             node.append(html);
1069             // Run any javascript associated with the new HTML.
1070             runTemplateJS(js);
1071             // Notify all filters about the new content.
1072             event.notifyFilterContentUpdated(node);
1073         }
1074     };
1076     return /** @alias module:core/templates */ {
1077         // Public variables and functions.
1078         /**
1079          * Every call to render creates a new instance of the class and calls render on it. This
1080          * means each render call has it's own class variables.
1081          *
1082          * @method render
1083          * @private
1084          * @param {string} templateName - should consist of the component and the name of the template like this:
1085          *                              core/menu (lib/templates/menu.mustache) or
1086          *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
1087          * @param {Object} context - Could be array, string or simple value for the context of the template.
1088          * @param {string} themeName - Name of the current theme.
1089          * @return {Promise} JQuery promise object resolved when the template has been rendered.
1090          */
1091         render: function(templateName, context, themeName) {
1092             var renderer = new Renderer();
1093             return renderer.render(templateName, context, themeName);
1094         },
1096         /**
1097          * Prefetch a set of templates without rendering them.
1098          *
1099          * @method getTemplate
1100          * @param {Array} templateNames The list of templates to fetch
1101          * @param {String} themeName
1102          * @returns {Promise}
1103          */
1104         prefetchTemplates: function(templateNames, themeName) {
1105             var renderer = new Renderer();
1107             if (typeof themeName === "undefined") {
1108                 // System context by default.
1109                 themeName = config.theme;
1110             }
1112             return renderer.prefetchTemplates(templateNames, themeName);
1113         },
1115         /**
1116          * Every call to render creates a new instance of the class and calls render on it. This
1117          * means each render call has it's own class variables.
1118          *
1119          * This alernate to the standard .render() function returns the html and js in a single object suitable for a
1120          * native Promise.
1121          *
1122          * @method renderForPromise
1123          * @private
1124          * @param {string} templateName - should consist of the component and the name of the template like this:
1125          *                              core/menu (lib/templates/menu.mustache) or
1126          *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
1127          * @param {Object} context - Could be array, string or simple value for the context of the template.
1128          * @param {string} themeName - Name of the current theme.
1129          * @return {Promise} JQuery promise object resolved when the template has been rendered.
1130          */
1131         renderForPromise: function(templateName, context, themeName) {
1132             var renderer = new Renderer();
1133             return renderer.render(templateName, context, themeName)
1134             .then(function(html, js) {
1135                 return {
1136                     html: html,
1137                     js: js,
1138                 };
1139             });
1140         },
1142         /**
1143          * Every call to renderIcon creates a new instance of the class and calls renderIcon on it. This
1144          * means each render call has it's own class variables.
1145          *
1146          * @method renderIcon
1147          * @public
1148          * @param {string} key - Icon key.
1149          * @param {string} component - Icon component
1150          * @param {string} title - Icon title
1151          * @return {Promise} JQuery promise object resolved when the pix has been rendered.
1152          */
1153         renderPix: function(key, component, title) {
1154             var renderer = new Renderer();
1155             return renderer.renderIcon(key, component, title);
1156         },
1158         /**
1159          * Execute a block of JS returned from a template.
1160          * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
1161          *
1162          * @method runTemplateJS
1163          * @param {string} source - A block of javascript.
1164          */
1165         runTemplateJS: runTemplateJS,
1167         /**
1168          * Replace a node in the page with some new HTML and run the JS.
1169          *
1170          * @method replaceNodeContents
1171          * @param {JQuery} element - Element or selector to replace.
1172          * @param {String} newHTML - HTML to insert / replace.
1173          * @param {String} newJS - Javascript to run after the insertion.
1174          */
1175         replaceNodeContents: function(element, newHTML, newJS) {
1176             domReplace(element, newHTML, newJS, true);
1177         },
1179         /**
1180          * Insert a node in the page with some new HTML and run the JS.
1181          *
1182          * @method replaceNode
1183          * @param {JQuery} element - Element or selector to replace.
1184          * @param {String} newHTML - HTML to insert / replace.
1185          * @param {String} newJS - Javascript to run after the insertion.
1186          */
1187         replaceNode: function(element, newHTML, newJS) {
1188             domReplace(element, newHTML, newJS, false);
1189         },
1191         /**
1192          * Prepend some HTML to a node and trigger events and fire javascript.
1193          *
1194          * @method prependNodeContents
1195          * @param {jQuery|String} element - Element or selector to prepend HTML to
1196          * @param {String} html - HTML to prepend
1197          * @param {String} js - Javascript to run after we prepend the html
1198          */
1199         prependNodeContents: function(element, html, js) {
1200             domPrepend(element, html, js);
1201         },
1203         /**
1204          * Append some HTML to a node and trigger events and fire javascript.
1205          *
1206          * @method appendNodeContents
1207          * @param {jQuery|String} element - Element or selector to append HTML to
1208          * @param {String} html - HTML to append
1209          * @param {String} js - Javascript to run after we append the html
1210          */
1211         appendNodeContents: function(element, html, js) {
1212             domAppend(element, html, js);
1213         },
1214     };
1215 });