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