MDL-69050 lang: Stop using the term blacklist in mustache output engine
[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
972340b6
DM
68 /** @var {Array} disallowedNestedHelpers - List of helpers that can't be called within other helpers */
69 var disallowedNestedHelpers = ['js'];
3fb0cbd4 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
bc9426c8
AN
511 if (!component || component === 'moodle') {
512 component = 'core';
513 }
514
9bdcf579
DW
515 if (param !== '') {
516 // Allow variable expansion in the param part only.
8d00afb1 517 param = helper(param, context);
9bdcf579
DW
518 }
519 // Allow json formatted $a arguments.
520 if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
521 param = JSON.parse(param);
522 }
523
8d00afb1
DW
524 var index = this.requiredStrings.length;
525 this.requiredStrings.push({key: key, component: component, param: param});
a89cf237
FM
526
527 // The placeholder must not use {{}} as those can be misinterpreted by the engine.
528 return '[[_s' + index + ']]';
9bdcf579
DW
529 };
530
0b4bff8c
AN
531 /**
532 * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
533 *
534 * @method quoteHelper
535 * @private
8d00afb1 536 * @param {object} context The current mustache context.
0b4bff8c
AN
537 * @param {string} sectionText The text to parse the arguments from.
538 * @param {function} helper Used to render subsections of the text.
539 * @return {string}
540 */
8d00afb1
DW
541 Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
542 var content = helper(sectionText.trim(), context);
0b4bff8c
AN
543
544 // Escape the {{ and the ".
545 // This involves wrapping {{, and }} in change delimeter tags.
546 content = content
f8f04b77 547 .replace(/"/g, '\\"')
0b4bff8c 548 .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
6c97045e 549 .replace(/(\r\n|\r|\n)/g, '&#x0a;')
0b4bff8c
AN
550 ;
551 return '"' + content + '"';
552 };
553
75378ded
RW
554 /**
555 * Shorten text helper to truncate text and append a trailing ellipsis.
556 *
557 * @method shortenTextHelper
558 * @private
559 * @param {object} context The current mustache context.
560 * @param {string} sectionText The text to parse the arguments from.
561 * @param {function} helper Used to render subsections of the text.
562 * @return {string}
563 */
564 Renderer.prototype.shortenTextHelper = function(context, sectionText, helper) {
565 // Non-greedy split on comma to grab section text into the length and
566 // text parts.
567 var regex = /(.*?),(.*)/;
568 var parts = sectionText.match(regex);
569 // The length is the part matched in the first set of parethesis.
570 var length = parts[1].trim();
571 // The length is the part matched in the second set of parethesis.
572 var text = parts[2].trim();
573 var content = helper(text, context);
574 return Truncate.truncate(content, {
575 length: length,
576 words: true,
577 ellipsis: '...'
578 });
579 };
580
0e5b3e28
RW
581 /**
582 * User date helper to render user dates from timestamps.
583 *
584 * @method userDateHelper
585 * @private
586 * @param {object} context The current mustache context.
587 * @param {string} sectionText The text to parse the arguments from.
588 * @param {function} helper Used to render subsections of the text.
589 * @return {string}
590 */
591 Renderer.prototype.userDateHelper = function(context, sectionText, helper) {
592 // Non-greedy split on comma to grab the timestamp and format.
593 var regex = /(.*?),(.*)/;
594 var parts = sectionText.match(regex);
595 var timestamp = helper(parts[1].trim(), context);
596 var format = helper(parts[2].trim(), context);
597 var index = this.requiredDates.length;
598
599 this.requiredDates.push({
600 timestamp: timestamp,
601 format: format
602 });
603
604 return '[[_t_' + index + ']]';
605 };
606
3fb0cbd4
RW
607 /**
608 * Return a helper function to be added to the context for rendering the a
609 * template.
610 *
611 * This will parse the provided text before giving it to the helper function
972340b6 612 * in order to remove any disallowed nested helpers to prevent one helper
3fb0cbd4
RW
613 * from calling another.
614 *
615 * In particular to prevent the JS helper from being called from within another
616 * helper because it can lead to security issues when the JS portion is user
617 * provided.
618 *
619 * @param {function} helperFunction The helper function to add
620 * @param {object} context The template context for the helper function
621 * @return {Function} To be set in the context
622 */
623 Renderer.prototype.addHelperFunction = function(helperFunction, context) {
624 return function() {
625 return function(sectionText, helper) {
972340b6 626 // Override the disallowed helpers in the template context with
3fb0cbd4
RW
627 // a function that returns an empty string for use when executing
628 // other helpers. This is to prevent these helpers from being
629 // executed as part of the rendering of another helper in order to
630 // prevent any potential security issues.
972340b6 631 var originalHelpers = disallowedNestedHelpers.reduce(function(carry, name) {
3fb0cbd4
RW
632 if (context.hasOwnProperty(name)) {
633 carry[name] = context[name];
634 }
635
636 return carry;
637 }, {});
638
972340b6 639 disallowedNestedHelpers.forEach(function(helperName) {
3fb0cbd4
RW
640 context[helperName] = function() {
641 return '';
642 };
643 });
644
645 // Execute the helper with the modified context that doesn't include
972340b6 646 // the disallowed nested helpers. This prevents the disallowed
3fb0cbd4
RW
647 // helpers from being called from within other helpers.
648 var result = helperFunction.apply(this, [context, sectionText, helper]);
649
650 // Restore the original helper implementation in the context so that
651 // any further rendering has access to them again.
652 for (var name in originalHelpers) {
653 context[name] = originalHelpers[name];
654 }
655
656 return result;
657 }.bind(this);
658 }.bind(this);
659 };
660
9bdcf579
DW
661 /**
662 * Add some common helper functions to all context objects passed to templates.
663 * These helpers match exactly the helpers available in php.
664 *
665 * @method addHelpers
666 * @private
667 * @param {Object} context Simple types used as the context for the template.
668 * @param {String} themeName We set this multiple times, because there are async calls.
669 */
8d00afb1
DW
670 Renderer.prototype.addHelpers = function(context, themeName) {
671 this.currentThemeName = themeName;
672 this.requiredStrings = [];
673 this.requiredJS = [];
674 context.uniqid = (uniqInstances++);
3fb0cbd4
RW
675 context.str = this.addHelperFunction(this.stringHelper, context);
676 context.pix = this.addHelperFunction(this.pixHelper, context);
677 context.js = this.addHelperFunction(this.jsHelper, context);
678 context.quote = this.addHelperFunction(this.quoteHelper, context);
679 context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);
680 context.userdate = this.addHelperFunction(this.userDateHelper, context);
9f5f3dcc 681 context.globals = {config: config};
9bdcf579
DW
682 context.currentTheme = themeName;
683 };
684
685 /**
686 * Get all the JS blocks from the last rendered template.
687 *
688 * @method getJS
689 * @private
9bdcf579
DW
690 * @return {string}
691 */
0e5b3e28 692 Renderer.prototype.getJS = function() {
9bdcf579 693 var js = '';
8d00afb1
DW
694 if (this.requiredJS.length > 0) {
695 js = this.requiredJS.join(";\n");
9bdcf579
DW
696 }
697
0e5b3e28 698 return js;
29879f8f
FM
699 };
700
701 /**
702 * Treat strings in content.
703 *
704 * The purpose of this method is to replace the placeholders found in a string
705 * with the their respective translated strings.
706 *
707 * Previously we were relying on String.replace() but the complexity increased with
708 * the numbers of strings to replace. Now we manually walk the string and stop at each
709 * placeholder we find, only then we replace it. Most of the time we will
710 * replace all the placeholders in a single run, at times we will need a few
711 * more runs when placeholders are replaced with strings that contain placeholders
712 * themselves.
713 *
714 * @param {String} content The content in which string placeholders are to be found.
715 * @param {Array} strings The strings to replace with.
716 * @return {String} The treated content.
717 */
8d00afb1 718 Renderer.prototype.treatStringsInContent = function(content, strings) {
a89cf237 719 var pattern = /\[\[_s\d+\]\]/,
29879f8f
FM
720 treated,
721 index,
722 strIndex,
723 walker,
724 char,
725 strFinal;
726
727 do {
728 treated = '';
729 index = content.search(pattern);
730 while (index > -1) {
731
732 // Copy the part prior to the placeholder to the treated string.
733 treated += content.substring(0, index);
734 content = content.substr(index);
735 strIndex = '';
d9dff92f 736 walker = 4; // 4 is the length of '[[_s'.
29879f8f
FM
737
738 // Walk the characters to manually extract the index of the string from the placeholder.
739 char = content.substr(walker, 1);
740 do {
741 strIndex += char;
742 walker++;
743 char = content.substr(walker, 1);
a89cf237 744 } while (char != ']');
29879f8f
FM
745
746 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
747 strFinal = strings[parseInt(strIndex, 10)];
748 if (typeof strFinal === 'undefined') {
a89cf237 749 Log.debug('Could not find string for pattern [[_s' + strIndex + ']].');
29879f8f
FM
750 strFinal = '';
751 }
752 treated += strFinal;
d9dff92f 753 content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '[[_s]]'.
29879f8f
FM
754
755 // Find the next placeholder.
756 index = content.search(pattern);
757 }
758
759 // The content becomes the treated part with the rest of the content.
760 content = treated + content;
761
762 // Check if we need to walk the content again, in case strings contained placeholders.
763 index = content.search(pattern);
764
765 } while (index > -1);
766
767 return content;
9bdcf579
DW
768 };
769
0e5b3e28
RW
770 /**
771 * Treat strings in content.
772 *
773 * The purpose of this method is to replace the date placeholders found in the
774 * content with the their respective translated dates.
775 *
776 * @param {String} content The content in which string placeholders are to be found.
373d43ae 777 * @param {Array} dates The dates to replace with.
0e5b3e28
RW
778 * @return {String} The treated content.
779 */
780 Renderer.prototype.treatDatesInContent = function(content, dates) {
781 dates.forEach(function(date, index) {
782 var key = '\\[\\[_t_' + index + '\\]\\]';
783 var re = new RegExp(key, 'g');
784 content = content.replace(re, date);
785 });
786
787 return content;
788 };
789
9bdcf579
DW
790 /**
791 * Render a template and then call the callback with the result.
792 *
793 * @method doRender
794 * @private
795 * @param {string} templateSource The mustache template to render.
796 * @param {Object} context Simple types used as the context for the template.
797 * @param {String} themeName Name of the current theme.
798 * @return {Promise} object
799 */
8d00afb1 800 Renderer.prototype.doRender = function(templateSource, context, themeName) {
8d00afb1 801 this.currentThemeName = themeName;
e330b1c2 802 var iconTemplate = iconSystem.getTemplateName();
9bdcf579 803
eb514bb3 804 var pendingPromise = new Pending('core/templates:doRender');
e330b1c2 805 return this.getTemplate(iconTemplate).then(function() {
f20a336b
DW
806 this.addHelpers(context, themeName);
807 var result = mustache.render(templateSource, context, this.partialHelper.bind(this));
0e5b3e28
RW
808 return $.Deferred().resolve(result.trim(), this.getJS()).promise();
809 }.bind(this))
810 .then(function(html, js) {
f20a336b
DW
811 if (this.requiredStrings.length > 0) {
812 return str.get_strings(this.requiredStrings).then(function(strings) {
813
0e5b3e28
RW
814 // Make sure string substitutions are done for the userdate
815 // values as well.
816 this.requiredDates = this.requiredDates.map(function(date) {
817 return {
818 timestamp: this.treatStringsInContent(date.timestamp, strings),
819 format: this.treatStringsInContent(date.format, strings)
820 };
821 }.bind(this));
822
f20a336b
DW
823 // Why do we not do another call the render here?
824 //
825 // Because that would expose DOS holes. E.g.
826 // I create an assignment called "{{fish" which
827 // would get inserted in the template in the first pass
828 // and cause the template to die on the second pass (unbalanced).
0e5b3e28
RW
829 html = this.treatStringsInContent(html, strings);
830 js = this.treatStringsInContent(js, strings);
831 return $.Deferred().resolve(html, js).promise();
832 }.bind(this));
833 }
f20a336b 834
0e5b3e28
RW
835 return $.Deferred().resolve(html, js).promise();
836 }.bind(this))
837 .then(function(html, js) {
838 // This has to happen after the strings replacement because you can
839 // use the string helper in content for the user date helper.
840 if (this.requiredDates.length > 0) {
841 return UserDate.get(this.requiredDates).then(function(dates) {
842 html = this.treatDatesInContent(html, dates);
843 js = this.treatDatesInContent(js, dates);
844 return $.Deferred().resolve(html, js).promise();
f20a336b 845 }.bind(this));
f20a336b 846 }
0e5b3e28
RW
847
848 return $.Deferred().resolve(html, js).promise();
4b7ad884
AN
849 }.bind(this))
850 .then(function(html, js) {
eb514bb3 851 pendingPromise.resolve();
4b7ad884
AN
852 return $.Deferred().resolve(html, js).promise();
853 });
9bdcf579
DW
854 };
855
28de7771
DW
856 /**
857 * Execute a block of JS returned from a template.
858 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
859 *
860 * @method runTemplateJS
861 * @param {string} source - A block of javascript.
862 */
863 var runTemplateJS = function(source) {
864 if (source.trim() !== '') {
35be5826 865 var newscript = $('<script>').attr('type', 'text/javascript').html(source);
28de7771
DW
866 $('head').append(newscript);
867 }
868 };
869
870 /**
871 * Do some DOM replacement and trigger correct events and fire javascript.
872 *
873 * @method domReplace
874 * @private
875 * @param {JQuery} element - Element or selector to replace.
876 * @param {String} newHTML - HTML to insert / replace.
877 * @param {String} newJS - Javascript to run after the insertion.
878 * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
f4561955 879 * @return {Array} The list of new DOM Nodes
28de7771
DW
880 */
881 var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
882 var replaceNode = $(element);
883 if (replaceNode.length) {
884 // First create the dom nodes so we have a reference to them.
885 var newNodes = $(newHTML);
1fca8a7b 886 var yuiNodes = null;
28de7771
DW
887 // Do the replacement in the page.
888 if (replaceChildNodes) {
1fca8a7b
DW
889 // Cleanup any YUI event listeners attached to any of these nodes.
890 yuiNodes = new Y.NodeList(replaceNode.children().get());
891 yuiNodes.destroy(true);
892
893 // JQuery will cleanup after itself.
28de7771
DW
894 replaceNode.empty();
895 replaceNode.append(newNodes);
896 } else {
1fca8a7b
DW
897 // Cleanup any YUI event listeners attached to any of these nodes.
898 yuiNodes = new Y.NodeList(replaceNode.get());
899 yuiNodes.destroy(true);
900
901 // JQuery will cleanup after itself.
28de7771
DW
902 replaceNode.replaceWith(newNodes);
903 }
904 // Run any javascript associated with the new HTML.
905 runTemplateJS(newJS);
906 // Notify all filters about the new content.
907 event.notifyFilterContentUpdated(newNodes);
f4561955
AN
908
909 return newNodes.get();
28de7771 910 }
f4561955
AN
911
912 return [];
28de7771
DW
913 };
914
39bf2a98
DW
915 /**
916 * Scan a template source for partial tags and return a list of the found partials.
917 *
918 * @method scanForPartials
919 * @private
920 * @param {string} templateSource - source template to scan.
921 * @return {Array} List of partials.
922 */
923 Renderer.prototype.scanForPartials = function(templateSource) {
924 var tokens = mustache.parse(templateSource),
925 partials = [];
926
927 var findPartial = function(tokens, partials) {
928 var i, token;
929 for (i = 0; i < tokens.length; i++) {
930 token = tokens[i];
931 if (token[0] == '>' || token[0] == '<') {
932 partials.push(token[1]);
933 }
934 if (token.length > 4) {
935 findPartial(token[4], partials);
936 }
937 }
938 };
939
940 findPartial(tokens, partials);
941
942 return partials;
943 };
944
945 /**
946 * Load a template and scan it for partials. Recursively fetch the partials.
947 *
948 * @method cachePartials
949 * @private
950 * @param {string} templateName - should consist of the component and the name of the template like this:
951 * core/menu (lib/templates/menu.mustache) or
952 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
a2cb00b6 953 * @param {Array} parentage - A list of requested partials in this render chain.
39bf2a98
DW
954 * @return {Promise} JQuery promise object resolved when all partials are in the cache.
955 */
a2cb00b6 956 Renderer.prototype.cachePartials = function(templateName, parentage) {
0015f5b8
RW
957 var searchKey = this.currentThemeName + '/' + templateName;
958
959 if (searchKey in cachePartialPromises) {
960 return cachePartialPromises[searchKey];
961 }
962
61e0f58c
AN
963 // This promise will not be resolved until all child partials are also resolved and ready.
964 // We create it here to allow us to check for recursive inclusion of templates.
a2cb00b6 965 // Keep track of the requested partials in this chain.
23e347bb 966 parentage = parentage || [searchKey];
a2cb00b6 967
61e0f58c
AN
968 cachePartialPromises[searchKey] = $.Deferred();
969
970 this.getTemplate(templateName)
971 .then(function(templateSource) {
39bf2a98 972 var partials = this.scanForPartials(templateSource);
b51c5b92 973 var uniquePartials = partials.filter(function(partialName) {
61e0f58c
AN
974 // Check for recursion.
975
a2cb00b6
DW
976 if (parentage.indexOf(this.currentThemeName + '/' + partialName) >= 0) {
977 // Ignore templates which include a parent template already requested in the current chain.
61e0f58c
AN
978 return false;
979 }
980
981 // Ignore templates that include themselves.
b51c5b92 982 return partialName != templateName;
61e0f58c
AN
983 }.bind(this));
984
985 // Fetch any partial which has not already been fetched.
b51c5b92 986 var fetchThemAll = uniquePartials.map(function(partialName) {
a2cb00b6
DW
987 parentage.push(this.currentThemeName + '/' + partialName);
988 return this.cachePartials(partialName, parentage);
0015f5b8 989 }.bind(this));
39bf2a98 990
61e0f58c
AN
991 // Resolve the templateName promise when all of the children are resolved.
992 return $.when.apply($, fetchThemAll)
993 .then(function() {
994 return cachePartialPromises[searchKey].resolve(templateSource);
b302369d 995 });
61e0f58c
AN
996 }.bind(this))
997 .catch(cachePartialPromises[searchKey].reject);
0015f5b8 998
61e0f58c 999 return cachePartialPromises[searchKey];
39bf2a98
DW
1000 };
1001
8d00afb1
DW
1002 /**
1003 * Load a template and call doRender on it.
1004 *
1005 * @method render
1006 * @private
1007 * @param {string} templateName - should consist of the component and the name of the template like this:
1008 * core/menu (lib/templates/menu.mustache) or
1009 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
1010 * @param {Object} context - Could be array, string or simple value for the context of the template.
1011 * @param {string} themeName - Name of the current theme.
1012 * @return {Promise} JQuery promise object resolved when the template has been rendered.
1013 */
1014 Renderer.prototype.render = function(templateName, context, themeName) {
8d00afb1
DW
1015 if (typeof (themeName) === "undefined") {
1016 // System context by default.
1017 themeName = config.theme;
1018 }
1019
1020 this.currentThemeName = themeName;
1021
95b06c13 1022 // Preload the module to do the icon rendering based on the theme iconsystem.
e330b1c2 1023 var modulename = config.iconsystemmodule;
95b06c13
DW
1024
1025 var ready = $.Deferred();
1026 require([modulename], function(System) {
1027 var system = new System();
1028 if (!(system instanceof IconSystem)) {
1029 ready.reject('Invalid icon system specified' + config.iconsystem);
1030 } else {
1031 iconSystem = system;
751ec025 1032 system.init().then(ready.resolve).catch(notification.exception);
95b06c13
DW
1033 }
1034 });
1035
1036 return ready.then(function() {
1037 return this.cachePartials(templateName);
1038 }.bind(this)).then(function(templateSource) {
1039 return this.doRender(templateSource, context, themeName);
1040 }.bind(this));
8d00afb1
DW
1041 };
1042
90525930
MN
1043 /**
1044 * Prepend some HTML to a node and trigger events and fire javascript.
1045 *
1046 * @method domPrepend
1047 * @private
1048 * @param {jQuery|String} element - Element or selector to prepend HTML to
1049 * @param {String} html - HTML to prepend
1050 * @param {String} js - Javascript to run after we prepend the html
f4561955 1051 * @return {Array} The list of new DOM Nodes
90525930
MN
1052 */
1053 var domPrepend = function(element, html, js) {
1054 var node = $(element);
1055 if (node.length) {
1056 // Prepend the html.
f4561955
AN
1057 var newContent = $(html);
1058 node.prepend(newContent);
90525930
MN
1059 // Run any javascript associated with the new HTML.
1060 runTemplateJS(js);
1061 // Notify all filters about the new content.
1062 event.notifyFilterContentUpdated(node);
f4561955
AN
1063
1064 return newContent.get();
90525930 1065 }
f4561955
AN
1066
1067 return [];
90525930 1068 };
28de7771 1069
f7775c9a
MN
1070 /**
1071 * Append some HTML to a node and trigger events and fire javascript.
1072 *
1073 * @method domAppend
1074 * @private
1075 * @param {jQuery|String} element - Element or selector to append HTML to
1076 * @param {String} html - HTML to append
1077 * @param {String} js - Javascript to run after we append the html
f4561955 1078 * @return {Array} The list of new DOM Nodes
f7775c9a
MN
1079 */
1080 var domAppend = function(element, html, js) {
1081 var node = $(element);
1082 if (node.length) {
1083 // Append the html.
f4561955
AN
1084 var newContent = $(html);
1085 node.append(newContent);
f7775c9a
MN
1086 // Run any javascript associated with the new HTML.
1087 runTemplateJS(js);
1088 // Notify all filters about the new content.
1089 event.notifyFilterContentUpdated(node);
f4561955
AN
1090
1091 return newContent.get();
f7775c9a 1092 }
f4561955
AN
1093
1094 return [];
f7775c9a
MN
1095 };
1096
9bdcf579
DW
1097 return /** @alias module:core/templates */ {
1098 // Public variables and functions.
1099 /**
8d00afb1
DW
1100 * Every call to render creates a new instance of the class and calls render on it. This
1101 * means each render call has it's own class variables.
9bdcf579
DW
1102 *
1103 * @method render
1104 * @private
1105 * @param {string} templateName - should consist of the component and the name of the template like this:
1106 * core/menu (lib/templates/menu.mustache) or
1107 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
1108 * @param {Object} context - Could be array, string or simple value for the context of the template.
1109 * @param {string} themeName - Name of the current theme.
1110 * @return {Promise} JQuery promise object resolved when the template has been rendered.
1111 */
1112 render: function(templateName, context, themeName) {
8d00afb1
DW
1113 var renderer = new Renderer();
1114 return renderer.render(templateName, context, themeName);
9bdcf579
DW
1115 },
1116
983baf4e
AN
1117 /**
1118 * Prefetch a set of templates without rendering them.
1119 *
1120 * @method getTemplate
1121 * @param {Array} templateNames The list of templates to fetch
1122 * @param {String} themeName
1123 * @returns {Promise}
1124 */
1125 prefetchTemplates: function(templateNames, themeName) {
1126 var renderer = new Renderer();
1127
1128 if (typeof themeName === "undefined") {
1129 // System context by default.
1130 themeName = config.theme;
1131 }
1132
1133 return renderer.prefetchTemplates(templateNames, themeName);
1134 },
1135
373d43ae
AN
1136 /**
1137 * Every call to render creates a new instance of the class and calls render on it. This
1138 * means each render call has it's own class variables.
1139 *
1140 * This alernate to the standard .render() function returns the html and js in a single object suitable for a
1141 * native Promise.
1142 *
1143 * @method renderForPromise
1144 * @private
1145 * @param {string} templateName - should consist of the component and the name of the template like this:
1146 * core/menu (lib/templates/menu.mustache) or
1147 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
1148 * @param {Object} context - Could be array, string or simple value for the context of the template.
1149 * @param {string} themeName - Name of the current theme.
1150 * @return {Promise} JQuery promise object resolved when the template has been rendered.
1151 */
1152 renderForPromise: function(templateName, context, themeName) {
1153 var renderer = new Renderer();
1154 return renderer.render(templateName, context, themeName)
1155 .then(function(html, js) {
1156 return {
1157 html: html,
1158 js: js,
1159 };
1160 });
1161 },
1162
95b06c13
DW
1163 /**
1164 * Every call to renderIcon creates a new instance of the class and calls renderIcon on it. This
1165 * means each render call has it's own class variables.
1166 *
1167 * @method renderIcon
1168 * @public
1169 * @param {string} key - Icon key.
1170 * @param {string} component - Icon component
1171 * @param {string} title - Icon title
1172 * @return {Promise} JQuery promise object resolved when the pix has been rendered.
1173 */
1174 renderPix: function(key, component, title) {
1175 var renderer = new Renderer();
1176 return renderer.renderIcon(key, component, title);
1177 },
1178
9bdcf579
DW
1179 /**
1180 * Execute a block of JS returned from a template.
1181 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
1182 *
1183 * @method runTemplateJS
9bdcf579
DW
1184 * @param {string} source - A block of javascript.
1185 */
28de7771
DW
1186 runTemplateJS: runTemplateJS,
1187
1188 /**
1189 * Replace a node in the page with some new HTML and run the JS.
1190 *
1191 * @method replaceNodeContents
c96f55e6
DP
1192 * @param {JQuery} element - Element or selector to replace.
1193 * @param {String} newHTML - HTML to insert / replace.
1194 * @param {String} newJS - Javascript to run after the insertion.
f4561955 1195 * @return {Array} The list of new DOM Nodes
28de7771
DW
1196 */
1197 replaceNodeContents: function(element, newHTML, newJS) {
f4561955 1198 return domReplace(element, newHTML, newJS, true);
28de7771
DW
1199 },
1200
1201 /**
1202 * Insert a node in the page with some new HTML and run the JS.
1203 *
1204 * @method replaceNode
c96f55e6
DP
1205 * @param {JQuery} element - Element or selector to replace.
1206 * @param {String} newHTML - HTML to insert / replace.
1207 * @param {String} newJS - Javascript to run after the insertion.
f4561955 1208 * @return {Array} The list of new DOM Nodes
28de7771
DW
1209 */
1210 replaceNode: function(element, newHTML, newJS) {
f4561955 1211 return domReplace(element, newHTML, newJS, false);
f7775c9a
MN
1212 },
1213
90525930
MN
1214 /**
1215 * Prepend some HTML to a node and trigger events and fire javascript.
1216 *
1217 * @method prependNodeContents
1218 * @param {jQuery|String} element - Element or selector to prepend HTML to
1219 * @param {String} html - HTML to prepend
1220 * @param {String} js - Javascript to run after we prepend the html
f4561955 1221 * @return {Array} The list of new DOM Nodes
90525930
MN
1222 */
1223 prependNodeContents: function(element, html, js) {
f4561955 1224 return domPrepend(element, html, js);
90525930
MN
1225 },
1226
f7775c9a
MN
1227 /**
1228 * Append some HTML to a node and trigger events and fire javascript.
1229 *
1230 * @method appendNodeContents
1231 * @param {jQuery|String} element - Element or selector to append HTML to
1232 * @param {String} html - HTML to append
1233 * @param {String} js - Javascript to run after we append the html
f4561955 1234 * @return {Array} The list of new DOM Nodes
f7775c9a
MN
1235 */
1236 appendNodeContents: function(element, html, js) {
f4561955 1237 return domAppend(element, html, js);
373d43ae 1238 },
9bdcf579
DW
1239 };
1240});