MDL-51582 grunt: task improvements
[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 */
26define([ 'core/mustache',
27 'jquery',
28 'core/ajax',
29 'core/str',
30 'core/notification',
31 'core/url',
4b9e5326 32 'core/config',
28de7771
DW
33 'core/localstorage',
34 'core/event'
9bdcf579 35 ],
28de7771 36 function(mustache, $, ajax, str, notification, coreurl, config, storage, event) {
9bdcf579
DW
37
38 // Private variables and functions.
39
40 /** @var {string[]} templateCache - Cache of already loaded templates */
41 var templateCache = {};
42
43 /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
44 var requiredStrings = [];
45
46 /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
47 var requiredJS = [];
48
49 /** @var {Number} uniqid Incrementing value that is changed for every call to render */
50 var uniqid = 1;
51
52 /** @var {String} themeName for the current render */
53 var currentThemeName = '';
54
55 /**
56 * Render image icons.
57 *
58 * @method pixHelper
59 * @private
60 * @param {string} sectionText The text to parse arguments from.
e37d53da 61 * @param {function} helper Used to render the alt attribute of the text.
9bdcf579
DW
62 * @return {string}
63 */
e37d53da 64 var pixHelper = function(sectionText, helper) {
9bdcf579
DW
65 var parts = sectionText.split(',');
66 var key = '';
67 var component = '';
68 var text = '';
69 var result;
70
71 if (parts.length > 0) {
72 key = parts.shift().trim();
73 }
74 if (parts.length > 0) {
75 component = parts.shift().trim();
76 }
77 if (parts.length > 0) {
78 text = parts.join(',').trim();
79 }
80 var url = coreurl.imageUrl(key, component);
81
82 var templatecontext = {
83 attributes: [
84 { name: 'src', value: url},
e37d53da 85 { name: 'alt', value: helper(text)},
9bdcf579
DW
86 { name: 'class', value: 'smallicon'}
87 ]
88 };
89 // We forced loading of this early, so it will be in the cache.
90 var template = templateCache[currentThemeName + '/core/pix_icon'];
91 result = mustache.render(template, templatecontext, partialHelper);
92 return result.trim();
93 };
94
95 /**
96 * Load a partial from the cache or ajax.
97 *
98 * @method partialHelper
99 * @private
100 * @param {string} name The partial name to load.
101 * @return {string}
102 */
103 var partialHelper = function(name) {
104 var template = '';
105
106 getTemplate(name, false).done(
107 function(source) {
108 template = source;
109 }
110 ).fail(notification.exception);
111
112 return template;
113 };
114
115 /**
116 * Render blocks of javascript and save them in an array.
117 *
118 * @method jsHelper
119 * @private
120 * @param {string} sectionText The text to save as a js block.
121 * @param {function} helper Used to render the block.
122 * @return {string}
123 */
124 var jsHelper = function(sectionText, helper) {
125 requiredJS.push(helper(sectionText, this));
126 return '';
127 };
128
129 /**
130 * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
131 * into a get_string call.
132 *
133 * @method stringHelper
134 * @private
135 * @param {string} sectionText The text to parse the arguments from.
136 * @param {function} helper Used to render subsections of the text.
137 * @return {string}
138 */
139 var stringHelper = function(sectionText, helper) {
140 var parts = sectionText.split(',');
141 var key = '';
142 var component = '';
143 var param = '';
144 if (parts.length > 0) {
145 key = parts.shift().trim();
146 }
147 if (parts.length > 0) {
148 component = parts.shift().trim();
149 }
150 if (parts.length > 0) {
151 param = parts.join(',').trim();
152 }
153
154 if (param !== '') {
155 // Allow variable expansion in the param part only.
156 param = helper(param, this);
157 }
158 // Allow json formatted $a arguments.
159 if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
160 param = JSON.parse(param);
161 }
162
163 var index = requiredStrings.length;
164 requiredStrings.push({key: key, component: component, param: param});
165 return '{{_s' + index + '}}';
166 };
167
168 /**
169 * Add some common helper functions to all context objects passed to templates.
170 * These helpers match exactly the helpers available in php.
171 *
172 * @method addHelpers
173 * @private
174 * @param {Object} context Simple types used as the context for the template.
175 * @param {String} themeName We set this multiple times, because there are async calls.
176 */
177 var addHelpers = function(context, themeName) {
178 currentThemeName = themeName;
179 requiredStrings = [];
180 requiredJS = [];
181 context.uniqid = uniqid++;
182 context.str = function() { return stringHelper; };
183 context.pix = function() { return pixHelper; };
184 context.js = function() { return jsHelper; };
185 context.globals = { config : config };
186 context.currentTheme = themeName;
187 };
188
189 /**
190 * Get all the JS blocks from the last rendered template.
191 *
192 * @method getJS
193 * @private
194 * @param {string[]} strings Replacement strings.
195 * @return {string}
196 */
197 var getJS = function(strings) {
198 var js = '';
199 if (requiredJS.length > 0) {
200 js = requiredJS.join(";\n");
201 }
202
203 var i = 0;
204
205 for (i = 0; i < strings.length; i++) {
206 js = js.replace('{{_s' + i + '}}', strings[i]);
207 }
208 // Re-render to get the final strings.
209 return js;
210 };
211
212 /**
213 * Render a template and then call the callback with the result.
214 *
215 * @method doRender
216 * @private
217 * @param {string} templateSource The mustache template to render.
218 * @param {Object} context Simple types used as the context for the template.
219 * @param {String} themeName Name of the current theme.
220 * @return {Promise} object
221 */
222 var doRender = function(templateSource, context, themeName) {
223 var deferred = $.Deferred();
224
225 currentThemeName = themeName;
226
227 // Make sure we fetch this first.
228 var loadPixTemplate = getTemplate('core/pix_icon', true);
229
230 loadPixTemplate.done(
231 function() {
232 addHelpers(context, themeName);
233 var result = '';
234 try {
235 result = mustache.render(templateSource, context, partialHelper);
236 } catch (ex) {
237 deferred.reject(ex);
238 }
239
240 if (requiredStrings.length > 0) {
241 str.get_strings(requiredStrings).done(
242 function(strings) {
243 var i;
244
245 // Why do we not do another call the render here?
246 //
247 // Because that would expose DOS holes. E.g.
248 // I create an assignment called "{{fish" which
249 // would get inserted in the template in the first pass
250 // and cause the template to die on the second pass (unbalanced).
251 for (i = 0; i < strings.length; i++) {
252 result = result.replace('{{_s' + i + '}}', strings[i]);
253 }
254 deferred.resolve(result.trim(), getJS(strings));
255 }
256 ).fail(
257 function(ex) {
258 deferred.reject(ex);
259 }
260 );
261 } else {
262 deferred.resolve(result.trim(), getJS([]));
263 }
264 }
265 ).fail(
266 function(ex) {
267 deferred.reject(ex);
268 }
269 );
270 return deferred.promise();
271 };
272
273 /**
4b9e5326 274 * Load a template from the cache or local storage or ajax request.
9bdcf579
DW
275 *
276 * @method getTemplate
277 * @private
278 * @param {string} templateName - should consist of the component and the name of the template like this:
279 * core/menu (lib/templates/menu.mustache) or
280 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
281 * @return {Promise} JQuery promise object resolved when the template has been fetched.
282 */
283 var getTemplate = function(templateName, async) {
284 var deferred = $.Deferred();
285 var parts = templateName.split('/');
286 var component = parts.shift();
287 var name = parts.shift();
288
289 var searchKey = currentThemeName + '/' + templateName;
290
4b9e5326 291 // First try request variables.
9bdcf579
DW
292 if (searchKey in templateCache) {
293 deferred.resolve(templateCache[searchKey]);
4b9e5326 294 return deferred.promise();
9bdcf579 295 }
4b9e5326
DW
296
297 // Now try local storage.
298 var cached = storage.get('core_template/' + searchKey);
299
300 if (cached) {
301 deferred.resolve(cached);
e0d08bfa 302 templateCache[searchKey] = cached;
4b9e5326
DW
303 return deferred.promise();
304 }
305
306 // Oh well - load via ajax.
307 var promises = ajax.call([{
308 methodname: 'core_output_load_template',
309 args:{
310 component: component,
311 template: name,
312 themename: currentThemeName
313 }
ba224fb4 314 }], async, false);
4b9e5326
DW
315
316 promises[0].done(
317 function (templateSource) {
318 storage.set('core_template/' + searchKey, templateSource);
319 templateCache[searchKey] = templateSource;
320 deferred.resolve(templateSource);
321 }
322 ).fail(
323 function (ex) {
324 deferred.reject(ex);
325 }
326 );
9bdcf579
DW
327 return deferred.promise();
328 };
329
28de7771
DW
330 /**
331 * Execute a block of JS returned from a template.
332 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
333 *
334 * @method runTemplateJS
335 * @param {string} source - A block of javascript.
336 */
337 var runTemplateJS = function(source) {
338 if (source.trim() !== '') {
339 var newscript = $('<script>').attr('type','text/javascript').html(source);
340 $('head').append(newscript);
341 }
342 };
343
344 /**
345 * Do some DOM replacement and trigger correct events and fire javascript.
346 *
347 * @method domReplace
348 * @private
349 * @param {JQuery} element - Element or selector to replace.
350 * @param {String} newHTML - HTML to insert / replace.
351 * @param {String} newJS - Javascript to run after the insertion.
352 * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
353 */
354 var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
355 var replaceNode = $(element);
356 if (replaceNode.length) {
357 // First create the dom nodes so we have a reference to them.
358 var newNodes = $(newHTML);
359 // Do the replacement in the page.
360 if (replaceChildNodes) {
361 replaceNode.empty();
362 replaceNode.append(newNodes);
363 } else {
364 replaceNode.replaceWith(newNodes);
365 }
366 // Run any javascript associated with the new HTML.
367 runTemplateJS(newJS);
368 // Notify all filters about the new content.
369 event.notifyFilterContentUpdated(newNodes);
370 }
371 };
372
373
9bdcf579
DW
374 return /** @alias module:core/templates */ {
375 // Public variables and functions.
376 /**
377 * Load a template and call doRender on it.
378 *
379 * @method render
380 * @private
381 * @param {string} templateName - should consist of the component and the name of the template like this:
382 * core/menu (lib/templates/menu.mustache) or
383 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
384 * @param {Object} context - Could be array, string or simple value for the context of the template.
385 * @param {string} themeName - Name of the current theme.
386 * @return {Promise} JQuery promise object resolved when the template has been rendered.
387 */
388 render: function(templateName, context, themeName) {
389 var deferred = $.Deferred();
390
391 if (typeof (themeName) === "undefined") {
392 // System context by default.
393 themeName = config.theme;
394 }
395
396 currentThemeName = themeName;
397
398 var loadTemplate = getTemplate(templateName, true);
399
400 loadTemplate.done(
401 function(templateSource) {
402 var renderPromise = doRender(templateSource, context, themeName);
403
404 renderPromise.done(
405 function(result, js) {
406 deferred.resolve(result, js);
407 }
408 ).fail(
409 function(ex) {
410 deferred.reject(ex);
411 }
412 );
413 }
414 ).fail(
415 function(ex) {
416 deferred.reject(ex);
417 }
418 );
419 return deferred.promise();
420 },
421
422 /**
423 * Execute a block of JS returned from a template.
424 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
425 *
426 * @method runTemplateJS
9bdcf579
DW
427 * @param {string} source - A block of javascript.
428 */
28de7771
DW
429 runTemplateJS: runTemplateJS,
430
431 /**
432 * Replace a node in the page with some new HTML and run the JS.
433 *
434 * @method replaceNodeContents
435 * @param {string} source - A block of javascript.
436 */
437 replaceNodeContents: function(element, newHTML, newJS) {
438 return domReplace(element, newHTML, newJS, true);
439 },
440
441 /**
442 * Insert a node in the page with some new HTML and run the JS.
443 *
444 * @method replaceNode
445 * @param {string} source - A block of javascript.
446 */
447 replaceNode: function(element, newHTML, newJS) {
448 return domReplace(element, newHTML, newJS, false);
9bdcf579
DW
449 }
450 };
451});