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