Commit | Line | Data |
---|---|---|
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 | */ | |
26 | define([ '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 | }); |