Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | 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 * as Log from 'core/log';
17
import * as Truncate from 'core/truncate';
18
import * as UserDate from 'core/user_date';
19
import Pending from 'core/pending';
20
import {getStrings} from 'core/str';
21
import IconSystem from 'core/icon_system';
22
import config from 'core/config';
23
import mustache from 'core/mustache';
24
import Loader from './loader';
25
import {getNormalisedComponent} from 'core/utils';
26
 
27
/** @var {string} The placeholder character used for standard strings (unclean) */
28
const placeholderString = 's';
29
 
30
/** @var {string} The placeholder character used for cleaned strings */
31
const placeholderCleanedString = 'c';
32
 
33
/**
34
 * Template Renderer Class.
35
 *
36
 * Note: This class is not intended to be instantiated directly. Instead, use the core/templates module.
37
 *
38
 * @module     core/local/templates/renderer
39
 * @copyright  2023 Andrew Lyons <andrew@nicols.co.uk>
40
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41
 * @since      4.3
42
 */
43
export default class Renderer {
44
    /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
45
    requiredStrings = null;
46
 
47
    /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */
48
    requiredDates = [];
49
 
50
    /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
51
    requiredJS = null;
52
 
53
    /** @var {String} themeName for the current render */
54
    currentThemeName = '';
55
 
56
    /** @var {Number} uniqInstances Count of times this constructor has been called. */
57
    static uniqInstances = 0;
58
 
59
    /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
60
    static loadTemplateBuffer = [];
61
 
62
    /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
63
    static isLoadingTemplates = false;
64
 
65
    /** @var {Object} iconSystem - Object extending core/iconsystem */
66
    iconSystem = null;
67
 
68
    /** @var {Array} disallowedNestedHelpers - List of helpers that can't be called within other helpers */
69
    static disallowedNestedHelpers = [
70
        'js',
71
    ];
72
 
73
    /** @var {String[]} templateCache - Cache of already loaded template strings */
74
    static templateCache = {};
75
 
76
    /**
77
     * Cache of already loaded template promises.
78
     *
79
     * @type {Promise[]}
80
     * @static
81
     * @private
82
     */
83
    static templatePromises = {};
84
 
85
    /**
86
     * The loader used to fetch templates.
87
     * @type {Loader}
88
     * @static
89
     * @private
90
     */
91
    static loader = Loader;
92
 
93
    /**
94
     * Constructor
95
     *
96
     * Each call to templates.render gets it's own instance of this class.
97
     */
98
    constructor() {
99
        this.requiredStrings = [];
100
        this.requiredJS = [];
101
        this.requiredDates = [];
102
        this.currentThemeName = '';
103
    }
104
 
105
    /**
106
     * Set the template loader to use for all Template renderers.
107
     *
108
     * @param {Loader} loader
109
     */
110
    static setLoader(loader) {
111
        this.loader = loader;
112
    }
113
 
114
    /**
115
     * Get the Loader used to fetch templates.
116
     *
117
     * @returns {Loader}
118
     */
119
    static getLoader() {
120
        return this.loader;
121
    }
122
 
123
    /**
124
     * Render a single image icon.
125
     *
126
     * @method renderIcon
127
     * @private
128
     * @param {string} key The icon key.
129
     * @param {string} component The component name.
130
     * @param {string} title The icon title
131
     * @returns {Promise}
132
     */
133
    async renderIcon(key, component, title) {
134
        // Preload the module to do the icon rendering based on the theme iconsystem.
135
        component = getNormalisedComponent(component);
136
 
137
        await this.setupIconSystem();
138
        const template = await Renderer.getLoader().getTemplate(
139
            this.iconSystem.getTemplateName(),
140
            this.currentThemeName,
141
        );
142
 
143
        return this.iconSystem.renderIcon(
144
            key,
145
            component,
146
            title,
147
            template
148
        );
149
    }
150
 
151
    /**
152
     * Helper to set up the icon system.
153
     */
154
    async setupIconSystem() {
155
        if (!this.iconSystem) {
156
            this.iconSystem = await IconSystem.instance();
157
        }
158
 
159
        return this.iconSystem;
160
    }
161
 
162
    /**
163
     * Render image icons.
164
     *
165
     * @method pixHelper
166
     * @private
167
     * @param {object} context The mustache context
168
     * @param {string} sectionText The text to parse arguments from.
169
     * @param {function} helper Used to render the alt attribute of the text.
170
     * @returns {string}
171
     */
172
    pixHelper(context, sectionText, helper) {
173
        const parts = sectionText.split(',');
174
        let key = '';
175
        let component = '';
176
        let text = '';
177
 
178
        if (parts.length > 0) {
179
            key = helper(parts.shift().trim(), context);
180
        }
181
        if (parts.length > 0) {
182
            component = helper(parts.shift().trim(), context);
183
        }
184
        if (parts.length > 0) {
185
            text = helper(parts.join(',').trim(), context);
186
        }
187
 
188
        // Note: We cannot use Promises in Mustache helpers.
189
        // We must fetch straight from the Loader cache.
190
        // The Loader cache is statically defined on the Loader class and should be used by all children.
191
        const Loader = Renderer.getLoader();
192
        const templateName = this.iconSystem.getTemplateName();
193
        const searchKey = Loader.getSearchKey(this.currentThemeName, templateName);
194
        const template = Loader.getTemplateFromCache(searchKey);
195
 
196
        component = getNormalisedComponent(component);
197
 
198
        // The key might have been escaped by the JS Mustache engine which
199
        // converts forward slashes to HTML entities. Let us undo that here.
200
        key = key.replace(/&#x2F;/gi, '/');
201
 
202
        return this.iconSystem.renderIcon(
203
            key,
204
            component,
205
            text,
206
            template
207
        );
208
    }
209
 
210
    /**
211
     * Render blocks of javascript and save them in an array.
212
     *
213
     * @method jsHelper
214
     * @private
215
     * @param {object} context The current mustache context.
216
     * @param {string} sectionText The text to save as a js block.
217
     * @param {function} helper Used to render the block.
218
     * @returns {string}
219
     */
220
    jsHelper(context, sectionText, helper) {
221
        this.requiredJS.push(helper(sectionText, context));
222
        return '';
223
    }
224
 
225
    /**
226
     * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
227
     * into a get_string call.
228
     *
229
     * @method stringHelper
230
     * @private
231
     * @param {object} context The current mustache context.
232
     * @param {string} sectionText The text to parse the arguments from.
233
     * @param {function} helper Used to render subsections of the text.
234
     * @returns {string}
235
     */
236
    stringHelper(context, sectionText, helper) {
237
        // A string instruction is in the format:
238
        // key, component, params.
239
 
240
        let parts = sectionText.split(',');
241
 
242
        const key = parts.length > 0 ? parts.shift().trim() : '';
243
        const component = parts.length > 0 ? getNormalisedComponent(parts.shift().trim()) : '';
244
        let param = parts.length > 0 ? parts.join(',').trim() : '';
245
 
246
        if (param !== '') {
247
            // Allow variable expansion in the param part only.
248
            param = helper(param, context);
249
        }
250
 
251
        if (param.match(/^{\s*"/gm)) {
252
            // If it can't be parsed then the string is not a JSON format.
253
            try {
254
                const parsedParam = JSON.parse(param);
255
                // Handle non-exception-throwing cases, e.g. null, integer, boolean.
256
                if (parsedParam && typeof parsedParam === "object") {
257
                    param = parsedParam;
258
                }
259
            } catch (err) {
260
                // This was probably not JSON.
261
                // Keep the error message visible but do not promote it because it may not be an error.
262
                window.console.warn(err.message);
263
            }
264
        }
265
 
266
        const index = this.requiredStrings.length;
267
        this.requiredStrings.push({
268
            key,
269
            component,
270
            param,
271
        });
272
 
273
        // The placeholder must not use {{}} as those can be misinterpreted by the engine.
274
        return `[[_s${index}]]`;
275
    }
276
 
277
    /**
278
     * String helper to render {{#cleanstr}}abd component { a : 'fish'}{{/cleanstr}}
279
     * into a get_string following by an HTML escape.
280
     *
281
     * @method cleanStringHelper
282
     * @private
283
     * @param {object} context The current mustache context.
284
     * @param {string} sectionText The text to parse the arguments from.
285
     * @param {function} helper Used to render subsections of the text.
286
     * @returns {string}
287
     */
288
    cleanStringHelper(context, sectionText, helper) {
289
        // We're going to use [[_cx]] format for clean strings, where x is a number.
290
        // Hence, replacing 's' with 'c' in the placeholder that stringHelper returns.
291
        return this
292
            .stringHelper(context, sectionText, helper)
293
            .replace(placeholderString, placeholderCleanedString);
294
    }
295
 
296
    /**
297
     * Quote helper used to wrap content in quotes, and escape all special JSON characters present in the content.
298
     *
299
     * @method quoteHelper
300
     * @private
301
     * @param {object} context The current mustache context.
302
     * @param {string} sectionText The text to parse the arguments from.
303
     * @param {function} helper Used to render subsections of the text.
304
     * @returns {string}
305
     */
306
    quoteHelper(context, sectionText, helper) {
307
        let content = helper(sectionText.trim(), context);
308
 
309
        // Escape the {{ and JSON encode.
310
        // This involves wrapping {{, and }} in change delimeter tags.
311
        content = JSON.stringify(content);
312
        content = content.replace(/([{}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>');
313
        return content;
314
    }
315
 
316
    /**
317
     * Shorten text helper to truncate text and append a trailing ellipsis.
318
     *
319
     * @method shortenTextHelper
320
     * @private
321
     * @param {object} context The current mustache context.
322
     * @param {string} sectionText The text to parse the arguments from.
323
     * @param {function} helper Used to render subsections of the text.
324
     * @returns {string}
325
     */
326
    shortenTextHelper(context, sectionText, helper) {
327
        // Non-greedy split on comma to grab section text into the length and
328
        // text parts.
329
        const parts = sectionText.match(/(.*?),(.*)/);
330
 
331
        // The length is the part matched in the first set of parethesis.
332
        const length = parts[1].trim();
333
        // The length is the part matched in the second set of parethesis.
334
        const text = parts[2].trim();
335
        const content = helper(text, context);
336
        return Truncate.truncate(content, {
337
            length,
338
            words: true,
339
            ellipsis: '...'
340
        });
341
    }
342
 
343
    /**
344
     * User date helper to render user dates from timestamps.
345
     *
346
     * @method userDateHelper
347
     * @private
348
     * @param {object} context The current mustache context.
349
     * @param {string} sectionText The text to parse the arguments from.
350
     * @param {function} helper Used to render subsections of the text.
351
     * @returns {string}
352
     */
353
    userDateHelper(context, sectionText, helper) {
354
        // Non-greedy split on comma to grab the timestamp and format.
355
        const parts = sectionText.match(/(.*?),(.*)/);
356
 
357
        const timestamp = helper(parts[1].trim(), context);
358
        const format = helper(parts[2].trim(), context);
359
        const index = this.requiredDates.length;
360
 
361
        this.requiredDates.push({
362
            timestamp: timestamp,
363
            format: format
364
        });
365
 
366
        return `[[_t_${index}]]`;
367
    }
368
 
369
    /**
370
     * Return a helper function to be added to the context for rendering the a
371
     * template.
372
     *
373
     * This will parse the provided text before giving it to the helper function
374
     * in order to remove any disallowed nested helpers to prevent one helper
375
     * from calling another.
376
     *
377
     * In particular to prevent the JS helper from being called from within another
378
     * helper because it can lead to security issues when the JS portion is user
379
     * provided.
380
     *
381
     * @param  {function} helperFunction The helper function to add
382
     * @param  {object} context The template context for the helper function
383
     * @returns {Function} To be set in the context
384
     */
385
    addHelperFunction(helperFunction, context) {
386
        return function() {
387
            return function(sectionText, helper) {
388
                // Override the disallowed helpers in the template context with
389
                // a function that returns an empty string for use when executing
390
                // other helpers. This is to prevent these helpers from being
391
                // executed as part of the rendering of another helper in order to
392
                // prevent any potential security issues.
393
                const originalHelpers = Renderer.disallowedNestedHelpers.reduce((carry, name) => {
394
                    if (context.hasOwnProperty(name)) {
395
                        carry[name] = context[name];
396
                    }
397
 
398
                    return carry;
399
                }, {});
400
 
401
                Renderer.disallowedNestedHelpers.forEach((helperName) => {
402
                    context[helperName] = () => '';
403
                });
404
 
405
                // Execute the helper with the modified context that doesn't include
406
                // the disallowed nested helpers. This prevents the disallowed
407
                // helpers from being called from within other helpers.
408
                const result = helperFunction.apply(this, [context, sectionText, helper]);
409
 
410
                // Restore the original helper implementation in the context so that
411
                // any further rendering has access to them again.
412
                for (const name in originalHelpers) {
413
                    context[name] = originalHelpers[name];
414
                }
415
 
416
                return result;
417
            }.bind(this);
418
        }.bind(this);
419
    }
420
 
421
    /**
422
     * Add some common helper functions to all context objects passed to templates.
423
     * These helpers match exactly the helpers available in php.
424
     *
425
     * @method addHelpers
426
     * @private
427
     * @param {Object} context Simple types used as the context for the template.
428
     * @param {String} themeName We set this multiple times, because there are async calls.
429
     */
430
    addHelpers(context, themeName) {
431
        this.currentThemeName = themeName;
432
        this.requiredStrings = [];
433
        this.requiredJS = [];
434
        context.uniqid = (Renderer.uniqInstances++);
435
 
436
        // Please note that these helpers _must_ not return a Promise.
437
        context.str = this.addHelperFunction(this.stringHelper, context);
438
        context.cleanstr = this.addHelperFunction(this.cleanStringHelper, context);
439
        context.pix = this.addHelperFunction(this.pixHelper, context);
440
        context.js = this.addHelperFunction(this.jsHelper, context);
441
        context.quote = this.addHelperFunction(this.quoteHelper, context);
442
        context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);
443
        context.userdate = this.addHelperFunction(this.userDateHelper, context);
444
        context.globals = {config: config};
445
        context.currentTheme = themeName;
446
    }
447
 
448
    /**
449
     * Get all the JS blocks from the last rendered template.
450
     *
451
     * @method getJS
452
     * @private
453
     * @returns {string}
454
     */
455
    getJS() {
456
        return this.requiredJS.join(";\n");
457
    }
458
 
459
    /**
460
     * Treat strings in content.
461
     *
462
     * The purpose of this method is to replace the placeholders found in a string
463
     * with the their respective translated strings.
464
     *
465
     * Previously we were relying on String.replace() but the complexity increased with
466
     * the numbers of strings to replace. Now we manually walk the string and stop at each
467
     * placeholder we find, only then we replace it. Most of the time we will
468
     * replace all the placeholders in a single run, at times we will need a few
469
     * more runs when placeholders are replaced with strings that contain placeholders
470
     * themselves.
471
     *
472
     * @param {String} content The content in which string placeholders are to be found.
473
     * @param {Map} stringMap The strings to replace with.
474
     * @returns {String} The treated content.
475
     */
476
    treatStringsInContent(content, stringMap) {
477
        // Placeholders are in the for [[_sX]] or [[_cX]] where X is the string index.
478
        const stringPattern = /(?<placeholder>\[\[_(?<stringType>[cs])(?<stringIndex>\d+)\]\])/g;
479
 
1441 ariadna 480
        // A helper to fetch the string for a given placeholder.
1 efrain 481
        const getUpdatedString = ({placeholder, stringType, stringIndex}) => {
482
            if (stringMap.has(placeholder)) {
483
                return stringMap.get(placeholder);
484
            }
485
 
486
            if (stringType === placeholderCleanedString) {
487
                // Attempt to find the unclean string and clean it. Store it for later use.
488
                const uncleanString = stringMap.get(`[[_s${stringIndex}]]`);
489
                if (uncleanString) {
490
                    stringMap.set(placeholder, mustache.escape(uncleanString));
491
                    return stringMap.get(placeholder);
492
                }
493
            }
494
 
495
            Log.debug(`Could not find string for pattern ${placeholder}`);
1441 ariadna 496
            return ''; // Fallback if no match is found.
1 efrain 497
        };
498
 
1441 ariadna 499
        let updatedContent = content; // Start with the original content.
500
        let placeholderFound = true; // Flag to track if we are still finding placeholders.
1 efrain 501
 
1441 ariadna 502
        // Continue looping until no more placeholders are found in the updated content.
503
        while (placeholderFound) {
504
            let match;
505
            let result = [];
506
            let lastIndex = 0;
507
            placeholderFound = false; // Assume no placeholders are found.
508
 
509
            // Find all placeholders in the content and replace them with their respective strings.
510
            while ((match = stringPattern.exec(updatedContent)) !== null) {
511
                placeholderFound = true; // A placeholder was found, so continue looping.
512
 
513
                // Add the content before the matched placeholder.
514
                result.push(updatedContent.slice(lastIndex, match.index));
515
 
516
                // Add the updated string for the placeholder.
517
                result.push(getUpdatedString(match.groups));
518
 
519
                // Update lastIndex to move past the current match.
520
                lastIndex = match.index + match[0].length;
521
            }
522
 
523
            // Add the remaining part of the content after the last match.
524
            result.push(updatedContent.slice(lastIndex));
525
 
526
            // Join the parts of the result array into the updated content.
527
            updatedContent = result.join('');
1 efrain 528
        }
529
 
1441 ariadna 530
        return updatedContent; // Return the fully updated content after all loops.
1 efrain 531
    }
532
 
533
    /**
534
     * Treat strings in content.
535
     *
536
     * The purpose of this method is to replace the date placeholders found in the
537
     * content with the their respective translated dates.
538
     *
539
     * @param {String} content The content in which string placeholders are to be found.
540
     * @param {Array} dates The dates to replace with.
541
     * @returns {String} The treated content.
542
     */
543
    treatDatesInContent(content, dates) {
544
        dates.forEach((date, index) => {
545
            content = content.replace(
546
                new RegExp(`\\[\\[_t_${index}\\]\\]`, 'g'),
547
                date,
548
            );
549
        });
550
 
551
        return content;
552
    }
553
 
554
    /**
555
     * Render a template and then call the callback with the result.
556
     *
557
     * @method doRender
558
     * @private
559
     * @param {string|Promise} templateSourcePromise The mustache template to render.
560
     * @param {Object} context Simple types used as the context for the template.
561
     * @param {String} themeName Name of the current theme.
562
     * @returns {Promise<object<string, string>>} The rendered HTML and JS.
563
     */
564
    async doRender(templateSourcePromise, context, themeName) {
565
        this.currentThemeName = themeName;
566
        const iconTemplate = this.iconSystem.getTemplateName();
567
 
568
        const pendingPromise = new Pending('core/templates:doRender');
569
        const [templateSource] = await Promise.all([
570
            templateSourcePromise,
571
            Renderer.getLoader().getTemplate(iconTemplate, themeName),
572
        ]);
573
 
1441 ariadna 574
        // Clone context object to avoid manipulating the original context object.
575
        const templateContext = {...context};
576
        this.addHelpers(templateContext, themeName);
1 efrain 577
 
578
        // Render the template.
579
        const renderedContent = await mustache.render(
580
            templateSource,
1441 ariadna 581
            templateContext,
1 efrain 582
            // Note: The third parameter is a function that will be called to process partials.
583
            (partialName) => Renderer.getLoader().partialHelper(partialName, themeName),
584
        );
585
 
586
        const {html, js} = await this.processRenderedContent(renderedContent);
587
 
588
        pendingPromise.resolve();
589
        return {html, js};
590
    }
591
 
592
    /**
593
     * Process the rendered content, treating any strings and applying and helper strings, dates, etc.
594
     * @param {string} renderedContent
595
     * @returns {Promise<object<string, string>>} The rendered HTML and JS.
596
     */
597
    async processRenderedContent(renderedContent) {
598
        let html = renderedContent.trim();
599
        let js = this.getJS();
600
 
601
        if (this.requiredStrings.length > 0) {
602
            // Fetch the strings into a new Map using the placeholder as an index.
603
            // Note: We only fetch the unclean version. Cleaning of strings happens lazily in treatStringsInContent.
604
            const stringMap = new Map(
605
                (await getStrings(this.requiredStrings)).map((string, index) => (
606
                    [`[[_s${index}]]`, string]
607
                ))
608
            );
609
 
610
            // Make sure string substitutions are done for the userdate
611
            // values as well.
612
            this.requiredDates = this.requiredDates.map(function(date) {
613
                return {
614
                    timestamp: this.treatStringsInContent(date.timestamp, stringMap),
615
                    format: this.treatStringsInContent(date.format, stringMap)
616
                };
617
            }.bind(this));
618
 
619
            // Why do we not do another call the render here?
620
            //
621
            // Because that would expose DOS holes. E.g.
622
            // I create an assignment called "{{fish" which
623
            // would get inserted in the template in the first pass
624
            // and cause the template to die on the second pass (unbalanced).
625
            html = this.treatStringsInContent(html, stringMap);
626
            js = this.treatStringsInContent(js, stringMap);
627
        }
628
 
629
        // This has to happen after the strings replacement because you can
630
        // use the string helper in content for the user date helper.
631
        if (this.requiredDates.length > 0) {
632
            const dates = await UserDate.get(this.requiredDates);
633
            html = this.treatDatesInContent(html, dates);
634
            js = this.treatDatesInContent(js, dates);
635
        }
636
 
637
        return {html, js};
638
    }
639
 
640
    /**
641
     * Load a template and call doRender on it.
642
     *
643
     * @method render
644
     * @private
645
     * @param {string} templateName - should consist of the component and the name of the template like this:
646
     *                              core/menu (lib/templates/menu.mustache) or
647
     *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
648
     * @param {Object} [context={}] - Could be array, string or simple value for the context of the template.
649
     * @param {string} [themeName] - Name of the current theme.
650
     * @returns {Promise<object>} Native promise object resolved when the template has been rendered.}
651
     */
652
    async render(
653
        templateName,
654
        context = {},
655
        themeName = config.theme,
656
    ) {
657
        this.currentThemeName = themeName;
658
 
659
        // Preload the module to do the icon rendering based on the theme iconsystem.
660
        await this.setupIconSystem();
661
 
662
        const templateSource = Renderer.getLoader().cachePartials(templateName, themeName);
663
        return this.doRender(templateSource, context, themeName);
664
    }
665
}