[ Index ]

PHP Cross Reference of Unnamed Project

title

Body

[close]

/lib/amd/src/ -> templates.js (source)

   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           'core/localstorage',
  34           'core/event',
  35           'core/yui',
  36           'core/log'
  37         ],
  38         function(mustache, $, ajax, str, notification, coreurl, config, storage, event, Y, Log) {
  39  
  40      // Module variables.
  41      /** @var {Number} uniqInstances Count of times this constructor has been called. */
  42      var uniqInstances = 0;
  43  
  44      /** @var {string[]} templateCache - Cache of already loaded templates */
  45      var templateCache = {};
  46  
  47      /**
  48       * Constructor
  49       *
  50       * Each call to templates.render gets it's own instance of this class.
  51       */
  52      var Renderer = function() {
  53          this.requiredStrings = [];
  54          this.requiredJS = [];
  55          this.currentThemeName = '';
  56      };
  57      // Class variables and functions.
  58  
  59      /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
  60      Renderer.prototype.requiredStrings = null;
  61  
  62      /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
  63      Renderer.prototype.requiredJS = null;
  64  
  65      /** @var {String} themeName for the current render */
  66      Renderer.prototype.currentThemeName = '';
  67  
  68      /**
  69       * Load a template from the cache or local storage or ajax request.
  70       *
  71       * @method getTemplate
  72       * @private
  73       * @param {string} templateName - should consist of the component and the name of the template like this:
  74       *                              core/menu (lib/templates/menu.mustache) or
  75       *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
  76       * @param {Boolean} async If false - this function will not return until the promises are resolved.
  77       * @return {Promise} JQuery promise object resolved when the template has been fetched.
  78       */
  79      Renderer.prototype.getTemplate = function(templateName, async) {
  80          var deferred = $.Deferred();
  81          var parts = templateName.split('/');
  82          var component = parts.shift();
  83          var name = parts.shift();
  84  
  85          var searchKey = this.currentThemeName + '/' + templateName;
  86  
  87          // First try request variables.
  88          if (searchKey in templateCache) {
  89              deferred.resolve(templateCache[searchKey]);
  90              return deferred.promise();
  91          }
  92  
  93          // Now try local storage.
  94          var cached = storage.get('core_template/' + searchKey);
  95  
  96          if (cached) {
  97              deferred.resolve(cached);
  98              templateCache[searchKey] = cached;
  99              return deferred.promise();
 100          }
 101  
 102          // Oh well - load via ajax.
 103          var promises = ajax.call([{
 104              methodname: 'core_output_load_template',
 105              args: {
 106                  component: component,
 107                  template: name,
 108                  themename: this.currentThemeName
 109              }
 110          }], async, false);
 111  
 112          promises[0].done(
 113              function(templateSource) {
 114                  storage.set('core_template/' + searchKey, templateSource);
 115                  templateCache[searchKey] = templateSource;
 116                  deferred.resolve(templateSource);
 117              }
 118          ).fail(
 119              function(ex) {
 120                  deferred.reject(ex);
 121              }
 122          );
 123          return deferred.promise();
 124      };
 125  
 126      /**
 127       * Load a partial from the cache or ajax.
 128       *
 129       * @method partialHelper
 130       * @private
 131       * @param {string} name The partial name to load.
 132       * @return {string}
 133       */
 134      Renderer.prototype.partialHelper = function(name) {
 135          var template = '';
 136  
 137          this.getTemplate(name, false).done(
 138              function(source) {
 139                  template = source;
 140              }
 141          ).fail(notification.exception);
 142  
 143          return template;
 144      };
 145  
 146      /**
 147       * Render image icons.
 148       *
 149       * @method pixHelper
 150       * @private
 151       * @param {object} context The mustache context
 152       * @param {string} sectionText The text to parse arguments from.
 153       * @param {function} helper Used to render the alt attribute of the text.
 154       * @return {string}
 155       */
 156      Renderer.prototype.pixHelper = function(context, sectionText, helper) {
 157          var parts = sectionText.split(',');
 158          var key = '';
 159          var component = '';
 160          var text = '';
 161          var result;
 162  
 163          if (parts.length > 0) {
 164              key = parts.shift().trim();
 165          }
 166          if (parts.length > 0) {
 167              component = parts.shift().trim();
 168          }
 169          if (parts.length > 0) {
 170              text = parts.join(',').trim();
 171          }
 172          var url = coreurl.imageUrl(key, component);
 173  
 174          var templatecontext = {
 175              attributes: [
 176                  {name: 'src', value: url},
 177                  {name: 'alt', value: helper(text)},
 178                  {name: 'class', value: 'smallicon'}
 179              ]
 180          };
 181          // We forced loading of this early, so it will be in the cache.
 182          var template = templateCache[this.currentThemeName + '/core/pix_icon'];
 183          result = mustache.render(template, templatecontext, this.partialHelper.bind(this));
 184          return result.trim();
 185      };
 186  
 187      /**
 188       * Render blocks of javascript and save them in an array.
 189       *
 190       * @method jsHelper
 191       * @private
 192       * @param {object} context The current mustache context.
 193       * @param {string} sectionText The text to save as a js block.
 194       * @param {function} helper Used to render the block.
 195       * @return {string}
 196       */
 197      Renderer.prototype.jsHelper = function(context, sectionText, helper) {
 198          this.requiredJS.push(helper(sectionText, context));
 199          return '';
 200      };
 201  
 202      /**
 203       * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
 204       * into a get_string call.
 205       *
 206       * @method stringHelper
 207       * @private
 208       * @param {object} context The current mustache context.
 209       * @param {string} sectionText The text to parse the arguments from.
 210       * @param {function} helper Used to render subsections of the text.
 211       * @return {string}
 212       */
 213      Renderer.prototype.stringHelper = function(context, sectionText, helper) {
 214          var parts = sectionText.split(',');
 215          var key = '';
 216          var component = '';
 217          var param = '';
 218          if (parts.length > 0) {
 219              key = parts.shift().trim();
 220          }
 221          if (parts.length > 0) {
 222              component = parts.shift().trim();
 223          }
 224          if (parts.length > 0) {
 225              param = parts.join(',').trim();
 226          }
 227  
 228          if (param !== '') {
 229              // Allow variable expansion in the param part only.
 230              param = helper(param, context);
 231          }
 232          // Allow json formatted $a arguments.
 233          if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
 234              param = JSON.parse(param);
 235          }
 236  
 237          var index = this.requiredStrings.length;
 238          this.requiredStrings.push({key: key, component: component, param: param});
 239          return '{{_s' + index + '}}';
 240      };
 241  
 242      /**
 243       * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
 244       *
 245       * @method quoteHelper
 246       * @private
 247       * @param {object} context The current mustache context.
 248       * @param {string} sectionText The text to parse the arguments from.
 249       * @param {function} helper Used to render subsections of the text.
 250       * @return {string}
 251       */
 252      Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
 253          var content = helper(sectionText.trim(), context);
 254  
 255          // Escape the {{ and the ".
 256          // This involves wrapping {{, and }} in change delimeter tags.
 257          content = content
 258              .replace('"', '\\"')
 259              .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
 260              ;
 261          return '"' + content + '"';
 262      };
 263  
 264      /**
 265       * Add some common helper functions to all context objects passed to templates.
 266       * These helpers match exactly the helpers available in php.
 267       *
 268       * @method addHelpers
 269       * @private
 270       * @param {Object} context Simple types used as the context for the template.
 271       * @param {String} themeName We set this multiple times, because there are async calls.
 272       */
 273      Renderer.prototype.addHelpers = function(context, themeName) {
 274          this.currentThemeName = themeName;
 275          this.requiredStrings = [];
 276          this.requiredJS = [];
 277          context.uniqid = (uniqInstances++);
 278          context.str = function() {
 279            return this.stringHelper.bind(this, context);
 280          }.bind(this);
 281          context.pix = function() {
 282            return this.pixHelper.bind(this, context);
 283          }.bind(this);
 284          context.js = function() {
 285            return this.jsHelper.bind(this, context);
 286          }.bind(this);
 287          context.quote = function() {
 288            return this.quoteHelper.bind(this, context);
 289          }.bind(this);
 290          context.globals = {config: config};
 291          context.currentTheme = themeName;
 292      };
 293  
 294      /**
 295       * Get all the JS blocks from the last rendered template.
 296       *
 297       * @method getJS
 298       * @private
 299       * @param {string[]} strings Replacement strings.
 300       * @return {string}
 301       */
 302      Renderer.prototype.getJS = function(strings) {
 303          var js = '';
 304          if (this.requiredJS.length > 0) {
 305              js = this.requiredJS.join(";\n");
 306          }
 307  
 308          // Re-render to get the final strings.
 309          return this.treatStringsInContent(js, strings);
 310      };
 311  
 312      /**
 313       * Treat strings in content.
 314       *
 315       * The purpose of this method is to replace the placeholders found in a string
 316       * with the their respective translated strings.
 317       *
 318       * Previously we were relying on String.replace() but the complexity increased with
 319       * the numbers of strings to replace. Now we manually walk the string and stop at each
 320       * placeholder we find, only then we replace it. Most of the time we will
 321       * replace all the placeholders in a single run, at times we will need a few
 322       * more runs when placeholders are replaced with strings that contain placeholders
 323       * themselves.
 324       *
 325       * @param {String} content The content in which string placeholders are to be found.
 326       * @param {Array} strings The strings to replace with.
 327       * @return {String} The treated content.
 328       */
 329      Renderer.prototype.treatStringsInContent = function(content, strings) {
 330          var pattern = /{{_s\d+}}/,
 331              treated,
 332              index,
 333              strIndex,
 334              walker,
 335              char,
 336              strFinal;
 337  
 338          do {
 339              treated = '';
 340              index = content.search(pattern);
 341              while (index > -1) {
 342  
 343                  // Copy the part prior to the placeholder to the treated string.
 344                  treated += content.substring(0, index);
 345                  content = content.substr(index);
 346                  strIndex = '';
 347                  walker = 4;  // 4 is the length of '{{_s'.
 348  
 349                  // Walk the characters to manually extract the index of the string from the placeholder.
 350                  char = content.substr(walker, 1);
 351                  do {
 352                      strIndex += char;
 353                      walker++;
 354                      char = content.substr(walker, 1);
 355                  } while (char != '}');
 356  
 357                  // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
 358                  strFinal = strings[parseInt(strIndex, 10)];
 359                  if (typeof strFinal === 'undefined') {
 360                      Log.debug('Could not find string for pattern {{_s' + strIndex + '}}.');
 361                      strFinal = '';
 362                  }
 363                  treated += strFinal;
 364                  content = content.substr(6 + strIndex.length);  // 6 is the length of the placeholder without the index: '{{_s}}'.
 365  
 366                  // Find the next placeholder.
 367                  index = content.search(pattern);
 368              }
 369  
 370              // The content becomes the treated part with the rest of the content.
 371              content = treated + content;
 372  
 373              // Check if we need to walk the content again, in case strings contained placeholders.
 374              index = content.search(pattern);
 375  
 376          } while (index > -1);
 377  
 378          return content;
 379      };
 380  
 381      /**
 382       * Render a template and then call the callback with the result.
 383       *
 384       * @method doRender
 385       * @private
 386       * @param {string} templateSource The mustache template to render.
 387       * @param {Object} context Simple types used as the context for the template.
 388       * @param {String} themeName Name of the current theme.
 389       * @return {Promise} object
 390       */
 391      Renderer.prototype.doRender = function(templateSource, context, themeName) {
 392          var deferred = $.Deferred();
 393  
 394          this.currentThemeName = themeName;
 395  
 396          // Make sure we fetch this first.
 397          var loadPixTemplate = this.getTemplate('core/pix_icon', true);
 398  
 399          loadPixTemplate.done(
 400              function() {
 401                  this.addHelpers(context, themeName);
 402                  var result = '';
 403                  try {
 404                      result = mustache.render(templateSource, context, this.partialHelper.bind(this));
 405                  } catch (ex) {
 406                      deferred.reject(ex);
 407                  }
 408  
 409                  if (this.requiredStrings.length > 0) {
 410                      str.get_strings(this.requiredStrings)
 411                      .then(function(strings) {
 412  
 413                          // Why do we not do another call the render here?
 414                          //
 415                          // Because that would expose DOS holes. E.g.
 416                          // I create an assignment called "{{fish" which
 417                          // would get inserted in the template in the first pass
 418                          // and cause the template to die on the second pass (unbalanced).
 419  
 420                          result = this.treatStringsInContent(result, strings);
 421                          deferred.resolve(result, this.getJS(strings));
 422                      }.bind(this))
 423                      .fail(deferred.reject);
 424                  } else {
 425                      deferred.resolve(result.trim(), this.getJS([]));
 426                  }
 427              }.bind(this)
 428          ).fail(deferred.reject);
 429          return deferred.promise();
 430      };
 431  
 432      /**
 433       * Execute a block of JS returned from a template.
 434       * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
 435       *
 436       * @method runTemplateJS
 437       * @param {string} source - A block of javascript.
 438       */
 439      var runTemplateJS = function(source) {
 440          if (source.trim() !== '') {
 441              var newscript = $('<script>').attr('type', 'text/javascript').html(source);
 442              $('head').append(newscript);
 443          }
 444      };
 445  
 446      /**
 447       * Do some DOM replacement and trigger correct events and fire javascript.
 448       *
 449       * @method domReplace
 450       * @private
 451       * @param {JQuery} element - Element or selector to replace.
 452       * @param {String} newHTML - HTML to insert / replace.
 453       * @param {String} newJS - Javascript to run after the insertion.
 454       * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
 455       */
 456      var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
 457          var replaceNode = $(element);
 458          if (replaceNode.length) {
 459              // First create the dom nodes so we have a reference to them.
 460              var newNodes = $(newHTML);
 461              var yuiNodes = null;
 462              // Do the replacement in the page.
 463              if (replaceChildNodes) {
 464                  // Cleanup any YUI event listeners attached to any of these nodes.
 465                  yuiNodes = new Y.NodeList(replaceNode.children().get());
 466                  yuiNodes.destroy(true);
 467  
 468                  // JQuery will cleanup after itself.
 469                  replaceNode.empty();
 470                  replaceNode.append(newNodes);
 471              } else {
 472                  // Cleanup any YUI event listeners attached to any of these nodes.
 473                  yuiNodes = new Y.NodeList(replaceNode.get());
 474                  yuiNodes.destroy(true);
 475  
 476                  // JQuery will cleanup after itself.
 477                  replaceNode.replaceWith(newNodes);
 478              }
 479              // Run any javascript associated with the new HTML.
 480              runTemplateJS(newJS);
 481              // Notify all filters about the new content.
 482              event.notifyFilterContentUpdated(newNodes);
 483          }
 484      };
 485  
 486      /**
 487       * Load a template and call doRender on it.
 488       *
 489       * @method render
 490       * @private
 491       * @param {string} templateName - should consist of the component and the name of the template like this:
 492       *                              core/menu (lib/templates/menu.mustache) or
 493       *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
 494       * @param {Object} context - Could be array, string or simple value for the context of the template.
 495       * @param {string} themeName - Name of the current theme.
 496       * @return {Promise} JQuery promise object resolved when the template has been rendered.
 497       */
 498      Renderer.prototype.render = function(templateName, context, themeName) {
 499          var deferred = $.Deferred();
 500  
 501          if (typeof (themeName) === "undefined") {
 502              // System context by default.
 503              themeName = config.theme;
 504          }
 505  
 506          this.currentThemeName = themeName;
 507  
 508          var loadTemplate = this.getTemplate(templateName, true);
 509  
 510          loadTemplate.done(
 511              function(templateSource) {
 512                  var renderPromise = this.doRender(templateSource, context, themeName);
 513  
 514                  renderPromise.done(
 515                      function(result, js) {
 516                          deferred.resolve(result, js);
 517                      }
 518                  ).fail(
 519                      function(ex) {
 520                          deferred.reject(ex);
 521                      }
 522                  );
 523              }.bind(this)
 524          ).fail(
 525              function(ex) {
 526                  deferred.reject(ex);
 527              }
 528          );
 529          return deferred.promise();
 530      };
 531  
 532  
 533      return /** @alias module:core/templates */ {
 534          // Public variables and functions.
 535          /**
 536           * Every call to render creates a new instance of the class and calls render on it. This
 537           * means each render call has it's own class variables.
 538           *
 539           * @method render
 540           * @private
 541           * @param {string} templateName - should consist of the component and the name of the template like this:
 542           *                              core/menu (lib/templates/menu.mustache) or
 543           *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
 544           * @param {Object} context - Could be array, string or simple value for the context of the template.
 545           * @param {string} themeName - Name of the current theme.
 546           * @return {Promise} JQuery promise object resolved when the template has been rendered.
 547           */
 548          render: function(templateName, context, themeName) {
 549              var renderer = new Renderer();
 550              return renderer.render(templateName, context, themeName);
 551          },
 552  
 553          /**
 554           * Execute a block of JS returned from a template.
 555           * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
 556           *
 557           * @method runTemplateJS
 558           * @param {string} source - A block of javascript.
 559           */
 560          runTemplateJS: runTemplateJS,
 561  
 562          /**
 563           * Replace a node in the page with some new HTML and run the JS.
 564           *
 565           * @method replaceNodeContents
 566           * @param {JQuery} element - Element or selector to replace.
 567           * @param {String} newHTML - HTML to insert / replace.
 568           * @param {String} newJS - Javascript to run after the insertion.
 569           */
 570          replaceNodeContents: function(element, newHTML, newJS) {
 571              domReplace(element, newHTML, newJS, true);
 572          },
 573  
 574          /**
 575           * Insert a node in the page with some new HTML and run the JS.
 576           *
 577           * @method replaceNode
 578           * @param {JQuery} element - Element or selector to replace.
 579           * @param {String} newHTML - HTML to insert / replace.
 580           * @param {String} newJS - Javascript to run after the insertion.
 581           */
 582          replaceNode: function(element, newHTML, newJS) {
 583              domReplace(element, newHTML, newJS, false);
 584          }
 585      };
 586  });


Generated: Thu Aug 11 10:00:09 2016 Cross-referenced by PHPXref 0.7.1