Merge branch 'MDL-54915-master' of git://github.com/andrewnicols/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 */
35be5826 26define(['core/mustache',
9bdcf579
DW
27 'jquery',
28 'core/ajax',
29 'core/str',
30 'core/notification',
31 'core/url',
4b9e5326 32 'core/config',
28de7771 33 'core/localstorage',
1fca8a7b 34 'core/event',
29879f8f
FM
35 'core/yui',
36 'core/log'
9bdcf579 37 ],
29879f8f 38 function(mustache, $, ajax, str, notification, coreurl, config, storage, event, Y, Log) {
9bdcf579
DW
39
40 // Private variables and functions.
41
42 /** @var {string[]} templateCache - Cache of already loaded templates */
43 var templateCache = {};
44
45 /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
46 var requiredStrings = [];
47
9bdcf579
DW
48 /** @var {Number} uniqid Incrementing value that is changed for every call to render */
49 var uniqid = 1;
50
51 /** @var {String} themeName for the current render */
52 var currentThemeName = '';
53
f992dcf6
DP
54 /**
55 * Load a template from the cache or local storage or ajax request.
56 *
57 * @method getTemplate
58 * @private
59 * @param {string} templateName - should consist of the component and the name of the template like this:
60 * core/menu (lib/templates/menu.mustache) or
61 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
c96f55e6 62 * @param {Boolean} async If false - this function will not return until the promises are resolved.
f992dcf6
DP
63 * @return {Promise} JQuery promise object resolved when the template has been fetched.
64 */
65 var getTemplate = function(templateName, async) {
66 var deferred = $.Deferred();
67 var parts = templateName.split('/');
68 var component = parts.shift();
69 var name = parts.shift();
70
71 var searchKey = currentThemeName + '/' + templateName;
72
73 // First try request variables.
74 if (searchKey in templateCache) {
75 deferred.resolve(templateCache[searchKey]);
76 return deferred.promise();
77 }
78
79 // Now try local storage.
80 var cached = storage.get('core_template/' + searchKey);
81
82 if (cached) {
83 deferred.resolve(cached);
84 templateCache[searchKey] = cached;
85 return deferred.promise();
86 }
87
88 // Oh well - load via ajax.
89 var promises = ajax.call([{
90 methodname: 'core_output_load_template',
35be5826 91 args: {
f992dcf6
DP
92 component: component,
93 template: name,
94 themename: currentThemeName
95 }
96 }], async, false);
97
98 promises[0].done(
35be5826 99 function(templateSource) {
f992dcf6
DP
100 storage.set('core_template/' + searchKey, templateSource);
101 templateCache[searchKey] = templateSource;
102 deferred.resolve(templateSource);
103 }
104 ).fail(
35be5826 105 function(ex) {
f992dcf6
DP
106 deferred.reject(ex);
107 }
108 );
109 return deferred.promise();
110 };
111
112 /**
113 * Load a partial from the cache or ajax.
114 *
115 * @method partialHelper
116 * @private
117 * @param {string} name The partial name to load.
118 * @return {string}
119 */
120 var partialHelper = function(name) {
121 var template = '';
122
123 getTemplate(name, false).done(
124 function(source) {
125 template = source;
126 }
127 ).fail(notification.exception);
128
129 return template;
130 };
131
9bdcf579
DW
132 /**
133 * Render image icons.
134 *
135 * @method pixHelper
136 * @private
137 * @param {string} sectionText The text to parse arguments from.
e37d53da 138 * @param {function} helper Used to render the alt attribute of the text.
9bdcf579
DW
139 * @return {string}
140 */
e37d53da 141 var pixHelper = function(sectionText, helper) {
9bdcf579
DW
142 var parts = sectionText.split(',');
143 var key = '';
144 var component = '';
145 var text = '';
146 var result;
147
148 if (parts.length > 0) {
149 key = parts.shift().trim();
150 }
151 if (parts.length > 0) {
152 component = parts.shift().trim();
153 }
154 if (parts.length > 0) {
155 text = parts.join(',').trim();
156 }
157 var url = coreurl.imageUrl(key, component);
158
159 var templatecontext = {
160 attributes: [
9f5f3dcc
DP
161 {name: 'src', value: url},
162 {name: 'alt', value: helper(text)},
163 {name: 'class', value: 'smallicon'}
9bdcf579
DW
164 ]
165 };
166 // We forced loading of this early, so it will be in the cache.
167 var template = templateCache[currentThemeName + '/core/pix_icon'];
168 result = mustache.render(template, templatecontext, partialHelper);
169 return result.trim();
170 };
171
9bdcf579
DW
172 /**
173 * Render blocks of javascript and save them in an array.
174 *
175 * @method jsHelper
176 * @private
177 * @param {string} sectionText The text to save as a js block.
178 * @param {function} helper Used to render the block.
179 * @return {string}
180 */
181 var jsHelper = function(sectionText, helper) {
9a60d5f9 182 this.jsBlocks.push(helper(sectionText, this));
9bdcf579
DW
183 return '';
184 };
185
186 /**
187 * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
188 * into a get_string call.
189 *
190 * @method stringHelper
191 * @private
192 * @param {string} sectionText The text to parse the arguments from.
193 * @param {function} helper Used to render subsections of the text.
194 * @return {string}
195 */
196 var stringHelper = function(sectionText, helper) {
197 var parts = sectionText.split(',');
198 var key = '';
199 var component = '';
200 var param = '';
201 if (parts.length > 0) {
202 key = parts.shift().trim();
203 }
204 if (parts.length > 0) {
205 component = parts.shift().trim();
206 }
207 if (parts.length > 0) {
208 param = parts.join(',').trim();
209 }
210
211 if (param !== '') {
212 // Allow variable expansion in the param part only.
213 param = helper(param, this);
214 }
215 // Allow json formatted $a arguments.
216 if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
217 param = JSON.parse(param);
218 }
219
220 var index = requiredStrings.length;
221 requiredStrings.push({key: key, component: component, param: param});
222 return '{{_s' + index + '}}';
223 };
224
0b4bff8c
AN
225 /**
226 * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
227 *
228 * @method quoteHelper
229 * @private
230 * @param {string} sectionText The text to parse the arguments from.
231 * @param {function} helper Used to render subsections of the text.
232 * @return {string}
233 */
234 var quoteHelper = function(sectionText, helper) {
235 var content = helper(sectionText.trim(), this);
236
237 // Escape the {{ and the ".
238 // This involves wrapping {{, and }} in change delimeter tags.
239 content = content
240 .replace('"', '\\"')
241 .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
242 ;
243 return '"' + content + '"';
244 };
245
9bdcf579
DW
246 /**
247 * Add some common helper functions to all context objects passed to templates.
248 * These helpers match exactly the helpers available in php.
249 *
250 * @method addHelpers
251 * @private
252 * @param {Object} context Simple types used as the context for the template.
253 * @param {String} themeName We set this multiple times, because there are async calls.
254 */
255 var addHelpers = function(context, themeName) {
256 currentThemeName = themeName;
257 requiredStrings = [];
9bdcf579 258 context.uniqid = uniqid++;
c96f55e6
DP
259 context.str = function() {
260 return stringHelper;
261 };
262 context.pix = function() {
263 return pixHelper;
264 };
265 context.js = function() {
266 return jsHelper;
267 };
268 context.quote = function() {
269 return quoteHelper;
270 };
9f5f3dcc 271 context.globals = {config: config};
9a60d5f9 272 context.jsBlocks = [];
9bdcf579
DW
273 context.currentTheme = themeName;
274 };
275
276 /**
277 * Get all the JS blocks from the last rendered template.
278 *
279 * @method getJS
280 * @private
281 * @param {string[]} strings Replacement strings.
282 * @return {string}
283 */
284 var getJS = function(strings) {
285 var js = '';
9a60d5f9
AN
286 if (this.jsBlocks.length > 0) {
287 js = this.jsBlocks.join(";\n");
9bdcf579
DW
288 }
289
9bdcf579 290 // Re-render to get the final strings.
29879f8f
FM
291 return treatStringsInContent(js, strings);
292 };
293
294 /**
295 * Treat strings in content.
296 *
297 * The purpose of this method is to replace the placeholders found in a string
298 * with the their respective translated strings.
299 *
300 * Previously we were relying on String.replace() but the complexity increased with
301 * the numbers of strings to replace. Now we manually walk the string and stop at each
302 * placeholder we find, only then we replace it. Most of the time we will
303 * replace all the placeholders in a single run, at times we will need a few
304 * more runs when placeholders are replaced with strings that contain placeholders
305 * themselves.
306 *
307 * @param {String} content The content in which string placeholders are to be found.
308 * @param {Array} strings The strings to replace with.
309 * @return {String} The treated content.
310 */
311 var treatStringsInContent = function(content, strings) {
312 var pattern = /{{_s\d+}}/,
313 treated,
314 index,
315 strIndex,
316 walker,
317 char,
318 strFinal;
319
320 do {
321 treated = '';
322 index = content.search(pattern);
323 while (index > -1) {
324
325 // Copy the part prior to the placeholder to the treated string.
326 treated += content.substring(0, index);
327 content = content.substr(index);
328 strIndex = '';
329 walker = 4; // 4 is the length of '{{_s'.
330
331 // Walk the characters to manually extract the index of the string from the placeholder.
332 char = content.substr(walker, 1);
333 do {
334 strIndex += char;
335 walker++;
336 char = content.substr(walker, 1);
337 } while (char != '}');
338
339 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
340 strFinal = strings[parseInt(strIndex, 10)];
341 if (typeof strFinal === 'undefined') {
342 Log.debug('Could not find string for pattern {{_s' + strIndex + '}}.');
343 strFinal = '';
344 }
345 treated += strFinal;
346 content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '{{_s}}'.
347
348 // Find the next placeholder.
349 index = content.search(pattern);
350 }
351
352 // The content becomes the treated part with the rest of the content.
353 content = treated + content;
354
355 // Check if we need to walk the content again, in case strings contained placeholders.
356 index = content.search(pattern);
357
358 } while (index > -1);
359
360 return content;
9bdcf579
DW
361 };
362
363 /**
364 * Render a template and then call the callback with the result.
365 *
366 * @method doRender
367 * @private
368 * @param {string} templateSource The mustache template to render.
369 * @param {Object} context Simple types used as the context for the template.
370 * @param {String} themeName Name of the current theme.
371 * @return {Promise} object
372 */
373 var doRender = function(templateSource, context, themeName) {
374 var deferred = $.Deferred();
375
376 currentThemeName = themeName;
377
378 // Make sure we fetch this first.
379 var loadPixTemplate = getTemplate('core/pix_icon', true);
380
381 loadPixTemplate.done(
382 function() {
383 addHelpers(context, themeName);
384 var result = '';
385 try {
386 result = mustache.render(templateSource, context, partialHelper);
387 } catch (ex) {
388 deferred.reject(ex);
389 }
390
391 if (requiredStrings.length > 0) {
29879f8f
FM
392 str.get_strings(requiredStrings)
393 .then(function(strings) {
394
395 // Why do we not do another call the render here?
396 //
397 // Because that would expose DOS holes. E.g.
398 // I create an assignment called "{{fish" which
399 // would get inserted in the template in the first pass
400 // and cause the template to die on the second pass (unbalanced).
401
402 result = treatStringsInContent(result, strings);
9a60d5f9 403 deferred.resolve(result, getJS.bind(context)(strings));
29879f8f
FM
404 })
405 .fail(deferred.reject);
9bdcf579 406 } else {
9a60d5f9 407 deferred.resolve(result.trim(), getJS.bind(context)([]));
9bdcf579
DW
408 }
409 }
29879f8f 410 ).fail(deferred.reject);
9bdcf579
DW
411 return deferred.promise();
412 };
413
28de7771
DW
414 /**
415 * Execute a block of JS returned from a template.
416 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
417 *
418 * @method runTemplateJS
419 * @param {string} source - A block of javascript.
420 */
421 var runTemplateJS = function(source) {
422 if (source.trim() !== '') {
35be5826 423 var newscript = $('<script>').attr('type', 'text/javascript').html(source);
28de7771
DW
424 $('head').append(newscript);
425 }
426 };
427
428 /**
429 * Do some DOM replacement and trigger correct events and fire javascript.
430 *
431 * @method domReplace
432 * @private
433 * @param {JQuery} element - Element or selector to replace.
434 * @param {String} newHTML - HTML to insert / replace.
435 * @param {String} newJS - Javascript to run after the insertion.
436 * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
437 */
438 var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
439 var replaceNode = $(element);
440 if (replaceNode.length) {
441 // First create the dom nodes so we have a reference to them.
442 var newNodes = $(newHTML);
1fca8a7b 443 var yuiNodes = null;
28de7771
DW
444 // Do the replacement in the page.
445 if (replaceChildNodes) {
1fca8a7b
DW
446 // Cleanup any YUI event listeners attached to any of these nodes.
447 yuiNodes = new Y.NodeList(replaceNode.children().get());
448 yuiNodes.destroy(true);
449
450 // JQuery will cleanup after itself.
28de7771
DW
451 replaceNode.empty();
452 replaceNode.append(newNodes);
453 } else {
1fca8a7b
DW
454 // Cleanup any YUI event listeners attached to any of these nodes.
455 yuiNodes = new Y.NodeList(replaceNode.get());
456 yuiNodes.destroy(true);
457
458 // JQuery will cleanup after itself.
28de7771
DW
459 replaceNode.replaceWith(newNodes);
460 }
461 // Run any javascript associated with the new HTML.
462 runTemplateJS(newJS);
463 // Notify all filters about the new content.
464 event.notifyFilterContentUpdated(newNodes);
465 }
466 };
467
468
9bdcf579
DW
469 return /** @alias module:core/templates */ {
470 // Public variables and functions.
471 /**
472 * Load a template and call doRender on it.
473 *
474 * @method render
475 * @private
476 * @param {string} templateName - should consist of the component and the name of the template like this:
477 * core/menu (lib/templates/menu.mustache) or
478 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
479 * @param {Object} context - Could be array, string or simple value for the context of the template.
480 * @param {string} themeName - Name of the current theme.
481 * @return {Promise} JQuery promise object resolved when the template has been rendered.
482 */
483 render: function(templateName, context, themeName) {
484 var deferred = $.Deferred();
485
486 if (typeof (themeName) === "undefined") {
487 // System context by default.
488 themeName = config.theme;
489 }
490
491 currentThemeName = themeName;
492
493 var loadTemplate = getTemplate(templateName, true);
494
495 loadTemplate.done(
496 function(templateSource) {
497 var renderPromise = doRender(templateSource, context, themeName);
498
499 renderPromise.done(
500 function(result, js) {
501 deferred.resolve(result, js);
502 }
503 ).fail(
504 function(ex) {
505 deferred.reject(ex);
506 }
507 );
508 }
509 ).fail(
510 function(ex) {
511 deferred.reject(ex);
512 }
513 );
514 return deferred.promise();
515 },
516
517 /**
518 * Execute a block of JS returned from a template.
519 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
520 *
521 * @method runTemplateJS
9bdcf579
DW
522 * @param {string} source - A block of javascript.
523 */
28de7771
DW
524 runTemplateJS: runTemplateJS,
525
526 /**
527 * Replace a node in the page with some new HTML and run the JS.
528 *
529 * @method replaceNodeContents
c96f55e6
DP
530 * @param {JQuery} element - Element or selector to replace.
531 * @param {String} newHTML - HTML to insert / replace.
532 * @param {String} newJS - Javascript to run after the insertion.
28de7771
DW
533 */
534 replaceNodeContents: function(element, newHTML, newJS) {
c96f55e6 535 domReplace(element, newHTML, newJS, true);
28de7771
DW
536 },
537
538 /**
539 * Insert a node in the page with some new HTML and run the JS.
540 *
541 * @method replaceNode
c96f55e6
DP
542 * @param {JQuery} element - Element or selector to replace.
543 * @param {String} newHTML - HTML to insert / replace.
544 * @param {String} newJS - Javascript to run after the insertion.
28de7771
DW
545 */
546 replaceNode: function(element, newHTML, newJS) {
c96f55e6 547 domReplace(element, newHTML, newJS, false);
9bdcf579
DW
548 }
549 };
550});