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