// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
import * as Log from 'core/log';
import * as Truncate from 'core/truncate';
import * as UserDate from 'core/user_date';
import Pending from 'core/pending';
import {getStrings} from 'core/str';
import IconSystem from 'core/icon_system';
import config from 'core/config';
import mustache from 'core/mustache';
import Loader from './loader';
import {getNormalisedComponent} from 'core/utils';
/** @var {string} The placeholder character used for standard strings (unclean) */
const placeholderString = 's';
/** @var {string} The placeholder character used for cleaned strings */
const placeholderCleanedString = 'c';
/**
* Template Renderer Class.
*
* Note: This class is not intended to be instantiated directly. Instead, use the core/templates module.
*
* @module core/local/templates/renderer
* @copyright 2023 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.3
*/
export default class Renderer {
/** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
requiredStrings = null;
/** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */
requiredDates = [];
/** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
requiredJS = null;
/** @var {String} themeName for the current render */
currentThemeName = '';
/** @var {Number} uniqInstances Count of times this constructor has been called. */
static uniqInstances = 0;
/** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
static loadTemplateBuffer = [];
/** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
static isLoadingTemplates = false;
/** @var {Object} iconSystem - Object extending core/iconsystem */
iconSystem = null;
/** @var {Array} disallowedNestedHelpers - List of helpers that can't be called within other helpers */
static disallowedNestedHelpers = [
'js',
];
/** @var {String[]} templateCache - Cache of already loaded template strings */
static templateCache = {};
/**
* Cache of already loaded template promises.
*
* @type {Promise[]}
* @static
* @private
*/
static templatePromises = {};
/**
* The loader used to fetch templates.
* @type {Loader}
* @static
* @private
*/
static loader = Loader;
/**
* Constructor
*
* Each call to templates.render gets it's own instance of this class.
*/
constructor() {
this.requiredStrings = [];
this.requiredJS = [];
this.requiredDates = [];
this.currentThemeName = '';
}
/**
* Set the template loader to use for all Template renderers.
*
* @param {Loader} loader
*/
static setLoader(loader) {
this.loader = loader;
}
/**
* Get the Loader used to fetch templates.
*
* @returns {Loader}
*/
static getLoader() {
return this.loader;
}
/**
* Render a single image icon.
*
* @method renderIcon
* @private
* @param {string} key The icon key.
* @param {string} component The component name.
* @param {string} title The icon title
* @returns {Promise}
*/
async renderIcon(key, component, title) {
// Preload the module to do the icon rendering based on the theme iconsystem.
component = getNormalisedComponent(component);
await this.setupIconSystem();
const template = await Renderer.getLoader().getTemplate(
this.iconSystem.getTemplateName(),
this.currentThemeName,
);
return this.iconSystem.renderIcon(
key,
component,
title,
template
);
}
/**
* Helper to set up the icon system.
*/
async setupIconSystem() {
if (!this.iconSystem) {
this.iconSystem = await IconSystem.instance();
}
return this.iconSystem;
}
/**
* Render image icons.
*
* @method pixHelper
* @private
* @param {object} context The mustache context
* @param {string} sectionText The text to parse arguments from.
* @param {function} helper Used to render the alt attribute of the text.
* @returns {string}
*/
pixHelper(context, sectionText, helper) {
const parts = sectionText.split(',');
let key = '';
let component = '';
let text = '';
if (parts.length > 0) {
key = helper(parts.shift().trim(), context);
}
if (parts.length > 0) {
component = helper(parts.shift().trim(), context);
}
if (parts.length > 0) {
text = helper(parts.join(',').trim(), context);
}
// Note: We cannot use Promises in Mustache helpers.
// We must fetch straight from the Loader cache.
// The Loader cache is statically defined on the Loader class and should be used by all children.
const Loader = Renderer.getLoader();
const templateName = this.iconSystem.getTemplateName();
const searchKey = Loader.getSearchKey(this.currentThemeName, templateName);
const template = Loader.getTemplateFromCache(searchKey);
component = getNormalisedComponent(component);
// The key might have been escaped by the JS Mustache engine which
// converts forward slashes to HTML entities. Let us undo that here.
key = key.replace(///gi, '/');
return this.iconSystem.renderIcon(
key,
component,
text,
template
);
}
/**
* Render blocks of javascript and save them in an array.
*
* @method jsHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to save as a js block.
* @param {function} helper Used to render the block.
* @returns {string}
*/
jsHelper(context, sectionText, helper) {
this.requiredJS.push(helper(sectionText, context));
return '';
}
/**
* String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
* into a get_string call.
*
* @method stringHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to parse the arguments from.
* @param {function} helper Used to render subsections of the text.
* @returns {string}
*/
stringHelper(context, sectionText, helper) {
// A string instruction is in the format:
// key, component, params.
let parts = sectionText.split(',');
const key = parts.length > 0 ? parts.shift().trim() : '';
const component = parts.length > 0 ? getNormalisedComponent(parts.shift().trim()) : '';
let param = parts.length > 0 ? parts.join(',').trim() : '';
if (param !== '') {
// Allow variable expansion in the param part only.
param = helper(param, context);
}
if (param.match(/^{\s*"/gm)) {
// If it can't be parsed then the string is not a JSON format.
try {
const parsedParam = JSON.parse(param);
// Handle non-exception-throwing cases, e.g. null, integer, boolean.
if (parsedParam && typeof parsedParam === "object") {
param = parsedParam;
}
} catch (err) {
// This was probably not JSON.
// Keep the error message visible but do not promote it because it may not be an error.
window.console.warn(err.message);
}
}
const index = this.requiredStrings.length;
this.requiredStrings.push({
key,
component,
param,
});
// The placeholder must not use {{}} as those can be misinterpreted by the engine.
return `[[_s${index}]]`;
}
/**
* String helper to render {{#cleanstr}}abd component { a : 'fish'}{{/cleanstr}}
* into a get_string following by an HTML escape.
*
* @method cleanStringHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to parse the arguments from.
* @param {function} helper Used to render subsections of the text.
* @returns {string}
*/
cleanStringHelper(context, sectionText, helper) {
// We're going to use [[_cx]] format for clean strings, where x is a number.
// Hence, replacing 's' with 'c' in the placeholder that stringHelper returns.
return this
.stringHelper(context, sectionText, helper)
.replace(placeholderString, placeholderCleanedString);
}
/**
* Quote helper used to wrap content in quotes, and escape all special JSON characters present in the content.
*
* @method quoteHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to parse the arguments from.
* @param {function} helper Used to render subsections of the text.
* @returns {string}
*/
quoteHelper(context, sectionText, helper) {
let content = helper(sectionText.trim(), context);
// Escape the {{ and JSON encode.
// This involves wrapping {{, and }} in change delimeter tags.
content = JSON.stringify(content);
content = content.replace(/([{}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>');
return content;
}
/**
* Shorten text helper to truncate text and append a trailing ellipsis.
*
* @method shortenTextHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to parse the arguments from.
* @param {function} helper Used to render subsections of the text.
* @returns {string}
*/
shortenTextHelper(context, sectionText, helper) {
// Non-greedy split on comma to grab section text into the length and
// text parts.
const parts = sectionText.match(/(.*?),(.*)/);
// The length is the part matched in the first set of parethesis.
const length = parts[1].trim();
// The length is the part matched in the second set of parethesis.
const text = parts[2].trim();
const content = helper(text, context);
return Truncate.truncate(content, {
length,
words: true,
ellipsis: '...'
});
}
/**
* User date helper to render user dates from timestamps.
*
* @method userDateHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to parse the arguments from.
* @param {function} helper Used to render subsections of the text.
* @returns {string}
*/
userDateHelper(context, sectionText, helper) {
// Non-greedy split on comma to grab the timestamp and format.
const parts = sectionText.match(/(.*?),(.*)/);
const timestamp = helper(parts[1].trim(), context);
const format = helper(parts[2].trim(), context);
const index = this.requiredDates.length;
this.requiredDates.push({
timestamp: timestamp,
format: format
});
return `[[_t_${index}]]`;
}
/**
* Return a helper function to be added to the context for rendering the a
* template.
*
* This will parse the provided text before giving it to the helper function
* in order to remove any disallowed nested helpers to prevent one helper
* from calling another.
*
* In particular to prevent the JS helper from being called from within another
* helper because it can lead to security issues when the JS portion is user
* provided.
*
* @param {function} helperFunction The helper function to add
* @param {object} context The template context for the helper function
* @returns {Function} To be set in the context
*/
addHelperFunction(helperFunction, context) {
return function() {
return function(sectionText, helper) {
// Override the disallowed helpers in the template context with
// a function that returns an empty string for use when executing
// other helpers. This is to prevent these helpers from being
// executed as part of the rendering of another helper in order to
// prevent any potential security issues.
const originalHelpers = Renderer.disallowedNestedHelpers.reduce((carry, name) => {
if (context.hasOwnProperty(name)) {
carry[name] = context[name];
}
return carry;
}, {});
Renderer.disallowedNestedHelpers.forEach((helperName) => {
context[helperName] = () => '';
});
// Execute the helper with the modified context that doesn't include
// the disallowed nested helpers. This prevents the disallowed
// helpers from being called from within other helpers.
const result = helperFunction.apply(this, [context, sectionText, helper]);
// Restore the original helper implementation in the context so that
// any further rendering has access to them again.
for (const name in originalHelpers) {
context[name] = originalHelpers[name];
}
return result;
}.bind(this);
}.bind(this);
}
/**
* Add some common helper functions to all context objects passed to templates.
* These helpers match exactly the helpers available in php.
*
* @method addHelpers
* @private
* @param {Object} context Simple types used as the context for the template.
* @param {String} themeName We set this multiple times, because there are async calls.
*/
addHelpers(context, themeName) {
this.currentThemeName = themeName;
this.requiredStrings = [];
this.requiredJS = [];
context.uniqid = (Renderer.uniqInstances++);
// Please note that these helpers _must_ not return a Promise.
context.str = this.addHelperFunction(this.stringHelper, context);
context.cleanstr = this.addHelperFunction(this.cleanStringHelper, context);
context.pix = this.addHelperFunction(this.pixHelper, context);
context.js = this.addHelperFunction(this.jsHelper, context);
context.quote = this.addHelperFunction(this.quoteHelper, context);
context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);
context.userdate = this.addHelperFunction(this.userDateHelper, context);
context.globals = {config: config};
context.currentTheme = themeName;
}
/**
* Get all the JS blocks from the last rendered template.
*
* @method getJS
* @private
* @returns {string}
*/
getJS() {
return this.requiredJS.join(";\n");
}
/**
* Treat strings in content.
*
* The purpose of this method is to replace the placeholders found in a string
* with the their respective translated strings.
*
* Previously we were relying on String.replace() but the complexity increased with
* the numbers of strings to replace. Now we manually walk the string and stop at each
* placeholder we find, only then we replace it. Most of the time we will
* replace all the placeholders in a single run, at times we will need a few
* more runs when placeholders are replaced with strings that contain placeholders
* themselves.
*
* @param {String} content The content in which string placeholders are to be found.
* @param {Map} stringMap The strings to replace with.
* @returns {String} The treated content.
*/
treatStringsInContent(content, stringMap) {
// Placeholders are in the for [[_sX]] or [[_cX]] where X is the string index.
const stringPattern = /(?<placeholder>\[\[_(?<stringType>[cs])(?<stringIndex>\d+)\]\])/g;
// A helpre to fetch the string for a given placeholder.
const getUpdatedString = ({placeholder, stringType, stringIndex}) => {
if (stringMap.has(placeholder)) {
return stringMap.get(placeholder);
}
if (stringType === placeholderCleanedString) {
// Attempt to find the unclean string and clean it. Store it for later use.
const uncleanString = stringMap.get(`[[_s${stringIndex}]]`);
if (uncleanString) {
stringMap.set(placeholder, mustache.escape(uncleanString));
return stringMap.get(placeholder);
}
}
Log.debug(`Could not find string for pattern ${placeholder}`);
return '';
};
// Find all placeholders in the content and replace them with their respective strings.
let match;
while ((match = stringPattern.exec(content)) !== null) {
let updatedContent = content.slice(0, match.index);
updatedContent += getUpdatedString(match.groups);
updatedContent += content.slice(match.index + match.groups.placeholder.length);
content = updatedContent;
}
return content;
}
/**
* Treat strings in content.
*
* The purpose of this method is to replace the date placeholders found in the
* content with the their respective translated dates.
*
* @param {String} content The content in which string placeholders are to be found.
* @param {Array} dates The dates to replace with.
* @returns {String} The treated content.
*/
treatDatesInContent(content, dates) {
dates.forEach((date, index) => {
content = content.replace(
new RegExp(`\\[\\[_t_${index}\\]\\]`, 'g'),
date,
);
});
return content;
}
/**
* Render a template and then call the callback with the result.
*
* @method doRender
* @private
* @param {string|Promise} templateSourcePromise The mustache template to render.
* @param {Object} context Simple types used as the context for the template.
* @param {String} themeName Name of the current theme.
* @returns {Promise<object<string, string>>} The rendered HTML and JS.
*/
async doRender(templateSourcePromise, context, themeName) {
this.currentThemeName = themeName;
const iconTemplate = this.iconSystem.getTemplateName();
const pendingPromise = new Pending('core/templates:doRender');
const [templateSource] = await Promise.all([
templateSourcePromise,
Renderer.getLoader().getTemplate(iconTemplate, themeName),
]);
this.addHelpers(context, themeName);
// Render the template.
const renderedContent = await mustache.render(
templateSource,
context,
// Note: The third parameter is a function that will be called to process partials.
(partialName) => Renderer.getLoader().partialHelper(partialName, themeName),
);
const {html, js} = await this.processRenderedContent(renderedContent);
pendingPromise.resolve();
return {html, js};
}
/**
* Process the rendered content, treating any strings and applying and helper strings, dates, etc.
* @param {string} renderedContent
* @returns {Promise<object<string, string>>} The rendered HTML and JS.
*/
async processRenderedContent(renderedContent) {
let html = renderedContent.trim();
let js = this.getJS();
if (this.requiredStrings.length > 0) {
// Fetch the strings into a new Map using the placeholder as an index.
// Note: We only fetch the unclean version. Cleaning of strings happens lazily in treatStringsInContent.
const stringMap = new Map(
(await getStrings(this.requiredStrings)).map((string, index) => (
[`[[_s${index}]]`, string]
))
);
// Make sure string substitutions are done for the userdate
// values as well.
this.requiredDates = this.requiredDates.map(function(date) {
return {
timestamp: this.treatStringsInContent(date.timestamp, stringMap),
format: this.treatStringsInContent(date.format, stringMap)
};
}.bind(this));
// Why do we not do another call the render here?
//
// Because that would expose DOS holes. E.g.
// I create an assignment called "{{fish" which
// would get inserted in the template in the first pass
// and cause the template to die on the second pass (unbalanced).
html = this.treatStringsInContent(html, stringMap);
js = this.treatStringsInContent(js, stringMap);
}
// This has to happen after the strings replacement because you can
// use the string helper in content for the user date helper.
if (this.requiredDates.length > 0) {
const dates = await UserDate.get(this.requiredDates);
html = this.treatDatesInContent(html, dates);
js = this.treatDatesInContent(js, dates);
}
return {html, js};
}
/**
* Load a template and call doRender on it.
*
* @method render
* @private
* @param {string} templateName - should consist of the component and the name of the template like this:
* core/menu (lib/templates/menu.mustache) or
* tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
* @param {Object} [context={}] - Could be array, string or simple value for the context of the template.
* @param {string} [themeName] - Name of the current theme.
* @returns {Promise<object>} Native promise object resolved when the template has been rendered.}
*/
async render(
templateName,
context = {},
themeName = config.theme,
) {
this.currentThemeName = themeName;
// Preload the module to do the icon rendering based on the theme iconsystem.
await this.setupIconSystem();
const templateSource = Renderer.getLoader().cachePartials(templateName, themeName);
return this.doRender(templateSource, context, themeName);
}
}