on-demand release 3.8dev+
[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 */
eb514bb3
AN
26define([
27 'core/mustache',
28 'jquery',
29 'core/ajax',
30 'core/str',
31 'core/notification',
32 'core/url',
33 'core/config',
34 'core/localstorage',
35 'core/icon_system',
36 'core/event',
37 'core/yui',
38 'core/log',
39 'core/truncate',
40 'core/user_date',
41 'core/pending',
42 ],
43 function(mustache, $, ajax, str, notification, coreurl, config, storage, IconSystem, event, Y, Log, Truncate, UserDate,
44 Pending) {
9bdcf579 45
8d00afb1
DW
46 // Module variables.
47 /** @var {Number} uniqInstances Count of times this constructor has been called. */
48 var uniqInstances = 0;
9bdcf579 49
39bf2a98 50 /** @var {String[]} templateCache - Cache of already loaded template strings */
9bdcf579
DW
51 var templateCache = {};
52
39bf2a98
DW
53 /** @var {Promise[]} templatePromises - Cache of already loaded template promises */
54 var templatePromises = {};
f20a336b 55
0015f5b8
RW
56 /** @var {Promise[]} cachePartialPromises - Cache of already loaded template partial promises */
57 var cachePartialPromises = {};
58
95b06c13
DW
59 /** @var {Object} iconSystem - Object extending core/iconsystem */
60 var iconSystem = {};
61
c77ba489
RW
62 /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
63 var loadTemplateBuffer = [];
64
65 /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
66 var isLoadingTemplates = false;
67
3fb0cbd4
RW
68 /** @var {Array} blacklistedNestedHelpers - List of helpers that can't be called within other helpers */
69 var blacklistedNestedHelpers = ['js'];
70
c77ba489
RW
71 /**
72 * Search the various caches for a template promise for the given search key.
73 * The search key should be in the format <theme>/<component>/<template> e.g. boost/core/modal.
74 *
75 * If the template is found in any of the caches it will populate the other caches with
76 * the same data as well.
77 *
78 * @param {String} searchKey The template search key in the format <theme>/<component>/<template> e.g. boost/core/modal
79 * @return {Object} jQuery promise resolved with the template source
80 */
81 var getTemplatePromiseFromCache = function(searchKey) {
af8a7075
MN
82 // Do not cache anything if templaterev is not valid.
83 if (M.cfg.templaterev <= 0) {
84 return null;
85 }
86
c77ba489
RW
87 // First try the cache of promises.
88 if (searchKey in templatePromises) {
89 return templatePromises[searchKey];
90 }
91
92 // Check the module cache.
93 if (searchKey in templateCache) {
94 // Add this to the promises cache for future.
95 templatePromises[searchKey] = $.Deferred().resolve(templateCache[searchKey]).promise();
96 return templatePromises[searchKey];
97 }
98
99 // Now try local storage.
9e5edc7f 100 var cached = storage.get('core_template/' + M.cfg.templaterev + ':' + searchKey);
c77ba489
RW
101 if (cached) {
102 // Add this to the module cache for future.
103 templateCache[searchKey] = cached;
104 // Add this to the promises cache for future.
105 templatePromises[searchKey] = $.Deferred().resolve(cached).promise();
106 return templatePromises[searchKey];
107 }
108
109 return null;
110 };
111
112 /**
113 * Take all of the templates waiting in the buffer and load them from the server
114 * or from the cache.
115 *
116 * All of the templates that need to be loaded from the server will be batched up
117 * and sent in a single network request.
118 */
119 var processLoadTemplateBuffer = function() {
120 if (!loadTemplateBuffer.length) {
121 return;
122 }
123
124 if (isLoadingTemplates) {
125 return;
126 }
127
128 isLoadingTemplates = true;
129 // Grab any templates waiting in the buffer.
130 var templatesToLoad = loadTemplateBuffer.slice();
131 // This will be resolved with the list of promises for the server request.
132 var serverRequestsDeferred = $.Deferred();
133 var requests = [];
134 // Get a list of promises for each of the templates we need to load.
135 var templatePromises = templatesToLoad.map(function(templateData) {
136 var component = templateData.component;
137 var name = templateData.name;
138 var searchKey = templateData.searchKey;
139 var theme = templateData.theme;
140 var templateDeferred = templateData.deferred;
141 var promise = null;
142
143 // Double check to see if this template happened to have landed in the
144 // cache as a dependency of an earlier template.
145 var cachedPromise = getTemplatePromiseFromCache(searchKey);
146 if (cachedPromise) {
147 // We've seen this template so immediately resolve the existing promise.
148 promise = cachedPromise;
149 } else {
150 // We haven't seen this template yet so we need to request it from
151 // the server.
152 requests.push({
153 methodname: 'core_output_load_template_with_dependencies',
154 args: {
155 component: component,
156 template: name,
545e2abb
SL
157 themename: theme,
158 lang: $('html').attr('lang').replace(/-/g, '_')
c77ba489
RW
159 }
160 });
161 // Remember the index in the requests list for this template so that
162 // we can get the appropriate promise back.
163 var index = requests.length - 1;
164
165 // The server deferred will be resolved with a list of all of the promises
166 // that were sent in the order that they were added to the requests array.
167 promise = serverRequestsDeferred.promise()
168 .then(function(promises) {
169 // The promise for this template will be the one that matches the index
170 // for it's entry in the requests array.
171 //
172 // Make sure the promise is added to the promises cache for this template
173 // search key so that we don't request it again.
174 templatePromises[searchKey] = promises[index].then(function(response) {
175 var templateSource = null;
176
177 // Process all of the template dependencies for this template and add
178 // them to the caches so that we don't request them again later.
179 response.templates.forEach(function(data) {
180 // Generate the search key for this template in the response so that we
181 // can add it to the caches.
182 var tempSearchKey = [theme, data.component, data.name].join('/');
183 // Cache all of the dependent templates because we'll need them to render
184 // the requested template.
185 templateCache[tempSearchKey] = data.value;
9e5edc7f 186 storage.set('core_template/' + M.cfg.templaterev + ':' + tempSearchKey, data.value);
c77ba489
RW
187
188 if (data.component == component && data.name == name) {
189 // This is the original template that was requested so remember it to return.
190 templateSource = data.value;
191 }
192 });
193
194 if (response.strings.length) {
195 // If we have strings that the template needs then warm the string cache
196 // with them now so that we don't need to re-fetch them.
197 str.cache_strings(response.strings.map(function(data) {
198 return {
199 component: data.component,
200 key: data.name,
201 value: data.value
202 };
203 }));
204 }
205
206 // Return the original template source that the user requested.
207 return templateSource;
208 });
209
210 return templatePromises[searchKey];
211 });
212 }
213
214 return promise
215 .then(function(source) {
216 // When we've successfully loaded the template then resolve the deferred
217 // in the buffer so that all of the calling code can proceed.
218 return templateDeferred.resolve(source);
219 })
220 .catch(function(error) {
221 // If there was an error loading the template then reject the deferred
222 // in the buffer so that all of the calling code can proceed.
223 templateDeferred.reject(error);
224 // Rethrow for anyone else listening.
225 throw error;
226 });
227 });
228
229 if (requests.length) {
230 // We have requests to send so resolve the deferred with the promises.
9e5edc7f 231 serverRequestsDeferred.resolve(ajax.call(requests, true, false, false, 0, M.cfg.templaterev));
c77ba489
RW
232 } else {
233 // Nothing to load so we can resolve our deferred.
234 serverRequestsDeferred.resolve();
235 }
236
237 // Once we've finished loading all of the templates then recurse to process
238 // any templates that may have been added to the buffer in the time that we
239 // were fetching.
240 $.when.apply(null, templatePromises)
241 .then(function() {
242 // Remove the templates we've loaded from the buffer.
243 loadTemplateBuffer.splice(0, templatesToLoad.length);
244 isLoadingTemplates = false;
245 processLoadTemplateBuffer();
246 return;
247 })
248 .catch(function() {
249 // Remove the templates we've loaded from the buffer.
250 loadTemplateBuffer.splice(0, templatesToLoad.length);
251 isLoadingTemplates = false;
252 processLoadTemplateBuffer();
253 });
254 };
255
8d00afb1
DW
256 /**
257 * Constructor
258 *
259 * Each call to templates.render gets it's own instance of this class.
260 */
261 var Renderer = function() {
262 this.requiredStrings = [];
263 this.requiredJS = [];
0e5b3e28 264 this.requiredDates = [];
8d00afb1
DW
265 this.currentThemeName = '';
266 };
267 // Class variables and functions.
268
9bdcf579 269 /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
8d00afb1 270 Renderer.prototype.requiredStrings = null;
9bdcf579 271
0e5b3e28
RW
272 /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */
273 Renderer.prototype.requiredDates = [];
274
9bdcf579 275 /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
8d00afb1 276 Renderer.prototype.requiredJS = null;
9bdcf579
DW
277
278 /** @var {String} themeName for the current render */
8d00afb1 279 Renderer.prototype.currentThemeName = '';
9bdcf579 280
f992dcf6 281 /**
c77ba489 282 * Load a template.
f992dcf6
DP
283 *
284 * @method getTemplate
285 * @private
286 * @param {string} templateName - should consist of the component and the name of the template like this:
287 * core/menu (lib/templates/menu.mustache) or
288 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
289 * @return {Promise} JQuery promise object resolved when the template has been fetched.
290 */
39bf2a98 291 Renderer.prototype.getTemplate = function(templateName) {
2b92891a
RW
292 var currentTheme = this.currentThemeName;
293 var searchKey = currentTheme + '/' + templateName;
f992dcf6 294
c77ba489
RW
295 // If we haven't already seen this template then buffer it.
296 var cachedPromise = getTemplatePromiseFromCache(searchKey);
297 if (cachedPromise) {
298 return cachedPromise;
f992dcf6
DP
299 }
300
c77ba489
RW
301 // Check the buffer to seee if this template has already been added.
302 var existingBufferRecords = loadTemplateBuffer.filter(function(record) {
303 return record.searchKey == searchKey;
304 });
305 if (existingBufferRecords.length) {
306 // This template is already in the buffer so just return the existing
307 // promise. No need to add it to the buffer again.
308 return existingBufferRecords[0].deferred.promise();
2b92891a
RW
309 }
310
c77ba489
RW
311 // This is the first time this has been requested so let's add it to the buffer
312 // to be loaded.
313 var parts = templateName.split('/');
314 var component = parts.shift();
0261d1ef 315 var name = parts.join('/');
c77ba489 316 var deferred = $.Deferred();
f992dcf6 317
c77ba489
RW
318 // Add this template to the buffer to be loaded.
319 loadTemplateBuffer.push({
320 component: component,
321 name: name,
322 theme: currentTheme,
323 searchKey: searchKey,
324 deferred: deferred
325 });
2b92891a 326
c77ba489
RW
327 // We know there is at least one thing in the buffer so kick off a processing run.
328 processLoadTemplateBuffer();
329 return deferred.promise();
f992dcf6
DP
330 };
331
332 /**
333 * Load a partial from the cache or ajax.
334 *
335 * @method partialHelper
336 * @private
337 * @param {string} name The partial name to load.
338 * @return {string}
339 */
8d00afb1 340 Renderer.prototype.partialHelper = function(name) {
f992dcf6 341
39bf2a98
DW
342 var searchKey = this.currentThemeName + '/' + name;
343
344 if (!(searchKey in templateCache)) {
345 notification.exception(new Error('Failed to pre-fetch the template: ' + name));
346 }
f992dcf6 347
39bf2a98 348 return templateCache[searchKey];
f992dcf6
DP
349 };
350
95b06c13
DW
351 /**
352 * Render a single image icon.
353 *
354 * @method renderIcon
355 * @private
356 * @param {string} key The icon key.
357 * @param {string} component The component name.
358 * @param {string} title The icon title
359 * @return {Promise}
360 */
361 Renderer.prototype.renderIcon = function(key, component, title) {
362 // Preload the module to do the icon rendering based on the theme iconsystem.
e330b1c2 363 var modulename = config.iconsystemmodule;
95b06c13 364
e330b1c2 365 // RequireJS does not return a promise.
95b06c13
DW
366 var ready = $.Deferred();
367 require([modulename], function(System) {
368 var system = new System();
369 if (!(system instanceof IconSystem)) {
e330b1c2 370 ready.reject('Invalid icon system specified' + config.iconsystemmodule);
95b06c13 371 } else {
95b06c13 372 iconSystem = system;
751ec025 373 system.init().then(ready.resolve).catch(notification.exception);
95b06c13
DW
374 }
375 });
376
e330b1c2
DW
377 return ready.then(function(iconSystem) {
378 return this.getTemplate(iconSystem.getTemplateName());
95b06c13
DW
379 }.bind(this)).then(function(template) {
380 return iconSystem.renderIcon(key, component, title, template);
381 });
382 };
383
9bdcf579
DW
384 /**
385 * Render image icons.
386 *
387 * @method pixHelper
388 * @private
8d00afb1 389 * @param {object} context The mustache context
9bdcf579 390 * @param {string} sectionText The text to parse arguments from.
e37d53da 391 * @param {function} helper Used to render the alt attribute of the text.
9bdcf579
DW
392 * @return {string}
393 */
8d00afb1 394 Renderer.prototype.pixHelper = function(context, sectionText, helper) {
9bdcf579
DW
395 var parts = sectionText.split(',');
396 var key = '';
397 var component = '';
398 var text = '';
9bdcf579
DW
399
400 if (parts.length > 0) {
e03d5d47 401 key = helper(parts.shift().trim(), context);
9bdcf579
DW
402 }
403 if (parts.length > 0) {
e03d5d47 404 component = helper(parts.shift().trim(), context);
9bdcf579
DW
405 }
406 if (parts.length > 0) {
e03d5d47 407 text = helper(parts.join(',').trim(), context);
9bdcf579 408 }
95b06c13 409
e330b1c2 410 var templateName = iconSystem.getTemplateName();
95b06c13 411
e330b1c2 412 var searchKey = this.currentThemeName + '/' + templateName;
39bf2a98 413 var template = templateCache[searchKey];
95b06c13 414
74e238f4
DM
415 // The key might have been escaped by the JS Mustache engine which
416 // converts forward slashes to HTML entities. Let us undo that here.
417 key = key.replace(/&#x2F;/gi, '/');
418
95b06c13 419 return iconSystem.renderIcon(key, component, text, template);
9bdcf579
DW
420 };
421
9bdcf579
DW
422 /**
423 * Render blocks of javascript and save them in an array.
424 *
425 * @method jsHelper
426 * @private
8d00afb1 427 * @param {object} context The current mustache context.
9bdcf579
DW
428 * @param {string} sectionText The text to save as a js block.
429 * @param {function} helper Used to render the block.
430 * @return {string}
431 */
8d00afb1
DW
432 Renderer.prototype.jsHelper = function(context, sectionText, helper) {
433 this.requiredJS.push(helper(sectionText, context));
9bdcf579
DW
434 return '';
435 };
436
437 /**
438 * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
439 * into a get_string call.
440 *
441 * @method stringHelper
442 * @private
8d00afb1 443 * @param {object} context The current mustache context.
9bdcf579
DW
444 * @param {string} sectionText The text to parse the arguments from.
445 * @param {function} helper Used to render subsections of the text.
446 * @return {string}
447 */
8d00afb1 448 Renderer.prototype.stringHelper = function(context, sectionText, helper) {
9bdcf579
DW
449 var parts = sectionText.split(',');
450 var key = '';
451 var component = '';
452 var param = '';
453 if (parts.length > 0) {
454 key = parts.shift().trim();
455 }
456 if (parts.length > 0) {
457 component = parts.shift().trim();
458 }
459 if (parts.length > 0) {
460 param = parts.join(',').trim();
461 }
462
463 if (param !== '') {
464 // Allow variable expansion in the param part only.
8d00afb1 465 param = helper(param, context);
9bdcf579
DW
466 }
467 // Allow json formatted $a arguments.
468 if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
469 param = JSON.parse(param);
470 }
471
8d00afb1
DW
472 var index = this.requiredStrings.length;
473 this.requiredStrings.push({key: key, component: component, param: param});
a89cf237
FM
474
475 // The placeholder must not use {{}} as those can be misinterpreted by the engine.
476 return '[[_s' + index + ']]';
9bdcf579
DW
477 };
478
0b4bff8c
AN
479 /**
480 * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
481 *
482 * @method quoteHelper
483 * @private
8d00afb1 484 * @param {object} context The current mustache context.
0b4bff8c
AN
485 * @param {string} sectionText The text to parse the arguments from.
486 * @param {function} helper Used to render subsections of the text.
487 * @return {string}
488 */
8d00afb1
DW
489 Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
490 var content = helper(sectionText.trim(), context);
0b4bff8c
AN
491
492 // Escape the {{ and the ".
493 // This involves wrapping {{, and }} in change delimeter tags.
494 content = content
f8f04b77 495 .replace(/"/g, '\\"')
0b4bff8c 496 .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
6c97045e 497 .replace(/(\r\n|\r|\n)/g, '&#x0a;')
0b4bff8c
AN
498 ;
499 return '"' + content + '"';
500 };
501
75378ded
RW
502 /**
503 * Shorten text helper to truncate text and append a trailing ellipsis.
504 *
505 * @method shortenTextHelper
506 * @private
507 * @param {object} context The current mustache context.
508 * @param {string} sectionText The text to parse the arguments from.
509 * @param {function} helper Used to render subsections of the text.
510 * @return {string}
511 */
512 Renderer.prototype.shortenTextHelper = function(context, sectionText, helper) {
513 // Non-greedy split on comma to grab section text into the length and
514 // text parts.
515 var regex = /(.*?),(.*)/;
516 var parts = sectionText.match(regex);
517 // The length is the part matched in the first set of parethesis.
518 var length = parts[1].trim();
519 // The length is the part matched in the second set of parethesis.
520 var text = parts[2].trim();
521 var content = helper(text, context);
522 return Truncate.truncate(content, {
523 length: length,
524 words: true,
525 ellipsis: '...'
526 });
527 };
528
0e5b3e28
RW
529 /**
530 * User date helper to render user dates from timestamps.
531 *
532 * @method userDateHelper
533 * @private
534 * @param {object} context The current mustache context.
535 * @param {string} sectionText The text to parse the arguments from.
536 * @param {function} helper Used to render subsections of the text.
537 * @return {string}
538 */
539 Renderer.prototype.userDateHelper = function(context, sectionText, helper) {
540 // Non-greedy split on comma to grab the timestamp and format.
541 var regex = /(.*?),(.*)/;
542 var parts = sectionText.match(regex);
543 var timestamp = helper(parts[1].trim(), context);
544 var format = helper(parts[2].trim(), context);
545 var index = this.requiredDates.length;
546
547 this.requiredDates.push({
548 timestamp: timestamp,
549 format: format
550 });
551
552 return '[[_t_' + index + ']]';
553 };
554
3fb0cbd4
RW
555 /**
556 * Return a helper function to be added to the context for rendering the a
557 * template.
558 *
559 * This will parse the provided text before giving it to the helper function
560 * in order to remove any blacklisted nested helpers to prevent one helper
561 * from calling another.
562 *
563 * In particular to prevent the JS helper from being called from within another
564 * helper because it can lead to security issues when the JS portion is user
565 * provided.
566 *
567 * @param {function} helperFunction The helper function to add
568 * @param {object} context The template context for the helper function
569 * @return {Function} To be set in the context
570 */
571 Renderer.prototype.addHelperFunction = function(helperFunction, context) {
572 return function() {
573 return function(sectionText, helper) {
574 // Override the blacklisted helpers in the template context with
575 // a function that returns an empty string for use when executing
576 // other helpers. This is to prevent these helpers from being
577 // executed as part of the rendering of another helper in order to
578 // prevent any potential security issues.
579 var originalHelpers = blacklistedNestedHelpers.reduce(function(carry, name) {
580 if (context.hasOwnProperty(name)) {
581 carry[name] = context[name];
582 }
583
584 return carry;
585 }, {});
586
587 blacklistedNestedHelpers.forEach(function(helperName) {
588 context[helperName] = function() {
589 return '';
590 };
591 });
592
593 // Execute the helper with the modified context that doesn't include
594 // the blacklisted nested helpers. This prevents the blacklisted
595 // helpers from being called from within other helpers.
596 var result = helperFunction.apply(this, [context, sectionText, helper]);
597
598 // Restore the original helper implementation in the context so that
599 // any further rendering has access to them again.
600 for (var name in originalHelpers) {
601 context[name] = originalHelpers[name];
602 }
603
604 return result;
605 }.bind(this);
606 }.bind(this);
607 };
608
9bdcf579
DW
609 /**
610 * Add some common helper functions to all context objects passed to templates.
611 * These helpers match exactly the helpers available in php.
612 *
613 * @method addHelpers
614 * @private
615 * @param {Object} context Simple types used as the context for the template.
616 * @param {String} themeName We set this multiple times, because there are async calls.
617 */
8d00afb1
DW
618 Renderer.prototype.addHelpers = function(context, themeName) {
619 this.currentThemeName = themeName;
620 this.requiredStrings = [];
621 this.requiredJS = [];
622 context.uniqid = (uniqInstances++);
3fb0cbd4
RW
623 context.str = this.addHelperFunction(this.stringHelper, context);
624 context.pix = this.addHelperFunction(this.pixHelper, context);
625 context.js = this.addHelperFunction(this.jsHelper, context);
626 context.quote = this.addHelperFunction(this.quoteHelper, context);
627 context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);
628 context.userdate = this.addHelperFunction(this.userDateHelper, context);
9f5f3dcc 629 context.globals = {config: config};
9bdcf579
DW
630 context.currentTheme = themeName;
631 };
632
633 /**
634 * Get all the JS blocks from the last rendered template.
635 *
636 * @method getJS
637 * @private
9bdcf579
DW
638 * @return {string}
639 */
0e5b3e28 640 Renderer.prototype.getJS = function() {
9bdcf579 641 var js = '';
8d00afb1
DW
642 if (this.requiredJS.length > 0) {
643 js = this.requiredJS.join(";\n");
9bdcf579
DW
644 }
645
0e5b3e28 646 return js;
29879f8f
FM
647 };
648
649 /**
650 * Treat strings in content.
651 *
652 * The purpose of this method is to replace the placeholders found in a string
653 * with the their respective translated strings.
654 *
655 * Previously we were relying on String.replace() but the complexity increased with
656 * the numbers of strings to replace. Now we manually walk the string and stop at each
657 * placeholder we find, only then we replace it. Most of the time we will
658 * replace all the placeholders in a single run, at times we will need a few
659 * more runs when placeholders are replaced with strings that contain placeholders
660 * themselves.
661 *
662 * @param {String} content The content in which string placeholders are to be found.
663 * @param {Array} strings The strings to replace with.
664 * @return {String} The treated content.
665 */
8d00afb1 666 Renderer.prototype.treatStringsInContent = function(content, strings) {
a89cf237 667 var pattern = /\[\[_s\d+\]\]/,
29879f8f
FM
668 treated,
669 index,
670 strIndex,
671 walker,
672 char,
673 strFinal;
674
675 do {
676 treated = '';
677 index = content.search(pattern);
678 while (index > -1) {
679
680 // Copy the part prior to the placeholder to the treated string.
681 treated += content.substring(0, index);
682 content = content.substr(index);
683 strIndex = '';
d9dff92f 684 walker = 4; // 4 is the length of '[[_s'.
29879f8f
FM
685
686 // Walk the characters to manually extract the index of the string from the placeholder.
687 char = content.substr(walker, 1);
688 do {
689 strIndex += char;
690 walker++;
691 char = content.substr(walker, 1);
a89cf237 692 } while (char != ']');
29879f8f
FM
693
694 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
695 strFinal = strings[parseInt(strIndex, 10)];
696 if (typeof strFinal === 'undefined') {
a89cf237 697 Log.debug('Could not find string for pattern [[_s' + strIndex + ']].');
29879f8f
FM
698 strFinal = '';
699 }
700 treated += strFinal;
d9dff92f 701 content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '[[_s]]'.
29879f8f
FM
702
703 // Find the next placeholder.
704 index = content.search(pattern);
705 }
706
707 // The content becomes the treated part with the rest of the content.
708 content = treated + content;
709
710 // Check if we need to walk the content again, in case strings contained placeholders.
711 index = content.search(pattern);
712
713 } while (index > -1);
714
715 return content;
9bdcf579
DW
716 };
717
0e5b3e28
RW
718 /**
719 * Treat strings in content.
720 *
721 * The purpose of this method is to replace the date placeholders found in the
722 * content with the their respective translated dates.
723 *
724 * @param {String} content The content in which string placeholders are to be found.
725 * @param {Array} strings The strings to replace with.
726 * @return {String} The treated content.
727 */
728 Renderer.prototype.treatDatesInContent = function(content, dates) {
729 dates.forEach(function(date, index) {
730 var key = '\\[\\[_t_' + index + '\\]\\]';
731 var re = new RegExp(key, 'g');
732 content = content.replace(re, date);
733 });
734
735 return content;
736 };
737
9bdcf579
DW
738 /**
739 * Render a template and then call the callback with the result.
740 *
741 * @method doRender
742 * @private
743 * @param {string} templateSource The mustache template to render.
744 * @param {Object} context Simple types used as the context for the template.
745 * @param {String} themeName Name of the current theme.
746 * @return {Promise} object
747 */
8d00afb1 748 Renderer.prototype.doRender = function(templateSource, context, themeName) {
8d00afb1 749 this.currentThemeName = themeName;
e330b1c2 750 var iconTemplate = iconSystem.getTemplateName();
9bdcf579 751
eb514bb3 752 var pendingPromise = new Pending('core/templates:doRender');
e330b1c2 753 return this.getTemplate(iconTemplate).then(function() {
f20a336b
DW
754 this.addHelpers(context, themeName);
755 var result = mustache.render(templateSource, context, this.partialHelper.bind(this));
0e5b3e28
RW
756 return $.Deferred().resolve(result.trim(), this.getJS()).promise();
757 }.bind(this))
758 .then(function(html, js) {
f20a336b
DW
759 if (this.requiredStrings.length > 0) {
760 return str.get_strings(this.requiredStrings).then(function(strings) {
761
0e5b3e28
RW
762 // Make sure string substitutions are done for the userdate
763 // values as well.
764 this.requiredDates = this.requiredDates.map(function(date) {
765 return {
766 timestamp: this.treatStringsInContent(date.timestamp, strings),
767 format: this.treatStringsInContent(date.format, strings)
768 };
769 }.bind(this));
770
f20a336b
DW
771 // Why do we not do another call the render here?
772 //
773 // Because that would expose DOS holes. E.g.
774 // I create an assignment called "{{fish" which
775 // would get inserted in the template in the first pass
776 // and cause the template to die on the second pass (unbalanced).
0e5b3e28
RW
777 html = this.treatStringsInContent(html, strings);
778 js = this.treatStringsInContent(js, strings);
779 return $.Deferred().resolve(html, js).promise();
780 }.bind(this));
781 }
f20a336b 782
0e5b3e28
RW
783 return $.Deferred().resolve(html, js).promise();
784 }.bind(this))
785 .then(function(html, js) {
786 // This has to happen after the strings replacement because you can
787 // use the string helper in content for the user date helper.
788 if (this.requiredDates.length > 0) {
789 return UserDate.get(this.requiredDates).then(function(dates) {
790 html = this.treatDatesInContent(html, dates);
791 js = this.treatDatesInContent(js, dates);
792 return $.Deferred().resolve(html, js).promise();
f20a336b 793 }.bind(this));
f20a336b 794 }
0e5b3e28
RW
795
796 return $.Deferred().resolve(html, js).promise();
4b7ad884
AN
797 }.bind(this))
798 .then(function(html, js) {
eb514bb3 799 pendingPromise.resolve();
4b7ad884
AN
800 return $.Deferred().resolve(html, js).promise();
801 });
9bdcf579
DW
802 };
803
28de7771
DW
804 /**
805 * Execute a block of JS returned from a template.
806 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
807 *
808 * @method runTemplateJS
809 * @param {string} source - A block of javascript.
810 */
811 var runTemplateJS = function(source) {
812 if (source.trim() !== '') {
35be5826 813 var newscript = $('<script>').attr('type', 'text/javascript').html(source);
28de7771
DW
814 $('head').append(newscript);
815 }
816 };
817
818 /**
819 * Do some DOM replacement and trigger correct events and fire javascript.
820 *
821 * @method domReplace
822 * @private
823 * @param {JQuery} element - Element or selector to replace.
824 * @param {String} newHTML - HTML to insert / replace.
825 * @param {String} newJS - Javascript to run after the insertion.
826 * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
827 */
828 var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
829 var replaceNode = $(element);
830 if (replaceNode.length) {
831 // First create the dom nodes so we have a reference to them.
832 var newNodes = $(newHTML);
1fca8a7b 833 var yuiNodes = null;
28de7771
DW
834 // Do the replacement in the page.
835 if (replaceChildNodes) {
1fca8a7b
DW
836 // Cleanup any YUI event listeners attached to any of these nodes.
837 yuiNodes = new Y.NodeList(replaceNode.children().get());
838 yuiNodes.destroy(true);
839
840 // JQuery will cleanup after itself.
28de7771
DW
841 replaceNode.empty();
842 replaceNode.append(newNodes);
843 } else {
1fca8a7b
DW
844 // Cleanup any YUI event listeners attached to any of these nodes.
845 yuiNodes = new Y.NodeList(replaceNode.get());
846 yuiNodes.destroy(true);
847
848 // JQuery will cleanup after itself.
28de7771
DW
849 replaceNode.replaceWith(newNodes);
850 }
851 // Run any javascript associated with the new HTML.
852 runTemplateJS(newJS);
853 // Notify all filters about the new content.
854 event.notifyFilterContentUpdated(newNodes);
855 }
856 };
857
39bf2a98
DW
858 /**
859 * Scan a template source for partial tags and return a list of the found partials.
860 *
861 * @method scanForPartials
862 * @private
863 * @param {string} templateSource - source template to scan.
864 * @return {Array} List of partials.
865 */
866 Renderer.prototype.scanForPartials = function(templateSource) {
867 var tokens = mustache.parse(templateSource),
868 partials = [];
869
870 var findPartial = function(tokens, partials) {
871 var i, token;
872 for (i = 0; i < tokens.length; i++) {
873 token = tokens[i];
874 if (token[0] == '>' || token[0] == '<') {
875 partials.push(token[1]);
876 }
877 if (token.length > 4) {
878 findPartial(token[4], partials);
879 }
880 }
881 };
882
883 findPartial(tokens, partials);
884
885 return partials;
886 };
887
888 /**
889 * Load a template and scan it for partials. Recursively fetch the partials.
890 *
891 * @method cachePartials
892 * @private
893 * @param {string} templateName - should consist of the component and the name of the template like this:
894 * core/menu (lib/templates/menu.mustache) or
895 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
a2cb00b6 896 * @param {Array} parentage - A list of requested partials in this render chain.
39bf2a98
DW
897 * @return {Promise} JQuery promise object resolved when all partials are in the cache.
898 */
a2cb00b6 899 Renderer.prototype.cachePartials = function(templateName, parentage) {
0015f5b8
RW
900 var searchKey = this.currentThemeName + '/' + templateName;
901
902 if (searchKey in cachePartialPromises) {
903 return cachePartialPromises[searchKey];
904 }
905
61e0f58c
AN
906 // This promise will not be resolved until all child partials are also resolved and ready.
907 // We create it here to allow us to check for recursive inclusion of templates.
a2cb00b6 908 // Keep track of the requested partials in this chain.
23e347bb 909 parentage = parentage || [searchKey];
a2cb00b6 910
61e0f58c
AN
911 cachePartialPromises[searchKey] = $.Deferred();
912
913 this.getTemplate(templateName)
914 .then(function(templateSource) {
39bf2a98 915 var partials = this.scanForPartials(templateSource);
b51c5b92 916 var uniquePartials = partials.filter(function(partialName) {
61e0f58c
AN
917 // Check for recursion.
918
a2cb00b6
DW
919 if (parentage.indexOf(this.currentThemeName + '/' + partialName) >= 0) {
920 // Ignore templates which include a parent template already requested in the current chain.
61e0f58c
AN
921 return false;
922 }
923
924 // Ignore templates that include themselves.
b51c5b92 925 return partialName != templateName;
61e0f58c
AN
926 }.bind(this));
927
928 // Fetch any partial which has not already been fetched.
b51c5b92 929 var fetchThemAll = uniquePartials.map(function(partialName) {
a2cb00b6
DW
930 parentage.push(this.currentThemeName + '/' + partialName);
931 return this.cachePartials(partialName, parentage);
0015f5b8 932 }.bind(this));
39bf2a98 933
61e0f58c
AN
934 // Resolve the templateName promise when all of the children are resolved.
935 return $.when.apply($, fetchThemAll)
936 .then(function() {
937 return cachePartialPromises[searchKey].resolve(templateSource);
b302369d 938 });
61e0f58c
AN
939 }.bind(this))
940 .catch(cachePartialPromises[searchKey].reject);
0015f5b8 941
61e0f58c 942 return cachePartialPromises[searchKey];
39bf2a98
DW
943 };
944
8d00afb1
DW
945 /**
946 * Load a template and call doRender on it.
947 *
948 * @method render
949 * @private
950 * @param {string} templateName - should consist of the component and the name of the template like this:
951 * core/menu (lib/templates/menu.mustache) or
952 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
953 * @param {Object} context - Could be array, string or simple value for the context of the template.
954 * @param {string} themeName - Name of the current theme.
955 * @return {Promise} JQuery promise object resolved when the template has been rendered.
956 */
957 Renderer.prototype.render = function(templateName, context, themeName) {
8d00afb1
DW
958 if (typeof (themeName) === "undefined") {
959 // System context by default.
960 themeName = config.theme;
961 }
962
963 this.currentThemeName = themeName;
964
95b06c13 965 // Preload the module to do the icon rendering based on the theme iconsystem.
e330b1c2 966 var modulename = config.iconsystemmodule;
95b06c13
DW
967
968 var ready = $.Deferred();
969 require([modulename], function(System) {
970 var system = new System();
971 if (!(system instanceof IconSystem)) {
972 ready.reject('Invalid icon system specified' + config.iconsystem);
973 } else {
974 iconSystem = system;
751ec025 975 system.init().then(ready.resolve).catch(notification.exception);
95b06c13
DW
976 }
977 });
978
979 return ready.then(function() {
980 return this.cachePartials(templateName);
981 }.bind(this)).then(function(templateSource) {
982 return this.doRender(templateSource, context, themeName);
983 }.bind(this));
8d00afb1
DW
984 };
985
90525930
MN
986 /**
987 * Prepend some HTML to a node and trigger events and fire javascript.
988 *
989 * @method domPrepend
990 * @private
991 * @param {jQuery|String} element - Element or selector to prepend HTML to
992 * @param {String} html - HTML to prepend
993 * @param {String} js - Javascript to run after we prepend the html
994 */
995 var domPrepend = function(element, html, js) {
996 var node = $(element);
997 if (node.length) {
998 // Prepend the html.
999 node.prepend(html);
1000 // Run any javascript associated with the new HTML.
1001 runTemplateJS(js);
1002 // Notify all filters about the new content.
1003 event.notifyFilterContentUpdated(node);
1004 }
1005 };
28de7771 1006
f7775c9a
MN
1007 /**
1008 * Append some HTML to a node and trigger events and fire javascript.
1009 *
1010 * @method domAppend
1011 * @private
1012 * @param {jQuery|String} element - Element or selector to append HTML to
1013 * @param {String} html - HTML to append
1014 * @param {String} js - Javascript to run after we append the html
1015 */
1016 var domAppend = function(element, html, js) {
1017 var node = $(element);
1018 if (node.length) {
1019 // Append the html.
1020 node.append(html);
1021 // Run any javascript associated with the new HTML.
1022 runTemplateJS(js);
1023 // Notify all filters about the new content.
1024 event.notifyFilterContentUpdated(node);
1025 }
1026 };
1027
9bdcf579
DW
1028 return /** @alias module:core/templates */ {
1029 // Public variables and functions.
1030 /**
8d00afb1
DW
1031 * Every call to render creates a new instance of the class and calls render on it. This
1032 * means each render call has it's own class variables.
9bdcf579
DW
1033 *
1034 * @method render
1035 * @private
1036 * @param {string} templateName - should consist of the component and the name of the template like this:
1037 * core/menu (lib/templates/menu.mustache) or
1038 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
1039 * @param {Object} context - Could be array, string or simple value for the context of the template.
1040 * @param {string} themeName - Name of the current theme.
1041 * @return {Promise} JQuery promise object resolved when the template has been rendered.
1042 */
1043 render: function(templateName, context, themeName) {
8d00afb1
DW
1044 var renderer = new Renderer();
1045 return renderer.render(templateName, context, themeName);
9bdcf579
DW
1046 },
1047
95b06c13
DW
1048 /**
1049 * Every call to renderIcon creates a new instance of the class and calls renderIcon on it. This
1050 * means each render call has it's own class variables.
1051 *
1052 * @method renderIcon
1053 * @public
1054 * @param {string} key - Icon key.
1055 * @param {string} component - Icon component
1056 * @param {string} title - Icon title
1057 * @return {Promise} JQuery promise object resolved when the pix has been rendered.
1058 */
1059 renderPix: function(key, component, title) {
1060 var renderer = new Renderer();
1061 return renderer.renderIcon(key, component, title);
1062 },
1063
9bdcf579
DW
1064 /**
1065 * Execute a block of JS returned from a template.
1066 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
1067 *
1068 * @method runTemplateJS
9bdcf579
DW
1069 * @param {string} source - A block of javascript.
1070 */
28de7771
DW
1071 runTemplateJS: runTemplateJS,
1072
1073 /**
1074 * Replace a node in the page with some new HTML and run the JS.
1075 *
1076 * @method replaceNodeContents
c96f55e6
DP
1077 * @param {JQuery} element - Element or selector to replace.
1078 * @param {String} newHTML - HTML to insert / replace.
1079 * @param {String} newJS - Javascript to run after the insertion.
28de7771
DW
1080 */
1081 replaceNodeContents: function(element, newHTML, newJS) {
c96f55e6 1082 domReplace(element, newHTML, newJS, true);
28de7771
DW
1083 },
1084
1085 /**
1086 * Insert a node in the page with some new HTML and run the JS.
1087 *
1088 * @method replaceNode
c96f55e6
DP
1089 * @param {JQuery} element - Element or selector to replace.
1090 * @param {String} newHTML - HTML to insert / replace.
1091 * @param {String} newJS - Javascript to run after the insertion.
28de7771
DW
1092 */
1093 replaceNode: function(element, newHTML, newJS) {
c96f55e6 1094 domReplace(element, newHTML, newJS, false);
f7775c9a
MN
1095 },
1096
90525930
MN
1097 /**
1098 * Prepend some HTML to a node and trigger events and fire javascript.
1099 *
1100 * @method prependNodeContents
1101 * @param {jQuery|String} element - Element or selector to prepend HTML to
1102 * @param {String} html - HTML to prepend
1103 * @param {String} js - Javascript to run after we prepend the html
1104 */
1105 prependNodeContents: function(element, html, js) {
1106 domPrepend(element, html, js);
1107 },
1108
f7775c9a
MN
1109 /**
1110 * Append some HTML to a node and trigger events and fire javascript.
1111 *
1112 * @method appendNodeContents
1113 * @param {jQuery|String} element - Element or selector to append HTML to
1114 * @param {String} html - HTML to append
1115 * @param {String} js - Javascript to run after we append the html
1116 */
1117 appendNodeContents: function(element, html, js) {
1118 domAppend(element, html, js);
9bdcf579
DW
1119 }
1120 };
1121});