MDL-68524 js: Add prefetch module
[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         // Do not cache anything if templaterev is not valid.
83         if (M.cfg.templaterev <= 0) {
84             return null;
85         }
87         // First try the cache of promises.
88         if (searchKey in templatePromises) {
89             return templatePromises[searchKey];
90         }
92         // Check the module cache.
93         if (searchKey in templateCache) {
94             // Add this to the promises cache for future.
95             templatePromises[searchKey] = $.Deferred().resolve(templateCache[searchKey]).promise();
96             return templatePromises[searchKey];
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;
186                                 storage.set('core_template/' + M.cfg.templaterev + ':' + tempSearchKey, data.value);
188                                 if (data.component == component && data.name == name) {
189                                     // This is the original template that was requested so remember it to return.
190                                     templateSource = data.value;
191                                 }
192                             });
194                             if (response.strings.length) {
195                                 // If we have strings that the template needs then warm the string cache
196                                 // with them now so that we don't need to re-fetch them.
197                                 str.cache_strings(response.strings.map(function(data) {
198                                     return {
199                                         component: data.component,
200                                         key: data.name,
201                                         value: data.value
202                                     };
203                                 }));
204                             }
206                             // Return the original template source that the user requested.
207                             return templateSource;
208                         });
210                         return templatePromises[searchKey];
211                     });
212             }
214             return promise
215                 .then(function(source) {
216                     // When we've successfully loaded the template then resolve the deferred
217                     // in the buffer so that all of the calling code can proceed.
218                     return templateDeferred.resolve(source);
219                 })
220                 .catch(function(error) {
221                     // If there was an error loading the template then reject the deferred
222                     // in the buffer so that all of the calling code can proceed.
223                     templateDeferred.reject(error);
224                     // Rethrow for anyone else listening.
225                     throw error;
226                 });
227         });
229         if (requests.length) {
230             // We have requests to send so resolve the deferred with the promises.
231             serverRequestsDeferred.resolve(ajax.call(requests, true, false, false, 0, M.cfg.templaterev));
232         } else {
233             // Nothing to load so we can resolve our deferred.
234             serverRequestsDeferred.resolve();
235         }
237         // Once we've finished loading all of the templates then recurse to process
238         // any templates that may have been added to the buffer in the time that we
239         // were fetching.
240         $.when.apply(null, templatePromises)
241             .then(function() {
242                 // Remove the templates we've loaded from the buffer.
243                 loadTemplateBuffer.splice(0, templatesToLoad.length);
244                 isLoadingTemplates = false;
245                 processLoadTemplateBuffer();
246                 return;
247             })
248             .catch(function() {
249                 // Remove the templates we've loaded from the buffer.
250                 loadTemplateBuffer.splice(0, templatesToLoad.length);
251                 isLoadingTemplates = false;
252                 processLoadTemplateBuffer();
253             });
254     };
256     /**
257      * Constructor
258      *
259      * Each call to templates.render gets it's own instance of this class.
260      */
261     var Renderer = function() {
262         this.requiredStrings = [];
263         this.requiredJS = [];
264         this.requiredDates = [];
265         this.currentThemeName = '';
266     };
267     // Class variables and functions.
269     /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
270     Renderer.prototype.requiredStrings = null;
272     /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */
273     Renderer.prototype.requiredDates = [];
275     /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
276     Renderer.prototype.requiredJS = null;
278     /** @var {String} themeName for the current render */
279     Renderer.prototype.currentThemeName = '';
281     /**
282      * Load a template.
283      *
284      * @method getTemplate
285      * @private
286      * @param {string} templateName - should consist of the component and the name of the template like this:
287      *                              core/menu (lib/templates/menu.mustache) or
288      *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
289      * @return {Promise} JQuery promise object resolved when the template has been fetched.
290      */
291     Renderer.prototype.getTemplate = function(templateName) {
292         var currentTheme = this.currentThemeName;
293         var searchKey = currentTheme + '/' + templateName;
295         // If we haven't already seen this template then buffer it.
296         var cachedPromise = getTemplatePromiseFromCache(searchKey);
297         if (cachedPromise) {
298             return cachedPromise;
299         }
301         // Check the buffer to see if this template has already been added.
302         var existingBufferRecords = loadTemplateBuffer.filter(function(record) {
303             return record.searchKey == searchKey;
304         });
305         if (existingBufferRecords.length) {
306             // This template is already in the buffer so just return the existing
307             // promise. No need to add it to the buffer again.
308             return existingBufferRecords[0].deferred.promise();
309         }
311         // This is the first time this has been requested so let's add it to the buffer
312         // to be loaded.
313         var parts = templateName.split('/');
314         var component = parts.shift();
315         var name = parts.join('/');
316         var deferred = $.Deferred();
318         // Add this template to the buffer to be loaded.
319         loadTemplateBuffer.push({
320             component: component,
321             name: name,
322             theme: currentTheme,
323             searchKey: searchKey,
324             deferred: deferred
325         });
327         // We know there is at least one thing in the buffer so kick off a processing run.
328         processLoadTemplateBuffer();
329         return deferred.promise();
330     };
332     /**
333      * Prefetch a set of templates without rendering them.
334      *
335      * @param {Array} templateNames The list of templates to fetch
336      * @param {String} currentTheme
337      */
338     Renderer.prototype.prefetchTemplates = function(templateNames, currentTheme) {
339         templateNames.forEach(function(templateName) {
340             var searchKey = currentTheme + '/' + templateName;
342             // If we haven't already seen this template then buffer it.
343             if (getTemplatePromiseFromCache(searchKey)) {
344                 return;
345             }
347             // Check the buffer to see if this template has already been added.
348             var existingBufferRecords = loadTemplateBuffer.filter(function(record) {
349                 return record.searchKey == searchKey;
350             });
352             if (existingBufferRecords.length) {
353                 // This template is already in the buffer so just return the existing promise.
354                 // No need to add it to the buffer again.
355                 return;
356             }
358             // This is the first time this has been requested so let's add it to the buffer to be loaded.
359             var parts = templateName.split('/');
360             var component = parts.shift();
361             var name = parts.join('/');
363             // Add this template to the buffer to be loaded.
364             loadTemplateBuffer.push({
365                 component: component,
366                 name: name,
367                 theme: currentTheme,
368                 searchKey: searchKey,
369                 deferred: $.Deferred(),
370             });
371         });
373         processLoadTemplateBuffer();
374     };
376     /**
377      * Load a partial from the cache or ajax.
378      *
379      * @method partialHelper
380      * @private
381      * @param {string} name The partial name to load.
382      * @return {string}
383      */
384     Renderer.prototype.partialHelper = function(name) {
386         var searchKey = this.currentThemeName + '/' + name;
388         if (!(searchKey in templateCache)) {
389             notification.exception(new Error('Failed to pre-fetch the template: ' + name));
390         }
392         return templateCache[searchKey];
393     };
395     /**
396      * Render a single image icon.
397      *
398      * @method renderIcon
399      * @private
400      * @param {string} key The icon key.
401      * @param {string} component The component name.
402      * @param {string} title The icon title
403      * @return {Promise}
404      */
405     Renderer.prototype.renderIcon = function(key, component, title) {
406         // Preload the module to do the icon rendering based on the theme iconsystem.
407         var modulename = config.iconsystemmodule;
409         // RequireJS does not return a promise.
410         var ready = $.Deferred();
411         require([modulename], function(System) {
412             var system = new System();
413             if (!(system instanceof IconSystem)) {
414                 ready.reject('Invalid icon system specified' + config.iconsystemmodule);
415             } else {
416                 iconSystem = system;
417                 system.init().then(ready.resolve).catch(notification.exception);
418             }
419         });
421         return ready.then(function(iconSystem) {
422             return this.getTemplate(iconSystem.getTemplateName());
423         }.bind(this)).then(function(template) {
424             return iconSystem.renderIcon(key, component, title, template);
425         });
426     };
428     /**
429      * Render image icons.
430      *
431      * @method pixHelper
432      * @private
433      * @param {object} context The mustache context
434      * @param {string} sectionText The text to parse arguments from.
435      * @param {function} helper Used to render the alt attribute of the text.
436      * @return {string}
437      */
438     Renderer.prototype.pixHelper = function(context, sectionText, helper) {
439         var parts = sectionText.split(',');
440         var key = '';
441         var component = '';
442         var text = '';
444         if (parts.length > 0) {
445             key = helper(parts.shift().trim(), context);
446         }
447         if (parts.length > 0) {
448             component = helper(parts.shift().trim(), context);
449         }
450         if (parts.length > 0) {
451             text = helper(parts.join(',').trim(), context);
452         }
454         var templateName = iconSystem.getTemplateName();
456         var searchKey = this.currentThemeName + '/' + templateName;
457         var template = templateCache[searchKey];
459         // The key might have been escaped by the JS Mustache engine which
460         // converts forward slashes to HTML entities. Let us undo that here.
461         key = key.replace(/&#x2F;/gi, '/');
463         return iconSystem.renderIcon(key, component, text, template);
464     };
466     /**
467      * Render blocks of javascript and save them in an array.
468      *
469      * @method jsHelper
470      * @private
471      * @param {object} context The current mustache context.
472      * @param {string} sectionText The text to save as a js block.
473      * @param {function} helper Used to render the block.
474      * @return {string}
475      */
476     Renderer.prototype.jsHelper = function(context, sectionText, helper) {
477         this.requiredJS.push(helper(sectionText, context));
478         return '';
479     };
481     /**
482      * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
483      * into a get_string call.
484      *
485      * @method stringHelper
486      * @private
487      * @param {object} context The current mustache context.
488      * @param {string} sectionText The text to parse the arguments from.
489      * @param {function} helper Used to render subsections of the text.
490      * @return {string}
491      */
492     Renderer.prototype.stringHelper = function(context, sectionText, helper) {
493         var parts = sectionText.split(',');
494         var key = '';
495         var component = '';
496         var param = '';
497         if (parts.length > 0) {
498             key = parts.shift().trim();
499         }
500         if (parts.length > 0) {
501             component = parts.shift().trim();
502         }
503         if (parts.length > 0) {
504             param = parts.join(',').trim();
505         }
507         if (param !== '') {
508             // Allow variable expansion in the param part only.
509             param = helper(param, context);
510         }
511         // Allow json formatted $a arguments.
512         if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
513             param = JSON.parse(param);
514         }
516         var index = this.requiredStrings.length;
517         this.requiredStrings.push({key: key, component: component, param: param});
519         // The placeholder must not use {{}} as those can be misinterpreted by the engine.
520         return '[[_s' + index + ']]';
521     };
523     /**
524      * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
525      *
526      * @method quoteHelper
527      * @private
528      * @param {object} context The current mustache context.
529      * @param {string} sectionText The text to parse the arguments from.
530      * @param {function} helper Used to render subsections of the text.
531      * @return {string}
532      */
533     Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
534         var content = helper(sectionText.trim(), context);
536         // Escape the {{ and the ".
537         // This involves wrapping {{, and }} in change delimeter tags.
538         content = content
539             .replace(/"/g, '\\"')
540             .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
541             .replace(/(\r\n|\r|\n)/g, '&#x0a;')
542             ;
543         return '"' + content + '"';
544     };
546     /**
547      * Shorten text helper to truncate text and append a trailing ellipsis.
548      *
549      * @method shortenTextHelper
550      * @private
551      * @param {object} context The current mustache context.
552      * @param {string} sectionText The text to parse the arguments from.
553      * @param {function} helper Used to render subsections of the text.
554      * @return {string}
555      */
556     Renderer.prototype.shortenTextHelper = function(context, sectionText, helper) {
557         // Non-greedy split on comma to grab section text into the length and
558         // text parts.
559         var regex = /(.*?),(.*)/;
560         var parts = sectionText.match(regex);
561         // The length is the part matched in the first set of parethesis.
562         var length = parts[1].trim();
563         // The length is the part matched in the second set of parethesis.
564         var text = parts[2].trim();
565         var content = helper(text, context);
566         return Truncate.truncate(content, {
567             length: length,
568             words: true,
569             ellipsis: '...'
570         });
571     };
573     /**
574      * User date helper to render user dates from timestamps.
575      *
576      * @method userDateHelper
577      * @private
578      * @param {object} context The current mustache context.
579      * @param {string} sectionText The text to parse the arguments from.
580      * @param {function} helper Used to render subsections of the text.
581      * @return {string}
582      */
583     Renderer.prototype.userDateHelper = function(context, sectionText, helper) {
584         // Non-greedy split on comma to grab the timestamp and format.
585         var regex = /(.*?),(.*)/;
586         var parts = sectionText.match(regex);
587         var timestamp = helper(parts[1].trim(), context);
588         var format = helper(parts[2].trim(), context);
589         var index = this.requiredDates.length;
591         this.requiredDates.push({
592             timestamp: timestamp,
593             format: format
594         });
596         return '[[_t_' + index + ']]';
597     };
599     /**
600      * Return a helper function to be added to the context for rendering the a
601      * template.
602      *
603      * This will parse the provided text before giving it to the helper function
604      * in order to remove any blacklisted nested helpers to prevent one helper
605      * from calling another.
606      *
607      * In particular to prevent the JS helper from being called from within another
608      * helper because it can lead to security issues when the JS portion is user
609      * provided.
610      *
611      * @param  {function} helperFunction The helper function to add
612      * @param  {object} context The template context for the helper function
613      * @return {Function} To be set in the context
614      */
615     Renderer.prototype.addHelperFunction = function(helperFunction, context) {
616         return function() {
617             return function(sectionText, helper) {
618                 // Override the blacklisted helpers in the template context with
619                 // a function that returns an empty string for use when executing
620                 // other helpers. This is to prevent these helpers from being
621                 // executed as part of the rendering of another helper in order to
622                 // prevent any potential security issues.
623                 var originalHelpers = blacklistedNestedHelpers.reduce(function(carry, name) {
624                     if (context.hasOwnProperty(name)) {
625                         carry[name] = context[name];
626                     }
628                     return carry;
629                 }, {});
631                 blacklistedNestedHelpers.forEach(function(helperName) {
632                     context[helperName] = function() {
633                         return '';
634                     };
635                 });
637                 // Execute the helper with the modified context that doesn't include
638                 // the blacklisted nested helpers. This prevents the blacklisted
639                 // helpers from being called from within other helpers.
640                 var result = helperFunction.apply(this, [context, sectionText, helper]);
642                 // Restore the original helper implementation in the context so that
643                 // any further rendering has access to them again.
644                 for (var name in originalHelpers) {
645                     context[name] = originalHelpers[name];
646                 }
648                 return result;
649             }.bind(this);
650         }.bind(this);
651     };
653     /**
654      * Add some common helper functions to all context objects passed to templates.
655      * These helpers match exactly the helpers available in php.
656      *
657      * @method addHelpers
658      * @private
659      * @param {Object} context Simple types used as the context for the template.
660      * @param {String} themeName We set this multiple times, because there are async calls.
661      */
662     Renderer.prototype.addHelpers = function(context, themeName) {
663         this.currentThemeName = themeName;
664         this.requiredStrings = [];
665         this.requiredJS = [];
666         context.uniqid = (uniqInstances++);
667         context.str = this.addHelperFunction(this.stringHelper, context);
668         context.pix = this.addHelperFunction(this.pixHelper, context);
669         context.js = this.addHelperFunction(this.jsHelper, context);
670         context.quote = this.addHelperFunction(this.quoteHelper, context);
671         context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);
672         context.userdate = this.addHelperFunction(this.userDateHelper, context);
673         context.globals = {config: config};
674         context.currentTheme = themeName;
675     };
677     /**
678      * Get all the JS blocks from the last rendered template.
679      *
680      * @method getJS
681      * @private
682      * @return {string}
683      */
684     Renderer.prototype.getJS = function() {
685         var js = '';
686         if (this.requiredJS.length > 0) {
687             js = this.requiredJS.join(";\n");
688         }
690         return js;
691     };
693     /**
694      * Treat strings in content.
695      *
696      * The purpose of this method is to replace the placeholders found in a string
697      * with the their respective translated strings.
698      *
699      * Previously we were relying on String.replace() but the complexity increased with
700      * the numbers of strings to replace. Now we manually walk the string and stop at each
701      * placeholder we find, only then we replace it. Most of the time we will
702      * replace all the placeholders in a single run, at times we will need a few
703      * more runs when placeholders are replaced with strings that contain placeholders
704      * themselves.
705      *
706      * @param {String} content The content in which string placeholders are to be found.
707      * @param {Array} strings The strings to replace with.
708      * @return {String} The treated content.
709      */
710     Renderer.prototype.treatStringsInContent = function(content, strings) {
711         var pattern = /\[\[_s\d+\]\]/,
712             treated,
713             index,
714             strIndex,
715             walker,
716             char,
717             strFinal;
719         do {
720             treated = '';
721             index = content.search(pattern);
722             while (index > -1) {
724                 // Copy the part prior to the placeholder to the treated string.
725                 treated += content.substring(0, index);
726                 content = content.substr(index);
727                 strIndex = '';
728                 walker = 4; // 4 is the length of '[[_s'.
730                 // Walk the characters to manually extract the index of the string from the placeholder.
731                 char = content.substr(walker, 1);
732                 do {
733                     strIndex += char;
734                     walker++;
735                     char = content.substr(walker, 1);
736                 } while (char != ']');
738                 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
739                 strFinal = strings[parseInt(strIndex, 10)];
740                 if (typeof strFinal === 'undefined') {
741                     Log.debug('Could not find string for pattern [[_s' + strIndex + ']].');
742                     strFinal = '';
743                 }
744                 treated += strFinal;
745                 content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '[[_s]]'.
747                 // Find the next placeholder.
748                 index = content.search(pattern);
749             }
751             // The content becomes the treated part with the rest of the content.
752             content = treated + content;
754             // Check if we need to walk the content again, in case strings contained placeholders.
755             index = content.search(pattern);
757         } while (index > -1);
759         return content;
760     };
762     /**
763      * Treat strings in content.
764      *
765      * The purpose of this method is to replace the date placeholders found in the
766      * content with the their respective translated dates.
767      *
768      * @param {String} content The content in which string placeholders are to be found.
769      * @param {Array} dates The dates to replace with.
770      * @return {String} The treated content.
771      */
772     Renderer.prototype.treatDatesInContent = function(content, dates) {
773         dates.forEach(function(date, index) {
774             var key = '\\[\\[_t_' + index + '\\]\\]';
775             var re = new RegExp(key, 'g');
776             content = content.replace(re, date);
777         });
779         return content;
780     };
782     /**
783      * Render a template and then call the callback with the result.
784      *
785      * @method doRender
786      * @private
787      * @param {string} templateSource The mustache template to render.
788      * @param {Object} context Simple types used as the context for the template.
789      * @param {String} themeName Name of the current theme.
790      * @return {Promise} object
791      */
792     Renderer.prototype.doRender = function(templateSource, context, themeName) {
793         this.currentThemeName = themeName;
794         var iconTemplate = iconSystem.getTemplateName();
796         var pendingPromise = new Pending('core/templates:doRender');
797         return this.getTemplate(iconTemplate).then(function() {
798             this.addHelpers(context, themeName);
799             var result = mustache.render(templateSource, context, this.partialHelper.bind(this));
800             return $.Deferred().resolve(result.trim(), this.getJS()).promise();
801         }.bind(this))
802         .then(function(html, js) {
803             if (this.requiredStrings.length > 0) {
804                 return str.get_strings(this.requiredStrings).then(function(strings) {
806                     // Make sure string substitutions are done for the userdate
807                     // values as well.
808                     this.requiredDates = this.requiredDates.map(function(date) {
809                         return {
810                             timestamp: this.treatStringsInContent(date.timestamp, strings),
811                             format: this.treatStringsInContent(date.format, strings)
812                         };
813                     }.bind(this));
815                     // Why do we not do another call the render here?
816                     //
817                     // Because that would expose DOS holes. E.g.
818                     // I create an assignment called "{{fish" which
819                     // would get inserted in the template in the first pass
820                     // and cause the template to die on the second pass (unbalanced).
821                     html = this.treatStringsInContent(html, strings);
822                     js = this.treatStringsInContent(js, strings);
823                     return $.Deferred().resolve(html, js).promise();
824                 }.bind(this));
825             }
827             return $.Deferred().resolve(html, js).promise();
828         }.bind(this))
829         .then(function(html, js) {
830             // This has to happen after the strings replacement because you can
831             // use the string helper in content for the user date helper.
832             if (this.requiredDates.length > 0) {
833                 return UserDate.get(this.requiredDates).then(function(dates) {
834                     html = this.treatDatesInContent(html, dates);
835                     js = this.treatDatesInContent(js, dates);
836                     return $.Deferred().resolve(html, js).promise();
837                 }.bind(this));
838             }
840             return $.Deferred().resolve(html, js).promise();
841         }.bind(this))
842         .then(function(html, js) {
843             pendingPromise.resolve();
844             return $.Deferred().resolve(html, js).promise();
845         });
846     };
848     /**
849      * Execute a block of JS returned from a template.
850      * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
851      *
852      * @method runTemplateJS
853      * @param {string} source - A block of javascript.
854      */
855     var runTemplateJS = function(source) {
856         if (source.trim() !== '') {
857             var newscript = $('<script>').attr('type', 'text/javascript').html(source);
858             $('head').append(newscript);
859         }
860     };
862     /**
863      * Do some DOM replacement and trigger correct events and fire javascript.
864      *
865      * @method domReplace
866      * @private
867      * @param {JQuery} element - Element or selector to replace.
868      * @param {String} newHTML - HTML to insert / replace.
869      * @param {String} newJS - Javascript to run after the insertion.
870      * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
871      */
872     var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
873         var replaceNode = $(element);
874         if (replaceNode.length) {
875             // First create the dom nodes so we have a reference to them.
876             var newNodes = $(newHTML);
877             var yuiNodes = null;
878             // Do the replacement in the page.
879             if (replaceChildNodes) {
880                 // Cleanup any YUI event listeners attached to any of these nodes.
881                 yuiNodes = new Y.NodeList(replaceNode.children().get());
882                 yuiNodes.destroy(true);
884                 // JQuery will cleanup after itself.
885                 replaceNode.empty();
886                 replaceNode.append(newNodes);
887             } else {
888                 // Cleanup any YUI event listeners attached to any of these nodes.
889                 yuiNodes = new Y.NodeList(replaceNode.get());
890                 yuiNodes.destroy(true);
892                 // JQuery will cleanup after itself.
893                 replaceNode.replaceWith(newNodes);
894             }
895             // Run any javascript associated with the new HTML.
896             runTemplateJS(newJS);
897             // Notify all filters about the new content.
898             event.notifyFilterContentUpdated(newNodes);
899         }
900     };
902     /**
903      * Scan a template source for partial tags and return a list of the found partials.
904      *
905      * @method scanForPartials
906      * @private
907      * @param {string} templateSource - source template to scan.
908      * @return {Array} List of partials.
909      */
910     Renderer.prototype.scanForPartials = function(templateSource) {
911         var tokens = mustache.parse(templateSource),
912             partials = [];
914         var findPartial = function(tokens, partials) {
915             var i, token;
916             for (i = 0; i < tokens.length; i++) {
917                 token = tokens[i];
918                 if (token[0] == '>' || token[0] == '<') {
919                     partials.push(token[1]);
920                 }
921                 if (token.length > 4) {
922                     findPartial(token[4], partials);
923                 }
924             }
925         };
927         findPartial(tokens, partials);
929         return partials;
930     };
932     /**
933      * Load a template and scan it for partials. Recursively fetch the partials.
934      *
935      * @method cachePartials
936      * @private
937      * @param {string} templateName - should consist of the component and the name of the template like this:
938      *                              core/menu (lib/templates/menu.mustache) or
939      *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
940      * @param {Array} parentage - A list of requested partials in this render chain.
941      * @return {Promise} JQuery promise object resolved when all partials are in the cache.
942      */
943     Renderer.prototype.cachePartials = function(templateName, parentage) {
944         var searchKey = this.currentThemeName + '/' + templateName;
946         if (searchKey in cachePartialPromises) {
947             return cachePartialPromises[searchKey];
948         }
950         // This promise will not be resolved until all child partials are also resolved and ready.
951         // We create it here to allow us to check for recursive inclusion of templates.
952         // Keep track of the requested partials in this chain.
953         parentage = parentage || [searchKey];
955         cachePartialPromises[searchKey] = $.Deferred();
957         this.getTemplate(templateName)
958         .then(function(templateSource) {
959             var partials = this.scanForPartials(templateSource);
960             var uniquePartials = partials.filter(function(partialName) {
961                 // Check for recursion.
963                 if (parentage.indexOf(this.currentThemeName + '/' + partialName) >= 0) {
964                     // Ignore templates which include a parent template already requested in the current chain.
965                     return false;
966                 }
968                 // Ignore templates that include themselves.
969                 return partialName != templateName;
970             }.bind(this));
972             // Fetch any partial which has not already been fetched.
973             var fetchThemAll = uniquePartials.map(function(partialName) {
974                 parentage.push(this.currentThemeName + '/' + partialName);
975                 return this.cachePartials(partialName, parentage);
976             }.bind(this));
978             // Resolve the templateName promise when all of the children are resolved.
979             return $.when.apply($, fetchThemAll)
980             .then(function() {
981                 return cachePartialPromises[searchKey].resolve(templateSource);
982             });
983         }.bind(this))
984         .catch(cachePartialPromises[searchKey].reject);
986         return cachePartialPromises[searchKey];
987     };
989     /**
990      * Load a template and call doRender on it.
991      *
992      * @method render
993      * @private
994      * @param {string} templateName - should consist of the component and the name of the template like this:
995      *                              core/menu (lib/templates/menu.mustache) or
996      *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
997      * @param {Object} context - Could be array, string or simple value for the context of the template.
998      * @param {string} themeName - Name of the current theme.
999      * @return {Promise} JQuery promise object resolved when the template has been rendered.
1000      */
1001     Renderer.prototype.render = function(templateName, context, themeName) {
1002         if (typeof (themeName) === "undefined") {
1003             // System context by default.
1004             themeName = config.theme;
1005         }
1007         this.currentThemeName = themeName;
1009         // Preload the module to do the icon rendering based on the theme iconsystem.
1010         var modulename = config.iconsystemmodule;
1012         var ready = $.Deferred();
1013         require([modulename], function(System) {
1014             var system = new System();
1015             if (!(system instanceof IconSystem)) {
1016                 ready.reject('Invalid icon system specified' + config.iconsystem);
1017             } else {
1018                 iconSystem = system;
1019                 system.init().then(ready.resolve).catch(notification.exception);
1020             }
1021         });
1023         return ready.then(function() {
1024                 return this.cachePartials(templateName);
1025             }.bind(this)).then(function(templateSource) {
1026                 return this.doRender(templateSource, context, themeName);
1027             }.bind(this));
1028     };
1030     /**
1031      * Prepend some HTML to a node and trigger events and fire javascript.
1032      *
1033      * @method domPrepend
1034      * @private
1035      * @param {jQuery|String} element - Element or selector to prepend HTML to
1036      * @param {String} html - HTML to prepend
1037      * @param {String} js - Javascript to run after we prepend the html
1038      */
1039     var domPrepend = function(element, html, js) {
1040         var node = $(element);
1041         if (node.length) {
1042             // Prepend the html.
1043             node.prepend(html);
1044             // Run any javascript associated with the new HTML.
1045             runTemplateJS(js);
1046             // Notify all filters about the new content.
1047             event.notifyFilterContentUpdated(node);
1048         }
1049     };
1051     /**
1052      * Append some HTML to a node and trigger events and fire javascript.
1053      *
1054      * @method domAppend
1055      * @private
1056      * @param {jQuery|String} element - Element or selector to append HTML to
1057      * @param {String} html - HTML to append
1058      * @param {String} js - Javascript to run after we append the html
1059      */
1060     var domAppend = function(element, html, js) {
1061         var node = $(element);
1062         if (node.length) {
1063             // Append the html.
1064             node.append(html);
1065             // Run any javascript associated with the new HTML.
1066             runTemplateJS(js);
1067             // Notify all filters about the new content.
1068             event.notifyFilterContentUpdated(node);
1069         }
1070     };
1072     return /** @alias module:core/templates */ {
1073         // Public variables and functions.
1074         /**
1075          * Every call to render creates a new instance of the class and calls render on it. This
1076          * means each render call has it's own class variables.
1077          *
1078          * @method render
1079          * @private
1080          * @param {string} templateName - should consist of the component and the name of the template like this:
1081          *                              core/menu (lib/templates/menu.mustache) or
1082          *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
1083          * @param {Object} context - Could be array, string or simple value for the context of the template.
1084          * @param {string} themeName - Name of the current theme.
1085          * @return {Promise} JQuery promise object resolved when the template has been rendered.
1086          */
1087         render: function(templateName, context, themeName) {
1088             var renderer = new Renderer();
1089             return renderer.render(templateName, context, themeName);
1090         },
1092         /**
1093          * Prefetch a set of templates without rendering them.
1094          *
1095          * @method getTemplate
1096          * @param {Array} templateNames The list of templates to fetch
1097          * @param {String} themeName
1098          * @returns {Promise}
1099          */
1100         prefetchTemplates: function(templateNames, themeName) {
1101             var renderer = new Renderer();
1103             if (typeof themeName === "undefined") {
1104                 // System context by default.
1105                 themeName = config.theme;
1106             }
1108             return renderer.prefetchTemplates(templateNames, themeName);
1109         },
1111         /**
1112          * Every call to render creates a new instance of the class and calls render on it. This
1113          * means each render call has it's own class variables.
1114          *
1115          * This alernate to the standard .render() function returns the html and js in a single object suitable for a
1116          * native Promise.
1117          *
1118          * @method renderForPromise
1119          * @private
1120          * @param {string} templateName - should consist of the component and the name of the template like this:
1121          *                              core/menu (lib/templates/menu.mustache) or
1122          *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
1123          * @param {Object} context - Could be array, string or simple value for the context of the template.
1124          * @param {string} themeName - Name of the current theme.
1125          * @return {Promise} JQuery promise object resolved when the template has been rendered.
1126          */
1127         renderForPromise: function(templateName, context, themeName) {
1128             var renderer = new Renderer();
1129             return renderer.render(templateName, context, themeName)
1130             .then(function(html, js) {
1131                 return {
1132                     html: html,
1133                     js: js,
1134                 };
1135             });
1136         },
1138         /**
1139          * Every call to renderIcon creates a new instance of the class and calls renderIcon on it. This
1140          * means each render call has it's own class variables.
1141          *
1142          * @method renderIcon
1143          * @public
1144          * @param {string} key - Icon key.
1145          * @param {string} component - Icon component
1146          * @param {string} title - Icon title
1147          * @return {Promise} JQuery promise object resolved when the pix has been rendered.
1148          */
1149         renderPix: function(key, component, title) {
1150             var renderer = new Renderer();
1151             return renderer.renderIcon(key, component, title);
1152         },
1154         /**
1155          * Execute a block of JS returned from a template.
1156          * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
1157          *
1158          * @method runTemplateJS
1159          * @param {string} source - A block of javascript.
1160          */
1161         runTemplateJS: runTemplateJS,
1163         /**
1164          * Replace a node in the page with some new HTML and run the JS.
1165          *
1166          * @method replaceNodeContents
1167          * @param {JQuery} element - Element or selector to replace.
1168          * @param {String} newHTML - HTML to insert / replace.
1169          * @param {String} newJS - Javascript to run after the insertion.
1170          */
1171         replaceNodeContents: function(element, newHTML, newJS) {
1172             domReplace(element, newHTML, newJS, true);
1173         },
1175         /**
1176          * Insert a node in the page with some new HTML and run the JS.
1177          *
1178          * @method replaceNode
1179          * @param {JQuery} element - Element or selector to replace.
1180          * @param {String} newHTML - HTML to insert / replace.
1181          * @param {String} newJS - Javascript to run after the insertion.
1182          */
1183         replaceNode: function(element, newHTML, newJS) {
1184             domReplace(element, newHTML, newJS, false);
1185         },
1187         /**
1188          * Prepend some HTML to a node and trigger events and fire javascript.
1189          *
1190          * @method prependNodeContents
1191          * @param {jQuery|String} element - Element or selector to prepend HTML to
1192          * @param {String} html - HTML to prepend
1193          * @param {String} js - Javascript to run after we prepend the html
1194          */
1195         prependNodeContents: function(element, html, js) {
1196             domPrepend(element, html, js);
1197         },
1199         /**
1200          * Append some HTML to a node and trigger events and fire javascript.
1201          *
1202          * @method appendNodeContents
1203          * @param {jQuery|String} element - Element or selector to append HTML to
1204          * @param {String} html - HTML to append
1205          * @param {String} js - Javascript to run after we append the html
1206          */
1207         appendNodeContents: function(element, html, js) {
1208             domAppend(element, html, js);
1209         },
1210     };
1211 });