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