MDL-68677 core: Correct usage of templaterev for caching templates
[moodle.git] / lib / amd / src / templates.js
CommitLineData
9bdcf579
DW
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/>.
15
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 */
eb514bb3
AN
26define([
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) {
9bdcf579 45
8d00afb1
DW
46 // Module variables.
47 /** @var {Number} uniqInstances Count of times this constructor has been called. */
48 var uniqInstances = 0;
9bdcf579 49
39bf2a98 50 /** @var {String[]} templateCache - Cache of already loaded template strings */
9bdcf579
DW
51 var templateCache = {};
52
39bf2a98
DW
53 /** @var {Promise[]} templatePromises - Cache of already loaded template promises */
54 var templatePromises = {};
f20a336b 55
0015f5b8
RW
56 /** @var {Promise[]} cachePartialPromises - Cache of already loaded template partial promises */
57 var cachePartialPromises = {};
58
95b06c13
DW
59 /** @var {Object} iconSystem - Object extending core/iconsystem */
60 var iconSystem = {};
61
c77ba489
RW
62 /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
63 var loadTemplateBuffer = [];
64
65 /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
66 var isLoadingTemplates = false;
67
3fb0cbd4
RW
68 /** @var {Array} blacklistedNestedHelpers - List of helpers that can't be called within other helpers */
69 var blacklistedNestedHelpers = ['js'];
70
c77ba489
RW
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 }
86
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 }
93
b9dec51a
AN
94 if (M.cfg.templaterev <= 0) {
95 // Template caching is disabled. Do not store in persistent storage.
96 return null;
97 }
98
c77ba489 99 // Now try local storage.
9e5edc7f 100 var cached = storage.get('core_template/' + M.cfg.templaterev + ':' + searchKey);
c77ba489
RW
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 }
108
109 return null;
110 };
111
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 }
123
124 if (isLoadingTemplates) {
125 return;
126 }
127
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;
142
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,
545e2abb
SL
157 themename: theme,
158 lang: $('html').attr('lang').replace(/-/g, '_')
c77ba489
RW
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;
164
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;
176
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;
b9dec51a
AN
186
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 }
c77ba489
RW
191
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 });
197
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 }
209
210 // Return the original template source that the user requested.
211 return templateSource;
212 });
213
214 return templatePromises[searchKey];
215 });
216 }
217
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 });
232
233 if (requests.length) {
234 // We have requests to send so resolve the deferred with the promises.
9e5edc7f 235 serverRequestsDeferred.resolve(ajax.call(requests, true, false, false, 0, M.cfg.templaterev));
c77ba489
RW
236 } else {
237 // Nothing to load so we can resolve our deferred.
238 serverRequestsDeferred.resolve();
239 }
240
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 };
259
8d00afb1
DW
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 = [];
0e5b3e28 268 this.requiredDates = [];
8d00afb1
DW
269 this.currentThemeName = '';
270 };
271 // Class variables and functions.
272
9bdcf579 273 /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
8d00afb1 274 Renderer.prototype.requiredStrings = null;
9bdcf579 275
0e5b3e28
RW
276 /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */
277 Renderer.prototype.requiredDates = [];
278
9bdcf579 279 /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
8d00afb1 280 Renderer.prototype.requiredJS = null;
9bdcf579
DW
281
282 /** @var {String} themeName for the current render */
8d00afb1 283 Renderer.prototype.currentThemeName = '';
9bdcf579 284
f992dcf6 285 /**
c77ba489 286 * Load a template.
f992dcf6
DP
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 */
39bf2a98 295 Renderer.prototype.getTemplate = function(templateName) {
2b92891a
RW
296 var currentTheme = this.currentThemeName;
297 var searchKey = currentTheme + '/' + templateName;
f992dcf6 298
c77ba489
RW
299 // If we haven't already seen this template then buffer it.
300 var cachedPromise = getTemplatePromiseFromCache(searchKey);
301 if (cachedPromise) {
302 return cachedPromise;
f992dcf6
DP
303 }
304
983baf4e 305 // Check the buffer to see if this template has already been added.
c77ba489
RW
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();
2b92891a
RW
313 }
314
c77ba489
RW
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();
0261d1ef 319 var name = parts.join('/');
c77ba489 320 var deferred = $.Deferred();
f992dcf6 321
c77ba489
RW
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 });
2b92891a 330
c77ba489
RW
331 // We know there is at least one thing in the buffer so kick off a processing run.
332 processLoadTemplateBuffer();
333 return deferred.promise();
f992dcf6
DP
334 };
335
983baf4e
AN
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;
345
346 // If we haven't already seen this template then buffer it.
347 if (getTemplatePromiseFromCache(searchKey)) {
348 return;
349 }
350
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 });
355
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 }
361
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('/');
366
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 });
376
377 processLoadTemplateBuffer();
378 };
379
f992dcf6
DP
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 */
8d00afb1 388 Renderer.prototype.partialHelper = function(name) {
f992dcf6 389
39bf2a98
DW
390 var searchKey = this.currentThemeName + '/' + name;
391
392 if (!(searchKey in templateCache)) {
393 notification.exception(new Error('Failed to pre-fetch the template: ' + name));
394 }
f992dcf6 395
39bf2a98 396 return templateCache[searchKey];
f992dcf6
DP
397 };
398
95b06c13
DW
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.
e330b1c2 411 var modulename = config.iconsystemmodule;
95b06c13 412
e330b1c2 413 // RequireJS does not return a promise.
95b06c13
DW
414 var ready = $.Deferred();
415 require([modulename], function(System) {
416 var system = new System();
417 if (!(system instanceof IconSystem)) {
e330b1c2 418 ready.reject('Invalid icon system specified' + config.iconsystemmodule);
95b06c13 419 } else {
95b06c13 420 iconSystem = system;
751ec025 421 system.init().then(ready.resolve).catch(notification.exception);
95b06c13
DW
422 }
423 });
424
e330b1c2
DW
425 return ready.then(function(iconSystem) {
426 return this.getTemplate(iconSystem.getTemplateName());
95b06c13
DW
427 }.bind(this)).then(function(template) {
428 return iconSystem.renderIcon(key, component, title, template);
429 });
430 };
431
9bdcf579
DW
432 /**
433 * Render image icons.
434 *
435 * @method pixHelper
436 * @private
8d00afb1 437 * @param {object} context The mustache context
9bdcf579 438 * @param {string} sectionText The text to parse arguments from.
e37d53da 439 * @param {function} helper Used to render the alt attribute of the text.
9bdcf579
DW
440 * @return {string}
441 */
8d00afb1 442 Renderer.prototype.pixHelper = function(context, sectionText, helper) {
9bdcf579
DW
443 var parts = sectionText.split(',');
444 var key = '';
445 var component = '';
446 var text = '';
9bdcf579
DW
447
448 if (parts.length > 0) {
e03d5d47 449 key = helper(parts.shift().trim(), context);
9bdcf579
DW
450 }
451 if (parts.length > 0) {
e03d5d47 452 component = helper(parts.shift().trim(), context);
9bdcf579
DW
453 }
454 if (parts.length > 0) {
e03d5d47 455 text = helper(parts.join(',').trim(), context);
9bdcf579 456 }
95b06c13 457
e330b1c2 458 var templateName = iconSystem.getTemplateName();
95b06c13 459
e330b1c2 460 var searchKey = this.currentThemeName + '/' + templateName;
39bf2a98 461 var template = templateCache[searchKey];
95b06c13 462
74e238f4
DM
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, '/');
466
95b06c13 467 return iconSystem.renderIcon(key, component, text, template);
9bdcf579
DW
468 };
469
9bdcf579
DW
470 /**
471 * Render blocks of javascript and save them in an array.
472 *
473 * @method jsHelper
474 * @private
8d00afb1 475 * @param {object} context The current mustache context.
9bdcf579
DW
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 */
8d00afb1
DW
480 Renderer.prototype.jsHelper = function(context, sectionText, helper) {
481 this.requiredJS.push(helper(sectionText, context));
9bdcf579
DW
482 return '';
483 };
484
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
8d00afb1 491 * @param {object} context The current mustache context.
9bdcf579
DW
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 */
8d00afb1 496 Renderer.prototype.stringHelper = function(context, sectionText, helper) {
9bdcf579
DW
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 }
510
511 if (param !== '') {
512 // Allow variable expansion in the param part only.
8d00afb1 513 param = helper(param, context);
9bdcf579
DW
514 }
515 // Allow json formatted $a arguments.
516 if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
517 param = JSON.parse(param);
518 }
519
8d00afb1
DW
520 var index = this.requiredStrings.length;
521 this.requiredStrings.push({key: key, component: component, param: param});
a89cf237
FM
522
523 // The placeholder must not use {{}} as those can be misinterpreted by the engine.
524 return '[[_s' + index + ']]';
9bdcf579
DW
525 };
526
0b4bff8c
AN
527 /**
528 * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
529 *
530 * @method quoteHelper
531 * @private
8d00afb1 532 * @param {object} context The current mustache context.
0b4bff8c
AN
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 */
8d00afb1
DW
537 Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
538 var content = helper(sectionText.trim(), context);
0b4bff8c
AN
539
540 // Escape the {{ and the ".
541 // This involves wrapping {{, and }} in change delimeter tags.
542 content = content
f8f04b77 543 .replace(/"/g, '\\"')
0b4bff8c 544 .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
6c97045e 545 .replace(/(\r\n|\r|\n)/g, '&#x0a;')
0b4bff8c
AN
546 ;
547 return '"' + content + '"';
548 };
549
75378ded
RW
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 };
576
0e5b3e28
RW
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;
594
595 this.requiredDates.push({
596 timestamp: timestamp,
597 format: format
598 });
599
600 return '[[_t_' + index + ']]';
601 };
602
3fb0cbd4
RW
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 }
631
632 return carry;
633 }, {});
634
635 blacklistedNestedHelpers.forEach(function(helperName) {
636 context[helperName] = function() {
637 return '';
638 };
639 });
640
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]);
645
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 }
651
652 return result;
653 }.bind(this);
654 }.bind(this);
655 };
656
9bdcf579
DW
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 */
8d00afb1
DW
666 Renderer.prototype.addHelpers = function(context, themeName) {
667 this.currentThemeName = themeName;
668 this.requiredStrings = [];
669 this.requiredJS = [];
670 context.uniqid = (uniqInstances++);
3fb0cbd4
RW
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);
9f5f3dcc 677 context.globals = {config: config};
9bdcf579
DW
678 context.currentTheme = themeName;
679 };
680
681 /**
682 * Get all the JS blocks from the last rendered template.
683 *
684 * @method getJS
685 * @private
9bdcf579
DW
686 * @return {string}
687 */
0e5b3e28 688 Renderer.prototype.getJS = function() {
9bdcf579 689 var js = '';
8d00afb1
DW
690 if (this.requiredJS.length > 0) {
691 js = this.requiredJS.join(";\n");
9bdcf579
DW
692 }
693
0e5b3e28 694 return js;
29879f8f
FM
695 };
696
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 */
8d00afb1 714 Renderer.prototype.treatStringsInContent = function(content, strings) {
a89cf237 715 var pattern = /\[\[_s\d+\]\]/,
29879f8f
FM
716 treated,
717 index,
718 strIndex,
719 walker,
720 char,
721 strFinal;
722
723 do {
724 treated = '';
725 index = content.search(pattern);
726 while (index > -1) {
727
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 = '';
d9dff92f 732 walker = 4; // 4 is the length of '[[_s'.
29879f8f
FM
733
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);
a89cf237 740 } while (char != ']');
29879f8f
FM
741
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') {
a89cf237 745 Log.debug('Could not find string for pattern [[_s' + strIndex + ']].');
29879f8f
FM
746 strFinal = '';
747 }
748 treated += strFinal;
d9dff92f 749 content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '[[_s]]'.
29879f8f
FM
750
751 // Find the next placeholder.
752 index = content.search(pattern);
753 }
754
755 // The content becomes the treated part with the rest of the content.
756 content = treated + content;
757
758 // Check if we need to walk the content again, in case strings contained placeholders.
759 index = content.search(pattern);
760
761 } while (index > -1);
762
763 return content;
9bdcf579
DW
764 };
765
0e5b3e28
RW
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.
373d43ae 773 * @param {Array} dates The dates to replace with.
0e5b3e28
RW
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 });
782
783 return content;
784 };
785
9bdcf579
DW
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 */
8d00afb1 796 Renderer.prototype.doRender = function(templateSource, context, themeName) {
8d00afb1 797 this.currentThemeName = themeName;
e330b1c2 798 var iconTemplate = iconSystem.getTemplateName();
9bdcf579 799
eb514bb3 800 var pendingPromise = new Pending('core/templates:doRender');
e330b1c2 801 return this.getTemplate(iconTemplate).then(function() {
f20a336b
DW
802 this.addHelpers(context, themeName);
803 var result = mustache.render(templateSource, context, this.partialHelper.bind(this));
0e5b3e28
RW
804 return $.Deferred().resolve(result.trim(), this.getJS()).promise();
805 }.bind(this))
806 .then(function(html, js) {
f20a336b
DW
807 if (this.requiredStrings.length > 0) {
808 return str.get_strings(this.requiredStrings).then(function(strings) {
809
0e5b3e28
RW
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));
818
f20a336b
DW
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).
0e5b3e28
RW
825 html = this.treatStringsInContent(html, strings);
826 js = this.treatStringsInContent(js, strings);
827 return $.Deferred().resolve(html, js).promise();
828 }.bind(this));
829 }
f20a336b 830
0e5b3e28
RW
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();
f20a336b 841 }.bind(this));
f20a336b 842 }
0e5b3e28
RW
843
844 return $.Deferred().resolve(html, js).promise();
4b7ad884
AN
845 }.bind(this))
846 .then(function(html, js) {
eb514bb3 847 pendingPromise.resolve();
4b7ad884
AN
848 return $.Deferred().resolve(html, js).promise();
849 });
9bdcf579
DW
850 };
851
28de7771
DW
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() !== '') {
35be5826 861 var newscript = $('<script>').attr('type', 'text/javascript').html(source);
28de7771
DW
862 $('head').append(newscript);
863 }
864 };
865
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);
1fca8a7b 881 var yuiNodes = null;
28de7771
DW
882 // Do the replacement in the page.
883 if (replaceChildNodes) {
1fca8a7b
DW
884 // Cleanup any YUI event listeners attached to any of these nodes.
885 yuiNodes = new Y.NodeList(replaceNode.children().get());
886 yuiNodes.destroy(true);
887
888 // JQuery will cleanup after itself.
28de7771
DW
889 replaceNode.empty();
890 replaceNode.append(newNodes);
891 } else {
1fca8a7b
DW
892 // Cleanup any YUI event listeners attached to any of these nodes.
893 yuiNodes = new Y.NodeList(replaceNode.get());
894 yuiNodes.destroy(true);
895
896 // JQuery will cleanup after itself.
28de7771
DW
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 };
905
39bf2a98
DW
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 = [];
917
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 };
930
931 findPartial(tokens, partials);
932
933 return partials;
934 };
935
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)
a2cb00b6 944 * @param {Array} parentage - A list of requested partials in this render chain.
39bf2a98
DW
945 * @return {Promise} JQuery promise object resolved when all partials are in the cache.
946 */
a2cb00b6 947 Renderer.prototype.cachePartials = function(templateName, parentage) {
0015f5b8
RW
948 var searchKey = this.currentThemeName + '/' + templateName;
949
950 if (searchKey in cachePartialPromises) {
951 return cachePartialPromises[searchKey];
952 }
953
61e0f58c
AN
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.
a2cb00b6 956 // Keep track of the requested partials in this chain.
23e347bb 957 parentage = parentage || [searchKey];
a2cb00b6 958
61e0f58c
AN
959 cachePartialPromises[searchKey] = $.Deferred();
960
961 this.getTemplate(templateName)
962 .then(function(templateSource) {
39bf2a98 963 var partials = this.scanForPartials(templateSource);
b51c5b92 964 var uniquePartials = partials.filter(function(partialName) {
61e0f58c
AN
965 // Check for recursion.
966
a2cb00b6
DW
967 if (parentage.indexOf(this.currentThemeName + '/' + partialName) >= 0) {
968 // Ignore templates which include a parent template already requested in the current chain.
61e0f58c
AN
969 return false;
970 }
971
972 // Ignore templates that include themselves.
b51c5b92 973 return partialName != templateName;
61e0f58c
AN
974 }.bind(this));
975
976 // Fetch any partial which has not already been fetched.
b51c5b92 977 var fetchThemAll = uniquePartials.map(function(partialName) {
a2cb00b6
DW
978 parentage.push(this.currentThemeName + '/' + partialName);
979 return this.cachePartials(partialName, parentage);
0015f5b8 980 }.bind(this));
39bf2a98 981
61e0f58c
AN
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);
b302369d 986 });
61e0f58c
AN
987 }.bind(this))
988 .catch(cachePartialPromises[searchKey].reject);
0015f5b8 989
61e0f58c 990 return cachePartialPromises[searchKey];
39bf2a98
DW
991 };
992
8d00afb1
DW
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) {
8d00afb1
DW
1006 if (typeof (themeName) === "undefined") {
1007 // System context by default.
1008 themeName = config.theme;
1009 }
1010
1011 this.currentThemeName = themeName;
1012
95b06c13 1013 // Preload the module to do the icon rendering based on the theme iconsystem.
e330b1c2 1014 var modulename = config.iconsystemmodule;
95b06c13
DW
1015
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;
751ec025 1023 system.init().then(ready.resolve).catch(notification.exception);
95b06c13
DW
1024 }
1025 });
1026
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));
8d00afb1
DW
1032 };
1033
90525930
MN
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 };
28de7771 1054
f7775c9a
MN
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 };
1075
9bdcf579
DW
1076 return /** @alias module:core/templates */ {
1077 // Public variables and functions.
1078 /**
8d00afb1
DW
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.
9bdcf579
DW
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) {
8d00afb1
DW
1092 var renderer = new Renderer();
1093 return renderer.render(templateName, context, themeName);
9bdcf579
DW
1094 },
1095
983baf4e
AN
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();
1106
1107 if (typeof themeName === "undefined") {
1108 // System context by default.
1109 themeName = config.theme;
1110 }
1111
1112 return renderer.prefetchTemplates(templateNames, themeName);
1113 },
1114
373d43ae
AN
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 },
1141
95b06c13
DW
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 },
1157
9bdcf579
DW
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
9bdcf579
DW
1163 * @param {string} source - A block of javascript.
1164 */
28de7771
DW
1165 runTemplateJS: runTemplateJS,
1166
1167 /**
1168 * Replace a node in the page with some new HTML and run the JS.
1169 *
1170 * @method replaceNodeContents
c96f55e6
DP
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.
28de7771
DW
1174 */
1175 replaceNodeContents: function(element, newHTML, newJS) {
c96f55e6 1176 domReplace(element, newHTML, newJS, true);
28de7771
DW
1177 },
1178
1179 /**
1180 * Insert a node in the page with some new HTML and run the JS.
1181 *
1182 * @method replaceNode
c96f55e6
DP
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.
28de7771
DW
1186 */
1187 replaceNode: function(element, newHTML, newJS) {
c96f55e6 1188 domReplace(element, newHTML, newJS, false);
f7775c9a
MN
1189 },
1190
90525930
MN
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 },
1202
f7775c9a
MN
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);
373d43ae 1213 },
9bdcf579
DW
1214 };
1215});