Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
import $ from 'jquery';
17
import ajax from 'core/ajax';
18
import * as str from 'core/str';
19
import * as config from 'core/config';
20
import mustache from 'core/mustache';
21
import storage from 'core/localstorage';
22
import {getNormalisedComponent} from 'core/utils';
23
 
24
/**
25
 * Template this.
26
 *
27
 * @module     core/local/templates/loader
28
 * @copyright  2023 Andrew Lyons <andrew@nicols.co.uk>
29
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
30
 * @since      4.3
31
 */
32
export default class Loader {
33
    /** @var {String} themeName for the current render */
34
    currentThemeName = '';
35
 
36
    /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
37
    static loadTemplateBuffer = [];
38
 
39
    /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
40
    static isLoadingTemplates = false;
41
 
42
    /** @var {Map} templateCache - Cache of already loaded template strings */
43
    static templateCache = new Map();
44
 
45
    /** @var {Promise[]} templatePromises - Cache of already loaded template promises */
46
    static templatePromises = {};
47
 
48
    /** @var {Promise[]} cachePartialPromises - Cache of already loaded template partial promises */
49
    static cachePartialPromises = [];
50
 
51
    /**
52
     * A helper to get the search key
53
     *
54
     * @param {string} theme
55
     * @param {string} templateName
56
     * @returns {string}
57
     */
58
    static getSearchKey(theme, templateName) {
59
        return `${theme}/${templateName}`;
60
    }
61
 
62
    /**
63
     * Load a template.
64
     *
65
     * @method getTemplate
66
     * @param {string} templateName - should consist of the component and the name of the template like this:
67
     *                              core/menu (lib/templates/menu.mustache) or
68
     *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
69
     * @param {string} [themeName=config.theme] - The theme to load the template from
70
     * @return {Promise} JQuery promise object resolved when the template has been fetched.
71
     */
72
    static getTemplate(templateName, themeName = config.theme) {
73
        const searchKey = this.getSearchKey(themeName, templateName);
74
 
75
        // If we haven't already seen this template then buffer it.
76
        const cachedPromise = this.getTemplatePromiseFromCache(searchKey);
77
        if (cachedPromise) {
78
            return cachedPromise;
79
        }
80
 
81
        // Check the buffer to see if this template has already been added.
82
        const existingBufferRecords = this.loadTemplateBuffer.filter((record) => record.searchKey === searchKey);
83
        if (existingBufferRecords.length) {
84
            // This template is already in the buffer so just return the existing
85
            // promise. No need to add it to the buffer again.
86
            return existingBufferRecords[0].deferred.promise();
87
        }
88
 
89
        // This is the first time this has been requested so let's add it to the buffer
90
        // to be loaded.
91
        const parts = templateName.split('/');
92
        const component = getNormalisedComponent(parts.shift());
93
        const name = parts.join('/');
94
        const deferred = $.Deferred();
95
 
96
        // Add this template to the buffer to be loaded.
97
        this.loadTemplateBuffer.push({
98
            component,
99
            name,
100
            theme: themeName,
101
            searchKey,
102
            deferred,
103
        });
104
 
105
        // We know there is at least one thing in the buffer so kick off a processing run.
106
        this.processLoadTemplateBuffer();
107
        return deferred.promise();
108
    }
109
 
110
    /**
111
     * Store a template in the cache.
112
     *
113
     * @param {string} searchKey
114
     * @param {string} templateSource
115
     */
116
    static setTemplateInCache(searchKey, templateSource) {
117
        // Cache all of the dependent templates because we'll need them to render
118
        // the requested template.
119
        this.templateCache.set(searchKey, templateSource);
120
    }
121
 
122
    /**
123
     * Fetch a template from the cache.
124
     *
125
     * @param {string} searchKey
126
     * @returns {string}
127
     */
128
    static getTemplateFromCache(searchKey) {
129
        return this.templateCache.get(searchKey);
130
    }
131
 
132
    /**
133
     * Check whether a template is in the cache.
134
     *
135
     * @param {string} searchKey
136
     * @returns {bool}
137
     */
138
    static hasTemplateInCache(searchKey) {
139
        return this.templateCache.has(searchKey);
140
    }
141
 
142
    /**
143
     * Prefetch a set of templates without rendering them.
144
     *
145
     * @param {Array} templateNames The list of templates to fetch
146
     * @param {string} themeName
147
     */
148
    static prefetchTemplates(templateNames, themeName) {
149
        templateNames.forEach((templateName) => this.prefetchTemplate(templateName, themeName));
150
    }
151
 
152
    /**
153
     * Prefetech a sginle template without rendering it.
154
     *
155
     * @param {string} templateName
156
     * @param {string} themeName
157
     */
158
    static prefetchTemplate(templateName, themeName) {
159
        const searchKey = this.getSearchKey(themeName, templateName);
160
 
161
        // If we haven't already seen this template then buffer it.
162
        if (this.hasTemplateInCache(searchKey)) {
163
            return;
164
        }
165
 
166
        // Check the buffer to see if this template has already been added.
167
        const existingBufferRecords = this.loadTemplateBuffer.filter((record) => record.searchKey === searchKey);
168
 
169
        if (existingBufferRecords.length) {
170
            // This template is already in the buffer so just return the existing promise.
171
            // No need to add it to the buffer again.
172
            return;
173
        }
174
 
175
        // This is the first time this has been requested so let's add it to the buffer to be loaded.
176
        const parts = templateName.split('/');
177
        const component = getNormalisedComponent(parts.shift());
178
        const name = parts.join('/');
179
 
180
        // Add this template to the buffer to be loaded.
181
        this.loadTemplateBuffer.push({
182
            component,
183
            name,
184
            theme: themeName,
185
            searchKey,
186
            deferred: $.Deferred(),
187
        });
188
 
189
        this.processLoadTemplateBuffer();
190
    }
191
 
192
    /**
193
     * Load a partial from the cache or ajax.
194
     *
195
     * @method partialHelper
196
     * @param {string} name The partial name to load.
197
     * @param {string} [themeName = config.theme] The theme to load the partial from.
198
     * @return {string}
199
     */
200
    static partialHelper(name, themeName = config.theme) {
201
        const searchKey = this.getSearchKey(themeName, name);
202
 
203
        if (!this.hasTemplateInCache(searchKey)) {
204
            new Error(`Failed to pre-fetch the template: ${name}`);
205
        }
206
        return this.getTemplateFromCache(searchKey);
207
    }
208
 
209
    /**
210
     * Scan a template source for partial tags and return a list of the found partials.
211
     *
212
     * @method scanForPartials
213
     * @param {string} templateSource - source template to scan.
214
     * @return {Array} List of partials.
215
     */
216
    static scanForPartials(templateSource) {
217
        const tokens = mustache.parse(templateSource);
218
        const partials = [];
219
 
220
        const findPartial = (tokens, partials) => {
221
            let i;
222
            for (i = 0; i < tokens.length; i++) {
223
                const token = tokens[i];
224
                if (token[0] == '>' || token[0] == '<') {
225
                    partials.push(token[1]);
226
                }
227
                if (token.length > 4) {
228
                    findPartial(token[4], partials);
229
                }
230
            }
231
        };
232
 
233
        findPartial(tokens, partials);
234
 
235
        return partials;
236
    }
237
 
238
    /**
239
     * Load a template and scan it for partials. Recursively fetch the partials.
240
     *
241
     * @method cachePartials
242
     * @param {string} templateName - should consist of the component and the name of the template like this:
243
     *                              core/menu (lib/templates/menu.mustache) or
244
     *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
245
     * @param {string} [themeName=config.theme]
246
     * @param {Array} parentage - A list of requested partials in this render chain.
247
     * @return {Promise} JQuery promise object resolved when all partials are in the cache.
248
     */
249
    static cachePartials(templateName, themeName = config.theme, parentage = []) {
250
        const searchKey = this.getSearchKey(themeName, templateName);
251
 
252
        if (searchKey in this.cachePartialPromises) {
253
            return this.cachePartialPromises[searchKey];
254
        }
255
 
256
        // This promise will not be resolved until all child partials are also resolved and ready.
257
        // We create it here to allow us to check for recursive inclusion of templates.
258
        // Keep track of the requested partials in this chain.
259
        if (!parentage.length) {
260
            parentage.push(searchKey);
261
        }
262
 
263
        this.cachePartialPromises[searchKey] = $.Deferred();
264
        this._cachePartials(templateName, themeName, parentage).catch((error) => {
265
            this.cachePartialPromises[searchKey].reject(error);
266
        });
267
 
268
        return this.cachePartialPromises[searchKey];
269
    }
270
 
271
    /**
272
     * Cache the template partials for the specified template.
273
     *
274
     * @param {string} templateName
275
     * @param {string} themeName
276
     * @param {array} parentage
277
     * @returns {promise<string>}
278
     */
279
    static async _cachePartials(templateName, themeName, parentage) {
280
        const searchKey = this.getSearchKey(themeName, templateName);
281
        const templateSource = await this.getTemplate(templateName, themeName);
282
        const partials = this.scanForPartials(templateSource);
283
        const uniquePartials = partials.filter((partialName) => {
284
            // Check for recursion.
285
            if (parentage.indexOf(`${themeName}/${partialName}`) >= 0) {
286
                // Ignore templates which include a parent template already requested in the current chain.
287
                return false;
288
            }
289
 
290
            // Ignore templates that include themselves.
291
            return partialName !== templateName;
292
        });
293
 
294
        // Fetch any partial which has not already been fetched.
295
        const fetchThemAll = uniquePartials.map((partialName) => {
296
            parentage.push(`${themeName}/${partialName}`);
297
            return this.cachePartials(partialName, themeName, parentage);
298
        });
299
 
300
        await Promise.all(fetchThemAll);
301
        return this.cachePartialPromises[searchKey].resolve(templateSource);
302
    }
303
 
304
    /**
305
     * Take all of the templates waiting in the buffer and load them from the server
306
     * or from the cache.
307
     *
308
     * All of the templates that need to be loaded from the server will be batched up
309
     * and sent in a single network request.
310
     */
311
    static processLoadTemplateBuffer() {
312
        if (!this.loadTemplateBuffer.length) {
313
            return;
314
        }
315
 
316
        if (this.isLoadingTemplates) {
317
            return;
318
        }
319
 
320
        this.isLoadingTemplates = true;
321
        // Grab any templates waiting in the buffer.
322
        const templatesToLoad = this.loadTemplateBuffer.slice();
323
        // This will be resolved with the list of promises for the server request.
324
        const serverRequestsDeferred = $.Deferred();
325
        const requests = [];
326
        // Get a list of promises for each of the templates we need to load.
327
        const templatePromises = templatesToLoad.map((templateData) => {
328
            const component = getNormalisedComponent(templateData.component);
329
            const name = templateData.name;
330
            const searchKey = templateData.searchKey;
331
            const theme = templateData.theme;
332
            const templateDeferred = templateData.deferred;
333
            let promise = null;
334
 
335
            // Double check to see if this template happened to have landed in the
336
            // cache as a dependency of an earlier template.
337
            if (this.hasTemplateInCache(searchKey)) {
338
                // We've seen this template so immediately resolve the existing promise.
339
                promise = this.getTemplatePromiseFromCache(searchKey);
340
            } else {
341
                // We haven't seen this template yet so we need to request it from
342
                // the server.
343
                requests.push({
344
                    methodname: 'core_output_load_template_with_dependencies',
345
                    args: {
346
                        component,
347
                        template: name,
348
                        themename: theme,
349
                        lang: config.language,
350
                    }
351
                });
352
                // Remember the index in the requests list for this template so that
353
                // we can get the appropriate promise back.
354
                const index = requests.length - 1;
355
 
356
                // The server deferred will be resolved with a list of all of the promises
357
                // that were sent in the order that they were added to the requests array.
358
                promise = serverRequestsDeferred.promise()
359
                    .then((promises) => {
360
                        // The promise for this template will be the one that matches the index
361
                        // for it's entry in the requests array.
362
                        //
363
                        // Make sure the promise is added to the promises cache for this template
364
                        // search key so that we don't request it again.
365
                        templatePromises[searchKey] = promises[index].then((response) => {
366
                            // Process all of the template dependencies for this template and add
367
                            // them to the caches so that we don't request them again later.
368
                            response.templates.forEach((data) => {
369
                                data.component = getNormalisedComponent(data.component);
370
                                const tempSearchKey = this.getSearchKey(
371
                                    theme,
372
                                    [data.component, data.name].join('/'),
373
                                );
374
 
375
                                // Cache all of the dependent templates because we'll need them to render
376
                                // the requested template.
377
                                this.setTemplateInCache(tempSearchKey, data.value);
378
 
379
                                if (config.templaterev > 0) {
380
                                    // The template cache is enabled - set the value there.
381
                                    storage.set(`core_template/${config.templaterev}:${tempSearchKey}`, data.value);
382
                                }
383
                            });
384
 
385
                            if (response.strings.length) {
386
                                // If we have strings that the template needs then warm the string cache
387
                                // with them now so that we don't need to re-fetch them.
388
                                str.cache_strings(response.strings.map(({component, name, value}) => ({
389
                                    component: getNormalisedComponent(component),
390
                                    key: name,
391
                                    value,
392
                                })));
393
                            }
394
 
395
                            // Return the original template source that the user requested.
396
                            if (this.hasTemplateInCache(searchKey)) {
397
                                return this.getTemplateFromCache(searchKey);
398
                            }
399
 
400
                            return null;
401
                        });
402
 
403
                        return templatePromises[searchKey];
404
                    });
405
            }
406
 
407
            return promise
408
                // When we've successfully loaded the template then resolve the deferred
409
                // in the buffer so that all of the calling code can proceed.
410
                .then((source) => templateDeferred.resolve(source))
411
                .catch((error) => {
412
                    // If there was an error loading the template then reject the deferred
413
                    // in the buffer so that all of the calling code can proceed.
414
                    templateDeferred.reject(error);
415
                    // Rethrow for anyone else listening.
416
                    throw error;
417
                });
418
        });
419
 
420
        if (requests.length) {
421
            // We have requests to send so resolve the deferred with the promises.
422
            serverRequestsDeferred.resolve(ajax.call(requests, true, false, false, 0, config.templaterev));
423
        } else {
424
            // Nothing to load so we can resolve our deferred.
425
            serverRequestsDeferred.resolve();
426
        }
427
 
428
        // Once we've finished loading all of the templates then recurse to process
429
        // any templates that may have been added to the buffer in the time that we
430
        // were fetching.
431
        $.when.apply(null, templatePromises)
432
            .then(() => {
433
                // Remove the templates we've loaded from the buffer.
434
                this.loadTemplateBuffer.splice(0, templatesToLoad.length);
435
                this.isLoadingTemplates = false;
436
                this.processLoadTemplateBuffer();
437
                return;
438
            })
439
            .catch(() => {
440
                // Remove the templates we've loaded from the buffer.
441
                this.loadTemplateBuffer.splice(0, templatesToLoad.length);
442
                this.isLoadingTemplates = false;
443
                this.processLoadTemplateBuffer();
444
            });
445
    }
446
 
447
    /**
448
     * Search the various caches for a template promise for the given search key.
449
     * The search key should be in the format <theme>/<component>/<template> e.g. boost/core/modal.
450
     *
451
     * If the template is found in any of the caches it will populate the other caches with
452
     * the same data as well.
453
     *
454
     * @param {String} searchKey The template search key in the format <theme>/<component>/<template> e.g. boost/core/modal
455
     * @returns {Object|null} jQuery promise resolved with the template source
456
     */
457
    static getTemplatePromiseFromCache(searchKey) {
458
        // First try the cache of promises.
459
        if (searchKey in this.templatePromises) {
460
            return this.templatePromises[searchKey];
461
        }
462
 
463
        // Check the module cache.
464
        if (this.hasTemplateInCache(searchKey)) {
465
            const templateSource = this.getTemplateFromCache(searchKey);
466
            // Add this to the promises cache for future.
467
            this.templatePromises[searchKey] = $.Deferred().resolve(templateSource).promise();
468
            return this.templatePromises[searchKey];
469
        }
470
 
471
        if (config.templaterev <= 0) {
472
            // Template caching is disabled. Do not store in persistent storage.
473
            return null;
474
        }
475
 
476
        // Now try local storage.
477
        const cached = storage.get(`core_template/${config.templaterev}:${searchKey}`);
478
        if (cached) {
479
            // Add this to the module cache for future.
480
            this.setTemplateInCache(searchKey, cached);
481
 
482
            // Add to the promises cache for future.
483
            this.templatePromises[searchKey] = $.Deferred().resolve(cached).promise();
484
            return this.templatePromises[searchKey];
485
        }
486
 
487
        return null;
488
    }
489
}