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