lib/amd/src/local/templates/renderer.js

  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. import * as Log from 'core/log';
  16. import * as Truncate from 'core/truncate';
  17. import * as UserDate from 'core/user_date';
  18. import Pending from 'core/pending';
  19. import {getStrings} from 'core/str';
  20. import IconSystem from 'core/icon_system';
  21. import config from 'core/config';
  22. import mustache from 'core/mustache';
  23. import Loader from './loader';
  24. import {getNormalisedComponent} from 'core/utils';
  25. /** @var {string} The placeholder character used for standard strings (unclean) */
  26. const placeholderString = 's';
  27. /** @var {string} The placeholder character used for cleaned strings */
  28. const placeholderCleanedString = 'c';
  29. /**
  30. * Template Renderer Class.
  31. *
  32. * Note: This class is not intended to be instantiated directly. Instead, use the core/templates module.
  33. *
  34. * @module core/local/templates/renderer
  35. * @copyright 2023 Andrew Lyons <andrew@nicols.co.uk>
  36. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  37. * @since 4.3
  38. */
  39. export default class Renderer {
  40. /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
  41. requiredStrings = null;
  42. /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */
  43. requiredDates = [];
  44. /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
  45. requiredJS = null;
  46. /** @var {String} themeName for the current render */
  47. currentThemeName = '';
  48. /** @var {Number} uniqInstances Count of times this constructor has been called. */
  49. static uniqInstances = 0;
  50. /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
  51. static loadTemplateBuffer = [];
  52. /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
  53. static isLoadingTemplates = false;
  54. /** @var {Object} iconSystem - Object extending core/iconsystem */
  55. iconSystem = null;
  56. /** @var {Array} disallowedNestedHelpers - List of helpers that can't be called within other helpers */
  57. static disallowedNestedHelpers = [
  58. 'js',
  59. ];
  60. /** @var {String[]} templateCache - Cache of already loaded template strings */
  61. static templateCache = {};
  62. /**
  63. * Cache of already loaded template promises.
  64. *
  65. * @type {Promise[]}
  66. * @static
  67. * @private
  68. */
  69. static templatePromises = {};
  70. /**
  71. * The loader used to fetch templates.
  72. * @type {Loader}
  73. * @static
  74. * @private
  75. */
  76. static loader = Loader;
  77. /**
  78. * Constructor
  79. *
  80. * Each call to templates.render gets it's own instance of this class.
  81. */
  82. constructor() {
  83. this.requiredStrings = [];
  84. this.requiredJS = [];
  85. this.requiredDates = [];
  86. this.currentThemeName = '';
  87. }
  88. /**
  89. * Set the template loader to use for all Template renderers.
  90. *
  91. * @param {Loader} loader
  92. */
  93. static setLoader(loader) {
  94. this.loader = loader;
  95. }
  96. /**
  97. * Get the Loader used to fetch templates.
  98. *
  99. * @returns {Loader}
  100. */
  101. static getLoader() {
  102. return this.loader;
  103. }
  104. /**
  105. * Render a single image icon.
  106. *
  107. * @method renderIcon
  108. * @private
  109. * @param {string} key The icon key.
  110. * @param {string} component The component name.
  111. * @param {string} title The icon title
  112. * @returns {Promise}
  113. */
  114. async renderIcon(key, component, title) {
  115. // Preload the module to do the icon rendering based on the theme iconsystem.
  116. component = getNormalisedComponent(component);
  117. await this.setupIconSystem();
  118. const template = await Renderer.getLoader().getTemplate(
  119. this.iconSystem.getTemplateName(),
  120. this.currentThemeName,
  121. );
  122. return this.iconSystem.renderIcon(
  123. key,
  124. component,
  125. title,
  126. template
  127. );
  128. }
  129. /**
  130. * Helper to set up the icon system.
  131. */
  132. async setupIconSystem() {
  133. if (!this.iconSystem) {
  134. this.iconSystem = await IconSystem.instance();
  135. }
  136. return this.iconSystem;
  137. }
  138. /**
  139. * Render image icons.
  140. *
  141. * @method pixHelper
  142. * @private
  143. * @param {object} context The mustache context
  144. * @param {string} sectionText The text to parse arguments from.
  145. * @param {function} helper Used to render the alt attribute of the text.
  146. * @returns {string}
  147. */
  148. pixHelper(context, sectionText, helper) {
  149. const parts = sectionText.split(',');
  150. let key = '';
  151. let component = '';
  152. let text = '';
  153. if (parts.length > 0) {
  154. key = helper(parts.shift().trim(), context);
  155. }
  156. if (parts.length > 0) {
  157. component = helper(parts.shift().trim(), context);
  158. }
  159. if (parts.length > 0) {
  160. text = helper(parts.join(',').trim(), context);
  161. }
  162. // Note: We cannot use Promises in Mustache helpers.
  163. // We must fetch straight from the Loader cache.
  164. // The Loader cache is statically defined on the Loader class and should be used by all children.
  165. const Loader = Renderer.getLoader();
  166. const templateName = this.iconSystem.getTemplateName();
  167. const searchKey = Loader.getSearchKey(this.currentThemeName, templateName);
  168. const template = Loader.getTemplateFromCache(searchKey);
  169. component = getNormalisedComponent(component);
  170. // The key might have been escaped by the JS Mustache engine which
  171. // converts forward slashes to HTML entities. Let us undo that here.
  172. key = key.replace(/&#x2F;/gi, '/');
  173. return this.iconSystem.renderIcon(
  174. key,
  175. component,
  176. text,
  177. template
  178. );
  179. }
  180. /**
  181. * Render blocks of javascript and save them in an array.
  182. *
  183. * @method jsHelper
  184. * @private
  185. * @param {object} context The current mustache context.
  186. * @param {string} sectionText The text to save as a js block.
  187. * @param {function} helper Used to render the block.
  188. * @returns {string}
  189. */
  190. jsHelper(context, sectionText, helper) {
  191. this.requiredJS.push(helper(sectionText, context));
  192. return '';
  193. }
  194. /**
  195. * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
  196. * into a get_string call.
  197. *
  198. * @method stringHelper
  199. * @private
  200. * @param {object} context The current mustache context.
  201. * @param {string} sectionText The text to parse the arguments from.
  202. * @param {function} helper Used to render subsections of the text.
  203. * @returns {string}
  204. */
  205. stringHelper(context, sectionText, helper) {
  206. // A string instruction is in the format:
  207. // key, component, params.
  208. let parts = sectionText.split(',');
  209. const key = parts.length > 0 ? parts.shift().trim() : '';
  210. const component = parts.length > 0 ? getNormalisedComponent(parts.shift().trim()) : '';
  211. let param = parts.length > 0 ? parts.join(',').trim() : '';
  212. if (param !== '') {
  213. // Allow variable expansion in the param part only.
  214. param = helper(param, context);
  215. }
  216. if (param.match(/^{\s*"/gm)) {
  217. // If it can't be parsed then the string is not a JSON format.
  218. try {
  219. const parsedParam = JSON.parse(param);
  220. // Handle non-exception-throwing cases, e.g. null, integer, boolean.
  221. if (parsedParam && typeof parsedParam === "object") {
  222. param = parsedParam;
  223. }
  224. } catch (err) {
  225. // This was probably not JSON.
  226. // Keep the error message visible but do not promote it because it may not be an error.
  227. window.console.warn(err.message);
  228. }
  229. }
  230. const index = this.requiredStrings.length;
  231. this.requiredStrings.push({
  232. key,
  233. component,
  234. param,
  235. });
  236. // The placeholder must not use {{}} as those can be misinterpreted by the engine.
  237. return `[[_s${index}]]`;
  238. }
  239. /**
  240. * String helper to render {{#cleanstr}}abd component { a : 'fish'}{{/cleanstr}}
  241. * into a get_string following by an HTML escape.
  242. *
  243. * @method cleanStringHelper
  244. * @private
  245. * @param {object} context The current mustache context.
  246. * @param {string} sectionText The text to parse the arguments from.
  247. * @param {function} helper Used to render subsections of the text.
  248. * @returns {string}
  249. */
  250. cleanStringHelper(context, sectionText, helper) {
  251. // We're going to use [[_cx]] format for clean strings, where x is a number.
  252. // Hence, replacing 's' with 'c' in the placeholder that stringHelper returns.
  253. return this
  254. .stringHelper(context, sectionText, helper)
  255. .replace(placeholderString, placeholderCleanedString);
  256. }
  257. /**
  258. * Quote helper used to wrap content in quotes, and escape all special JSON characters present in the content.
  259. *
  260. * @method quoteHelper
  261. * @private
  262. * @param {object} context The current mustache context.
  263. * @param {string} sectionText The text to parse the arguments from.
  264. * @param {function} helper Used to render subsections of the text.
  265. * @returns {string}
  266. */
  267. quoteHelper(context, sectionText, helper) {
  268. let content = helper(sectionText.trim(), context);
  269. // Escape the {{ and JSON encode.
  270. // This involves wrapping {{, and }} in change delimeter tags.
  271. content = JSON.stringify(content);
  272. content = content.replace(/([{}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>');
  273. return content;
  274. }
  275. /**
  276. * Shorten text helper to truncate text and append a trailing ellipsis.
  277. *
  278. * @method shortenTextHelper
  279. * @private
  280. * @param {object} context The current mustache context.
  281. * @param {string} sectionText The text to parse the arguments from.
  282. * @param {function} helper Used to render subsections of the text.
  283. * @returns {string}
  284. */
  285. shortenTextHelper(context, sectionText, helper) {
  286. // Non-greedy split on comma to grab section text into the length and
  287. // text parts.
  288. const parts = sectionText.match(/(.*?),(.*)/);
  289. // The length is the part matched in the first set of parethesis.
  290. const length = parts[1].trim();
  291. // The length is the part matched in the second set of parethesis.
  292. const text = parts[2].trim();
  293. const content = helper(text, context);
  294. return Truncate.truncate(content, {
  295. length,
  296. words: true,
  297. ellipsis: '...'
  298. });
  299. }
  300. /**
  301. * User date helper to render user dates from timestamps.
  302. *
  303. * @method userDateHelper
  304. * @private
  305. * @param {object} context The current mustache context.
  306. * @param {string} sectionText The text to parse the arguments from.
  307. * @param {function} helper Used to render subsections of the text.
  308. * @returns {string}
  309. */
  310. userDateHelper(context, sectionText, helper) {
  311. // Non-greedy split on comma to grab the timestamp and format.
  312. const parts = sectionText.match(/(.*?),(.*)/);
  313. const timestamp = helper(parts[1].trim(), context);
  314. const format = helper(parts[2].trim(), context);
  315. const index = this.requiredDates.length;
  316. this.requiredDates.push({
  317. timestamp: timestamp,
  318. format: format
  319. });
  320. return `[[_t_${index}]]`;
  321. }
  322. /**
  323. * Return a helper function to be added to the context for rendering the a
  324. * template.
  325. *
  326. * This will parse the provided text before giving it to the helper function
  327. * in order to remove any disallowed nested helpers to prevent one helper
  328. * from calling another.
  329. *
  330. * In particular to prevent the JS helper from being called from within another
  331. * helper because it can lead to security issues when the JS portion is user
  332. * provided.
  333. *
  334. * @param {function} helperFunction The helper function to add
  335. * @param {object} context The template context for the helper function
  336. * @returns {Function} To be set in the context
  337. */
  338. addHelperFunction(helperFunction, context) {
  339. return function() {
  340. return function(sectionText, helper) {
  341. // Override the disallowed helpers in the template context with
  342. // a function that returns an empty string for use when executing
  343. // other helpers. This is to prevent these helpers from being
  344. // executed as part of the rendering of another helper in order to
  345. // prevent any potential security issues.
  346. const originalHelpers = Renderer.disallowedNestedHelpers.reduce((carry, name) => {
  347. if (context.hasOwnProperty(name)) {
  348. carry[name] = context[name];
  349. }
  350. return carry;
  351. }, {});
  352. Renderer.disallowedNestedHelpers.forEach((helperName) => {
  353. context[helperName] = () => '';
  354. });
  355. // Execute the helper with the modified context that doesn't include
  356. // the disallowed nested helpers. This prevents the disallowed
  357. // helpers from being called from within other helpers.
  358. const result = helperFunction.apply(this, [context, sectionText, helper]);
  359. // Restore the original helper implementation in the context so that
  360. // any further rendering has access to them again.
  361. for (const name in originalHelpers) {
  362. context[name] = originalHelpers[name];
  363. }
  364. return result;
  365. }.bind(this);
  366. }.bind(this);
  367. }
  368. /**
  369. * Add some common helper functions to all context objects passed to templates.
  370. * These helpers match exactly the helpers available in php.
  371. *
  372. * @method addHelpers
  373. * @private
  374. * @param {Object} context Simple types used as the context for the template.
  375. * @param {String} themeName We set this multiple times, because there are async calls.
  376. */
  377. addHelpers(context, themeName) {
  378. this.currentThemeName = themeName;
  379. this.requiredStrings = [];
  380. this.requiredJS = [];
  381. context.uniqid = (Renderer.uniqInstances++);
  382. // Please note that these helpers _must_ not return a Promise.
  383. context.str = this.addHelperFunction(this.stringHelper, context);
  384. context.cleanstr = this.addHelperFunction(this.cleanStringHelper, context);
  385. context.pix = this.addHelperFunction(this.pixHelper, context);
  386. context.js = this.addHelperFunction(this.jsHelper, context);
  387. context.quote = this.addHelperFunction(this.quoteHelper, context);
  388. context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);
  389. context.userdate = this.addHelperFunction(this.userDateHelper, context);
  390. context.globals = {config: config};
  391. context.currentTheme = themeName;
  392. }
  393. /**
  394. * Get all the JS blocks from the last rendered template.
  395. *
  396. * @method getJS
  397. * @private
  398. * @returns {string}
  399. */
  400. getJS() {
  401. return this.requiredJS.join(";\n");
  402. }
  403. /**
  404. * Treat strings in content.
  405. *
  406. * The purpose of this method is to replace the placeholders found in a string
  407. * with the their respective translated strings.
  408. *
  409. * Previously we were relying on String.replace() but the complexity increased with
  410. * the numbers of strings to replace. Now we manually walk the string and stop at each
  411. * placeholder we find, only then we replace it. Most of the time we will
  412. * replace all the placeholders in a single run, at times we will need a few
  413. * more runs when placeholders are replaced with strings that contain placeholders
  414. * themselves.
  415. *
  416. * @param {String} content The content in which string placeholders are to be found.
  417. * @param {Map} stringMap The strings to replace with.
  418. * @returns {String} The treated content.
  419. */
  420. treatStringsInContent(content, stringMap) {
  421. // Placeholders are in the for [[_sX]] or [[_cX]] where X is the string index.
  422. const stringPattern = /(?<placeholder>\[\[_(?<stringType>[cs])(?<stringIndex>\d+)\]\])/g;
  423. // A helper to fetch the string for a given placeholder.
  424. const getUpdatedString = ({placeholder, stringType, stringIndex}) => {
  425. if (stringMap.has(placeholder)) {
  426. return stringMap.get(placeholder);
  427. }
  428. if (stringType === placeholderCleanedString) {
  429. // Attempt to find the unclean string and clean it. Store it for later use.
  430. const uncleanString = stringMap.get(`[[_s${stringIndex}]]`);
  431. if (uncleanString) {
  432. stringMap.set(placeholder, mustache.escape(uncleanString));
  433. return stringMap.get(placeholder);
  434. }
  435. }
  436. Log.debug(`Could not find string for pattern ${placeholder}`);
  437. return ''; // Fallback if no match is found.
  438. };
  439. let updatedContent = content; // Start with the original content.
  440. let placeholderFound = true; // Flag to track if we are still finding placeholders.
  441. // Continue looping until no more placeholders are found in the updated content.
  442. while (placeholderFound) {
  443. let match;
  444. let result = [];
  445. let lastIndex = 0;
  446. placeholderFound = false; // Assume no placeholders are found.
  447. // Find all placeholders in the content and replace them with their respective strings.
  448. while ((match = stringPattern.exec(updatedContent)) !== null) {
  449. placeholderFound = true; // A placeholder was found, so continue looping.
  450. // Add the content before the matched placeholder.
  451. result.push(updatedContent.slice(lastIndex, match.index));
  452. // Add the updated string for the placeholder.
  453. result.push(getUpdatedString(match.groups));
  454. // Update lastIndex to move past the current match.
  455. lastIndex = match.index + match[0].length;
  456. }
  457. // Add the remaining part of the content after the last match.
  458. result.push(updatedContent.slice(lastIndex));
  459. // Join the parts of the result array into the updated content.
  460. updatedContent = result.join('');
  461. }
  462. return updatedContent; // Return the fully updated content after all loops.
  463. }
  464. /**
  465. * Treat strings in content.
  466. *
  467. * The purpose of this method is to replace the date placeholders found in the
  468. * content with the their respective translated dates.
  469. *
  470. * @param {String} content The content in which string placeholders are to be found.
  471. * @param {Array} dates The dates to replace with.
  472. * @returns {String} The treated content.
  473. */
  474. treatDatesInContent(content, dates) {
  475. dates.forEach((date, index) => {
  476. content = content.replace(
  477. new RegExp(`\\[\\[_t_${index}\\]\\]`, 'g'),
  478. date,
  479. );
  480. });
  481. return content;
  482. }
  483. /**
  484. * Render a template and then call the callback with the result.
  485. *
  486. * @method doRender
  487. * @private
  488. * @param {string|Promise} templateSourcePromise The mustache template to render.
  489. * @param {Object} context Simple types used as the context for the template.
  490. * @param {String} themeName Name of the current theme.
  491. * @returns {Promise<object<string, string>>} The rendered HTML and JS.
  492. */
  493. async doRender(templateSourcePromise, context, themeName) {
  494. this.currentThemeName = themeName;
  495. const iconTemplate = this.iconSystem.getTemplateName();
  496. const pendingPromise = new Pending('core/templates:doRender');
  497. const [templateSource] = await Promise.all([
  498. templateSourcePromise,
  499. Renderer.getLoader().getTemplate(iconTemplate, themeName),
  500. ]);
  501. this.addHelpers(context, themeName);
  502. // Render the template.
  503. const renderedContent = await mustache.render(
  504. templateSource,
  505. context,
  506. // Note: The third parameter is a function that will be called to process partials.
  507. (partialName) => Renderer.getLoader().partialHelper(partialName, themeName),
  508. );
  509. const {html, js} = await this.processRenderedContent(renderedContent);
  510. pendingPromise.resolve();
  511. return {html, js};
  512. }
  513. /**
  514. * Process the rendered content, treating any strings and applying and helper strings, dates, etc.
  515. * @param {string} renderedContent
  516. * @returns {Promise<object<string, string>>} The rendered HTML and JS.
  517. */
  518. async processRenderedContent(renderedContent) {
  519. let html = renderedContent.trim();
  520. let js = this.getJS();
  521. if (this.requiredStrings.length > 0) {
  522. // Fetch the strings into a new Map using the placeholder as an index.
  523. // Note: We only fetch the unclean version. Cleaning of strings happens lazily in treatStringsInContent.
  524. const stringMap = new Map(
  525. (await getStrings(this.requiredStrings)).map((string, index) => (
  526. [`[[_s${index}]]`, string]
  527. ))
  528. );
  529. // Make sure string substitutions are done for the userdate
  530. // values as well.
  531. this.requiredDates = this.requiredDates.map(function(date) {
  532. return {
  533. timestamp: this.treatStringsInContent(date.timestamp, stringMap),
  534. format: this.treatStringsInContent(date.format, stringMap)
  535. };
  536. }.bind(this));
  537. // Why do we not do another call the render here?
  538. //
  539. // Because that would expose DOS holes. E.g.
  540. // I create an assignment called "{{fish" which
  541. // would get inserted in the template in the first pass
  542. // and cause the template to die on the second pass (unbalanced).
  543. html = this.treatStringsInContent(html, stringMap);
  544. js = this.treatStringsInContent(js, stringMap);
  545. }
  546. // This has to happen after the strings replacement because you can
  547. // use the string helper in content for the user date helper.
  548. if (this.requiredDates.length > 0) {
  549. const dates = await UserDate.get(this.requiredDates);
  550. html = this.treatDatesInContent(html, dates);
  551. js = this.treatDatesInContent(js, dates);
  552. }
  553. return {html, js};
  554. }
  555. /**
  556. * Load a template and call doRender on it.
  557. *
  558. * @method render
  559. * @private
  560. * @param {string} templateName - should consist of the component and the name of the template like this:
  561. * core/menu (lib/templates/menu.mustache) or
  562. * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
  563. * @param {Object} [context={}] - Could be array, string or simple value for the context of the template.
  564. * @param {string} [themeName] - Name of the current theme.
  565. * @returns {Promise<object>} Native promise object resolved when the template has been rendered.}
  566. */
  567. async render(
  568. templateName,
  569. context = {},
  570. themeName = config.theme,
  571. ) {
  572. this.currentThemeName = themeName;
  573. // Preload the module to do the icon rendering based on the theme iconsystem.
  574. await this.setupIconSystem();
  575. const templateSource = Renderer.getLoader().cachePartials(templateName, themeName);
  576. return this.doRender(templateSource, context, themeName);
  577. }
  578. }