mirror of
https://github.com/moodle/moodle.git
synced 2025-08-04 08:26:37 +02:00
Merge branch 'MDL-78266-master' of https://github.com/andrewnicols/moodle
This commit is contained in:
commit
c0bca499df
12 changed files with 1386 additions and 1243 deletions
11
lib/amd/build/local/templates/loader.min.js
vendored
Normal file
11
lib/amd/build/local/templates/loader.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
lib/amd/build/local/templates/loader.min.js.map
Normal file
1
lib/amd/build/local/templates/loader.min.js.map
Normal file
File diff suppressed because one or more lines are too long
14
lib/amd/build/local/templates/renderer.min.js
vendored
Normal file
14
lib/amd/build/local/templates/renderer.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
lib/amd/build/local/templates/renderer.min.js.map
Normal file
1
lib/amd/build/local/templates/renderer.min.js.map
Normal file
File diff suppressed because one or more lines are too long
16
lib/amd/build/templates.min.js
vendored
16
lib/amd/build/templates.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
lib/amd/build/utils.min.js
vendored
2
lib/amd/build/utils.min.js
vendored
|
@ -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
|
//# sourceMappingURL=utils.min.js.map
|
|
@ -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"}
|
489
lib/amd/src/local/templates/loader.js
Normal file
489
lib/amd/src/local/templates/loader.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
650
lib/amd/src/local/templates/renderer.js
Normal file
650
lib/amd/src/local/templates/renderer.js
Normal 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(///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
|
@ -89,3 +89,19 @@ export const debounce = (func, wait) => {
|
||||||
}, 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';
|
||||||
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue