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