Merge branch 'MDL-78266-master' of https://github.com/andrewnicols/moodle

This commit is contained in:
Jun Pataleta 2023-05-29 17:16:03 +08:00
commit c0bca499df
12 changed files with 1386 additions and 1243 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,3 @@
define("core/utils",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.throttle=_exports.debounce=void 0;_exports.throttle=(func,wait)=>{let onCooldown=!1,runAgain=null;const run=function(){for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++)args[_key]=arguments[_key];runAgain=null!==runAgain,onCooldown||(func.apply(this,args),onCooldown=!0,setTimeout((()=>{const recurse=runAgain;onCooldown=!1,runAgain=null,recurse&&run(args)}),wait))};return run};_exports.debounce=(func,wait)=>{let timeout=null;return function(){for(var _len2=arguments.length,args=new Array(_len2),_key2=0;_key2<_len2;_key2++)args[_key2]=arguments[_key2];clearTimeout(timeout),timeout=setTimeout((()=>{func.apply(this,args)}),wait)}}}));
define("core/utils",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.throttle=_exports.getNormalisedComponent=_exports.debounce=void 0;_exports.throttle=(func,wait)=>{let onCooldown=!1,runAgain=null;const run=function(){for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++)args[_key]=arguments[_key];runAgain=null!==runAgain,onCooldown||(func.apply(this,args),onCooldown=!0,setTimeout((()=>{const recurse=runAgain;onCooldown=!1,runAgain=null,recurse&&run(args)}),wait))};return run};_exports.debounce=(func,wait)=>{let timeout=null;return function(){for(var _len2=arguments.length,args=new Array(_len2),_key2=0;_key2<_len2;_key2++)args[_key2]=arguments[_key2];clearTimeout(timeout),timeout=setTimeout((()=>{func.apply(this,args)}),wait)}};_exports.getNormalisedComponent=component=>component&&"moodle"!==component&&"core"!==component?component:"core"}));
//# sourceMappingURL=utils.min.js.map

View file

@ -1 +1 @@
{"version":3,"file":"utils.min.js","sources":["../src/utils.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Utility functions.\n *\n * @module core/utils\n * @copyright 2019 Ryan Wyllie <ryan@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n /**\n * Create a wrapper function to throttle the execution of the given\n *\n * function to at most once every specified period.\n *\n * If the function is attempted to be executed while it's in cooldown\n * (during the wait period) then it'll immediately execute again as\n * soon as the cooldown is over.\n *\n * @method\n * @param {Function} func The function to throttle\n * @param {Number} wait The number of milliseconds to wait between executions\n * @return {Function}\n */\nexport const throttle = (func, wait) => {\n let onCooldown = false;\n let runAgain = null;\n const run = function(...args) {\n if (runAgain === null) {\n // This is the first time the function has been called.\n runAgain = false;\n } else {\n // This function has been called a second time during the wait period\n // so re-run it once the wait period is over.\n runAgain = true;\n }\n\n if (onCooldown) {\n // Function has already run for this wait period.\n return;\n }\n\n func.apply(this, args);\n onCooldown = true;\n\n setTimeout(() => {\n const recurse = runAgain;\n onCooldown = false;\n runAgain = null;\n\n if (recurse) {\n run(args);\n }\n }, wait);\n };\n\n return run;\n};\n\n/**\n * Create a wrapper function to debounce the execution of the given\n * function. Each attempt to execute the function will reset the cooldown\n * period.\n *\n * @method\n * @param {Function} func The function to debounce\n * @param {Number} wait The number of milliseconds to wait after the final attempt to execute\n * @return {Function}\n */\nexport const debounce = (func, wait) => {\n let timeout = null;\n return function(...args) {\n clearTimeout(timeout);\n timeout = setTimeout(() => {\n func.apply(this, args);\n }, wait);\n };\n};\n"],"names":["func","wait","onCooldown","runAgain","run","args","apply","this","setTimeout","recurse","timeout","clearTimeout"],"mappings":"yKAqCwB,CAACA,KAAMC,YACvBC,YAAa,EACbC,SAAW,WACTC,IAAM,yCAAYC,6CAAAA,2BAGhBF,SAFa,OAAbA,SASAD,aAKJF,KAAKM,MAAMC,KAAMF,MACjBH,YAAa,EAEbM,YAAW,WACDC,QAAUN,SAChBD,YAAa,EACbC,SAAW,KAEPM,SACAL,IAAIC,QAETJ,eAGAG,uBAaa,CAACJ,KAAMC,YACvBS,QAAU,YACP,0CAAYL,kDAAAA,6BACfM,aAAaD,SACbA,QAAUF,YAAW,KACjBR,KAAKM,MAAMC,KAAMF,QAClBJ"}
{"version":3,"file":"utils.min.js","sources":["../src/utils.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Utility functions.\n *\n * @module core/utils\n * @copyright 2019 Ryan Wyllie <ryan@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n /**\n * Create a wrapper function to throttle the execution of the given\n *\n * function to at most once every specified period.\n *\n * If the function is attempted to be executed while it's in cooldown\n * (during the wait period) then it'll immediately execute again as\n * soon as the cooldown is over.\n *\n * @method\n * @param {Function} func The function to throttle\n * @param {Number} wait The number of milliseconds to wait between executions\n * @return {Function}\n */\nexport const throttle = (func, wait) => {\n let onCooldown = false;\n let runAgain = null;\n const run = function(...args) {\n if (runAgain === null) {\n // This is the first time the function has been called.\n runAgain = false;\n } else {\n // This function has been called a second time during the wait period\n // so re-run it once the wait period is over.\n runAgain = true;\n }\n\n if (onCooldown) {\n // Function has already run for this wait period.\n return;\n }\n\n func.apply(this, args);\n onCooldown = true;\n\n setTimeout(() => {\n const recurse = runAgain;\n onCooldown = false;\n runAgain = null;\n\n if (recurse) {\n run(args);\n }\n }, wait);\n };\n\n return run;\n};\n\n/**\n * Create a wrapper function to debounce the execution of the given\n * function. Each attempt to execute the function will reset the cooldown\n * period.\n *\n * @method\n * @param {Function} func The function to debounce\n * @param {Number} wait The number of milliseconds to wait after the final attempt to execute\n * @return {Function}\n */\nexport const debounce = (func, wait) => {\n let timeout = null;\n return function(...args) {\n clearTimeout(timeout);\n timeout = setTimeout(() => {\n func.apply(this, args);\n }, wait);\n };\n};\n\n/**\n * Normalise the provided component such that '', 'moodle', and 'core' are treated consistently.\n *\n * @param {String} component\n * @returns {String}\n */\nexport const getNormalisedComponent = (component) => {\n if (component) {\n if (component !== 'moodle' && component !== 'core') {\n return component;\n }\n }\n\n return 'core';\n};\n"],"names":["func","wait","onCooldown","runAgain","run","args","apply","this","setTimeout","recurse","timeout","clearTimeout","component"],"mappings":"yMAqCwB,CAACA,KAAMC,YACvBC,YAAa,EACbC,SAAW,WACTC,IAAM,yCAAYC,6CAAAA,2BAGhBF,SAFa,OAAbA,SASAD,aAKJF,KAAKM,MAAMC,KAAMF,MACjBH,YAAa,EAEbM,YAAW,WACDC,QAAUN,SAChBD,YAAa,EACbC,SAAW,KAEPM,SACAL,IAAIC,QAETJ,eAGAG,uBAaa,CAACJ,KAAMC,YACvBS,QAAU,YACP,0CAAYL,kDAAAA,6BACfM,aAAaD,SACbA,QAAUF,YAAW,KACjBR,KAAKM,MAAMC,KAAMF,QAClBJ,wCAU4BW,WAC/BA,WACkB,WAAdA,WAAwC,SAAdA,UACnBA,UAIR"}

View file

@ -0,0 +1,489 @@
// 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 $ from 'jquery';
import ajax from 'core/ajax';
import * as str from 'core/str';
import * as config from 'core/config';
import mustache from 'core/mustache';
import storage from 'core/localstorage';
import {getNormalisedComponent} from 'core/utils';
/**
* Template this.
*
* @module core/local/templates/loader
* @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 Loader {
/** @var {String} themeName for the current render */
currentThemeName = '';
/** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
static loadTemplateBuffer = [];
/** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
static isLoadingTemplates = false;
/** @var {Map} templateCache - Cache of already loaded template strings */
static templateCache = new Map();
/** @var {Promise[]} templatePromises - Cache of already loaded template promises */
static templatePromises = {};
/** @var {Promise[]} cachePartialPromises - Cache of already loaded template partial promises */
static cachePartialPromises = [];
/**
* A helper to get the search key
*
* @param {string} theme
* @param {string} templateName
* @returns {string}
*/
static getSearchKey(theme, templateName) {
return `${theme}/${templateName}`;
}
/**
* Load a template.
*
* @method getTemplate
* @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 {string} [themeName=config.theme] - The theme to load the template from
* @return {Promise} JQuery promise object resolved when the template has been fetched.
*/
static getTemplate(templateName, themeName = config.theme) {
const searchKey = this.getSearchKey(themeName, templateName);
// If we haven't already seen this template then buffer it.
const cachedPromise = this.getTemplatePromiseFromCache(searchKey);
if (cachedPromise) {
return cachedPromise;
}
// Check the buffer to see if this template has already been added.
const existingBufferRecords = this.loadTemplateBuffer.filter((record) => record.searchKey === searchKey);
if (existingBufferRecords.length) {
// This template is already in the buffer so just return the existing
// promise. No need to add it to the buffer again.
return existingBufferRecords[0].deferred.promise();
}
// This is the first time this has been requested so let's add it to the buffer
// to be loaded.
const parts = templateName.split('/');
const component = getNormalisedComponent(parts.shift());
const name = parts.join('/');
const deferred = $.Deferred();
// Add this template to the buffer to be loaded.
this.loadTemplateBuffer.push({
component,
name,
theme: themeName,
searchKey,
deferred,
});
// We know there is at least one thing in the buffer so kick off a processing run.
this.processLoadTemplateBuffer();
return deferred.promise();
}
/**
* Store a template in the cache.
*
* @param {string} searchKey
* @param {string} templateSource
*/
static setTemplateInCache(searchKey, templateSource) {
// Cache all of the dependent templates because we'll need them to render
// the requested template.
this.templateCache.set(searchKey, templateSource);
}
/**
* Fetch a template from the cache.
*
* @param {string} searchKey
* @returns {string}
*/
static getTemplateFromCache(searchKey) {
return this.templateCache.get(searchKey);
}
/**
* Check whether a template is in the cache.
*
* @param {string} searchKey
* @returns {bool}
*/
static hasTemplateInCache(searchKey) {
return this.templateCache.has(searchKey);
}
/**
* Prefetch a set of templates without rendering them.
*
* @param {Array} templateNames The list of templates to fetch
* @param {string} themeName
*/
static prefetchTemplates(templateNames, themeName) {
templateNames.forEach((templateName) => this.prefetchTemplate(templateName, themeName));
}
/**
* Prefetech a sginle template without rendering it.
*
* @param {string} templateName
* @param {string} themeName
*/
static prefetchTemplate(templateName, themeName) {
const searchKey = this.getSearchKey(themeName, templateName);
// If we haven't already seen this template then buffer it.
if (this.hasTemplateInCache(searchKey)) {
return;
}
// Check the buffer to see if this template has already been added.
const existingBufferRecords = this.loadTemplateBuffer.filter((record) => record.searchKey === searchKey);
if (existingBufferRecords.length) {
// This template is already in the buffer so just return the existing promise.
// No need to add it to the buffer again.
return;
}
// This is the first time this has been requested so let's add it to the buffer to be loaded.
const parts = templateName.split('/');
const component = getNormalisedComponent(parts.shift());
const name = parts.join('/');
// Add this template to the buffer to be loaded.
this.loadTemplateBuffer.push({
component,
name,
theme: themeName,
searchKey,
deferred: $.Deferred(),
});
this.processLoadTemplateBuffer();
}
/**
* Load a partial from the cache or ajax.
*
* @method partialHelper
* @param {string} name The partial name to load.
* @param {string} [themeName = config.theme] The theme to load the partial from.
* @return {string}
*/
static partialHelper(name, themeName = config.theme) {
const searchKey = this.getSearchKey(themeName, name);
if (!this.hasTemplateInCache(searchKey)) {
new Error(`Failed to pre-fetch the template: ${name}`);
}
return this.getTemplateFromCache(searchKey);
}
/**
* Scan a template source for partial tags and return a list of the found partials.
*
* @method scanForPartials
* @param {string} templateSource - source template to scan.
* @return {Array} List of partials.
*/
static scanForPartials(templateSource) {
const tokens = mustache.parse(templateSource);
const partials = [];
const findPartial = (tokens, partials) => {
let i;
for (i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token[0] == '>' || token[0] == '<') {
partials.push(token[1]);
}
if (token.length > 4) {
findPartial(token[4], partials);
}
}
};
findPartial(tokens, partials);
return partials;
}
/**
* Load a template and scan it for partials. Recursively fetch the partials.
*
* @method cachePartials
* @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 {string} [themeName=config.theme]
* @param {Array} parentage - A list of requested partials in this render chain.
* @return {Promise} JQuery promise object resolved when all partials are in the cache.
*/
static cachePartials(templateName, themeName = config.theme, parentage = []) {
const searchKey = this.getSearchKey(themeName, templateName);
if (searchKey in this.cachePartialPromises) {
return this.cachePartialPromises[searchKey];
}
// This promise will not be resolved until all child partials are also resolved and ready.
// We create it here to allow us to check for recursive inclusion of templates.
// Keep track of the requested partials in this chain.
if (!parentage.length) {
parentage.push(searchKey);
}
this.cachePartialPromises[searchKey] = $.Deferred();
this._cachePartials(templateName, themeName, parentage).catch((error) => {
this.cachePartialPromises[searchKey].reject(error);
});
return this.cachePartialPromises[searchKey];
}
/**
* Cache the template partials for the specified template.
*
* @param {string} templateName
* @param {string} themeName
* @param {array} parentage
* @returns {promise<string>}
*/
static async _cachePartials(templateName, themeName, parentage) {
const searchKey = this.getSearchKey(themeName, templateName);
const templateSource = await this.getTemplate(templateName, themeName);
const partials = this.scanForPartials(templateSource);
const uniquePartials = partials.filter((partialName) => {
// Check for recursion.
if (parentage.indexOf(`${themeName}/${partialName}`) >= 0) {
// Ignore templates which include a parent template already requested in the current chain.
return false;
}
// Ignore templates that include themselves.
return partialName !== templateName;
});
// Fetch any partial which has not already been fetched.
const fetchThemAll = uniquePartials.map((partialName) => {
parentage.push(`${themeName}/${partialName}`);
return this.cachePartials(partialName, themeName, parentage);
});
await Promise.all(fetchThemAll);
return this.cachePartialPromises[searchKey].resolve(templateSource);
}
/**
* Take all of the templates waiting in the buffer and load them from the server
* or from the cache.
*
* All of the templates that need to be loaded from the server will be batched up
* and sent in a single network request.
*/
static processLoadTemplateBuffer() {
if (!this.loadTemplateBuffer.length) {
return;
}
if (this.isLoadingTemplates) {
return;
}
this.isLoadingTemplates = true;
// Grab any templates waiting in the buffer.
const templatesToLoad = this.loadTemplateBuffer.slice();
// This will be resolved with the list of promises for the server request.
const serverRequestsDeferred = $.Deferred();
const requests = [];
// Get a list of promises for each of the templates we need to load.
const templatePromises = templatesToLoad.map((templateData) => {
const component = getNormalisedComponent(templateData.component);
const name = templateData.name;
const searchKey = templateData.searchKey;
const theme = templateData.theme;
const templateDeferred = templateData.deferred;
let promise = null;
// Double check to see if this template happened to have landed in the
// cache as a dependency of an earlier template.
if (this.hasTemplateInCache(searchKey)) {
// We've seen this template so immediately resolve the existing promise.
promise = this.getTemplatePromiseFromCache(searchKey);
} else {
// We haven't seen this template yet so we need to request it from
// the server.
requests.push({
methodname: 'core_output_load_template_with_dependencies',
args: {
component,
template: name,
themename: theme,
lang: $('html').attr('lang').replace(/-/g, '_')
}
});
// Remember the index in the requests list for this template so that
// we can get the appropriate promise back.
const index = requests.length - 1;
// The server deferred will be resolved with a list of all of the promises
// that were sent in the order that they were added to the requests array.
promise = serverRequestsDeferred.promise()
.then((promises) => {
// The promise for this template will be the one that matches the index
// for it's entry in the requests array.
//
// Make sure the promise is added to the promises cache for this template
// search key so that we don't request it again.
templatePromises[searchKey] = promises[index].then((response) => {
// Process all of the template dependencies for this template and add
// them to the caches so that we don't request them again later.
response.templates.forEach((data) => {
data.component = getNormalisedComponent(data.component);
const tempSearchKey = this.getSearchKey(
theme,
[data.component, data.name].join('/'),
);
// Cache all of the dependent templates because we'll need them to render
// the requested template.
this.setTemplateInCache(tempSearchKey, data.value);
if (config.templaterev > 0) {
// The template cache is enabled - set the value there.
storage.set(`core_template/${config.templaterev}:${tempSearchKey}`, data.value);
}
});
if (response.strings.length) {
// If we have strings that the template needs then warm the string cache
// with them now so that we don't need to re-fetch them.
str.cache_strings(response.strings.map(({component, name, value}) => ({
component: getNormalisedComponent(component),
key: name,
value,
})));
}
// Return the original template source that the user requested.
if (this.hasTemplateInCache(searchKey)) {
return this.getTemplateFromCache(searchKey);
}
return null;
});
return templatePromises[searchKey];
});
}
return promise
// When we've successfully loaded the template then resolve the deferred
// in the buffer so that all of the calling code can proceed.
.then((source) => templateDeferred.resolve(source))
.catch((error) => {
// If there was an error loading the template then reject the deferred
// in the buffer so that all of the calling code can proceed.
templateDeferred.reject(error);
// Rethrow for anyone else listening.
throw error;
});
});
if (requests.length) {
// We have requests to send so resolve the deferred with the promises.
serverRequestsDeferred.resolve(ajax.call(requests, true, false, false, 0, config.templaterev));
} else {
// Nothing to load so we can resolve our deferred.
serverRequestsDeferred.resolve();
}
// Once we've finished loading all of the templates then recurse to process
// any templates that may have been added to the buffer in the time that we
// were fetching.
$.when.apply(null, templatePromises)
.then(() => {
// Remove the templates we've loaded from the buffer.
this.loadTemplateBuffer.splice(0, templatesToLoad.length);
this.isLoadingTemplates = false;
this.processLoadTemplateBuffer();
return;
})
.catch(() => {
// Remove the templates we've loaded from the buffer.
this.loadTemplateBuffer.splice(0, templatesToLoad.length);
this.isLoadingTemplates = false;
this.processLoadTemplateBuffer();
});
}
/**
* Search the various caches for a template promise for the given search key.
* The search key should be in the format <theme>/<component>/<template> e.g. boost/core/modal.
*
* If the template is found in any of the caches it will populate the other caches with
* the same data as well.
*
* @param {String} searchKey The template search key in the format <theme>/<component>/<template> e.g. boost/core/modal
* @returns {Object|null} jQuery promise resolved with the template source
*/
static getTemplatePromiseFromCache(searchKey) {
// First try the cache of promises.
if (searchKey in this.templatePromises) {
return this.templatePromises[searchKey];
}
// Check the module cache.
if (this.hasTemplateInCache(searchKey)) {
const templateSource = this.getTemplateFromCache(searchKey);
// Add this to the promises cache for future.
this.templatePromises[searchKey] = $.Deferred().resolve(templateSource).promise();
return this.templatePromises[searchKey];
}
if (config.templaterev <= 0) {
// Template caching is disabled. Do not store in persistent storage.
return null;
}
// Now try local storage.
const cached = storage.get(`core_template/${config.templaterev}:${searchKey}`);
if (cached) {
// Add this to the module cache for future.
this.setTemplateInCache(searchKey, cached);
// Add to the promises cache for future.
this.templatePromises[searchKey] = $.Deferred().resolve(cached).promise();
return this.templatePromises[searchKey];
}
return null;
}
}

View file

@ -0,0 +1,650 @@
// 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 {get_strings as 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() {
let System = await import(config.iconsystemmodule);
if (System.default) {
// Note: This handles an issue in our Babel dynamic import transpilation.
// For some reason it doesn't seem to be able to handle the default export.
System = System.default;
}
const instance = new System();
if (!(instance instanceof IconSystem)) {
throw new Error(`Invalid icon system specified ${config.iconsystemmodule}`);
}
instance.init();
this.iconSystem = instance;
}
/**
* 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(/&#x2F;/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);
}
}

File diff suppressed because it is too large Load diff

View file

@ -89,3 +89,19 @@ export const debounce = (func, wait) => {
}, wait);
};
};
/**
* Normalise the provided component such that '', 'moodle', and 'core' are treated consistently.
*
* @param {String} component
* @returns {String}
*/
export const getNormalisedComponent = (component) => {
if (component) {
if (component !== 'moodle' && component !== 'core') {
return component;
}
}
return 'core';
};