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