// 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: config.language,
}
});
// 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;
}
}