MDL-66384 core: Limit explode to allow subdirs in template names
[moodle.git] / lib / classes / output / mustache_template_source_loader.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Load template source strings.
19  *
20  * @package    core
21  * @category   output
22  * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 namespace core\output;
28 defined('MOODLE_INTERNAL') || die();
30 use \Mustache_Tokenizer;
32 /**
33  * Load template source strings.
34  *
35  * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
36  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37  */
38 class mustache_template_source_loader {
40     /** @var $gettemplatesource Callback function to load the template source from full name */
41     private $gettemplatesource = null;
43     /**
44      * Constructor that takes a callback to allow the calling code to specify how to retrieve
45      * the source for a template name.
46      *
47      * If no callback is provided then default to the load from disk implementation.
48      *
49      * @param callable|null $gettemplatesource Callback to load template source by template name
50      */
51     public function __construct(callable $gettemplatesource = null) {
52         if ($gettemplatesource) {
53             // The calling code has specified a function for retrieving the template source
54             // code by name and theme.
55             $this->gettemplatesource = $gettemplatesource;
56         } else {
57             // By default we will pull the template from disk.
58             $this->gettemplatesource = function($component, $name, $themename) {
59                 $fulltemplatename = $component . '/' . $name;
60                 $filename = mustache_template_finder::get_template_filepath($fulltemplatename, $themename);
61                 return file_get_contents($filename);
62             };
63         }
64     }
66     /**
67      * Remove comments from mustache template.
68      *
69      * @param string $templatestr
70      * @return string
71      */
72     protected function strip_template_comments($templatestr) : string {
73         return preg_replace('/(?={{!)(.*)(}})/sU', '', $templatestr);
74     }
76     /**
77      * Load the template source from the component and template name.
78      *
79      * @param string $component The moodle component (e.g. core_message)
80      * @param string $name The template name (e.g. message_drawer)
81      * @param string $themename The theme to load the template for (e.g. boost)
82      * @param bool $includecomments If the comments should be stripped from the source before returning
83      * @return string The template source
84      */
85     public function load(
86         string $component,
87         string $name,
88         string $themename,
89         bool $includecomments = false
90     ) : string {
91         // Get the template source from the callback.
92         $source = ($this->gettemplatesource)($component, $name, $themename);
94         // Remove comments from template.
95         if (!$includecomments) {
96             $source = $this->strip_template_comments($source);
97         }
99         return $source;
100     }
102     /**
103      * Load a template and some of the dependencies that will be needed in order to render
104      * the template.
105      *
106      * The current implementation will return all of the templates and all of the strings in
107      * each of those templates (excluding string substitutions).
108      *
109      * The return format is an array indexed with the dependency type (e.g. templates / strings) then
110      * the component (e.g. core_message), and then the id (e.g. message_drawer).
111      *
112      * For example:
113      * * We have 3 templates in core named foo, bar, and baz.
114      * * foo includes bar and bar includes baz.
115      * * foo uses the string 'home' from core
116      * * baz uses the string 'help' from core
117      *
118      * If we load the template foo this function would return:
119      * [
120      *      'templates' => [
121      *          'core' => [
122      *              'foo' => '... template source ...',
123      *              'bar' => '... template source ...',
124      *              'baz' => '... template source ...',
125      *          ]
126      *      ],
127      *      'strings' => [
128      *          'core' => [
129      *              'home' => 'Home',
130      *              'help' => 'Help'
131      *          ]
132      *      ]
133      * ]
134      *
135      * @param string $templatecomponent The moodle component (e.g. core_message)
136      * @param string $templatename The template name (e.g. message_drawer)
137      * @param string $themename The theme to load the template for (e.g. boost)
138      * @param bool $includecomments If the comments should be stripped from the source before returning
139      * @param array $seentemplates List of templates already processed / to be skipped.
140      * @param array $seenstrings List of strings already processed / to be skipped.
141      * @param string|null $lang moodle translation language, null means use current.
142      * @return array
143      */
144     public function load_with_dependencies(
145         string $templatecomponent,
146         string $templatename,
147         string $themename,
148         bool $includecomments = false,
149         array $seentemplates = [],
150         array $seenstrings = [],
151         string $lang = null
152     ) : array {
153         // Initialise the return values.
154         $templates = [];
155         $strings = [];
156         $templatecomponent = trim($templatecomponent);
157         $templatename = trim($templatename);
158         // Get the requested template source.
159         $templatesource = $this->load($templatecomponent, $templatename, $themename, $includecomments);
160         // This is a helper function to save a value in one of the result arrays (either $templates or $strings).
161         $save = function(array $results, array $seenlist, string $component, string $id, $value) {
162             if (!isset($results[$component])) {
163                 // If the results list doesn't already contain this component then initialise it.
164                 $results[$component] = [];
165             }
167             // Save the value.
168             $results[$component][$id] = $value;
169             // Record that this item has been processed.
170             array_push($seenlist, "$component/$id");
171             // Return the updated results and seen list.
172             return [$results, $seenlist];
173         };
174         // This is a helper function for processing a dependency. Does stuff like ignore duplicate processing,
175         // common result formatting etc.
176         $handler = function(array $dependency, array $ignorelist, callable $processcallback) {
177             foreach ($dependency as $component => $ids) {
178                 foreach ($ids as $id) {
179                     $dependencyid = "$component/$id";
180                     if (array_search($dependencyid, $ignorelist) === false) {
181                         $processcallback($component, $id);
182                         // Add this to our ignore list now that we've processed it so that we don't
183                         // process it again.
184                         array_push($ignorelist, $dependencyid);
185                     }
186                 }
187             }
189             return $ignorelist;
190         };
192         // Save this template as the first result in the $templates result array.
193         list($templates, $seentemplates) = $save($templates, $seentemplates, $templatecomponent, $templatename, $templatesource);
195         // Check the template for any dependencies that need to be loaded.
196         $dependencies = $this->scan_template_source_for_dependencies($templatesource);
198         // Load all of the lang strings that this template requires and add them to the
199         // returned values.
200         $seenstrings = $handler(
201             $dependencies['strings'],
202             $seenstrings,
203             // Include $strings and $seenstrings by reference so that their values can be updated
204             // outside of this anonymous function.
205             function($component, $id) use ($save, &$strings, &$seenstrings, $lang) {
206                 $string = get_string_manager()->get_string($id, $component, null, $lang);
207                 // Save the string in the $strings results array.
208                 list($strings, $seenstrings) = $save($strings, $seenstrings, $component, $id, $string);
209             }
210         );
212         // Load any child templates that we've found in this template and add them to
213         // the return list of dependencies.
214         $seentemplates = $handler(
215             $dependencies['templates'],
216             $seentemplates,
217             // Include $strings, $seenstrings, $templates, and $seentemplates by reference so that their values can be updated
218             // outside of this anonymous function.
219             function($component, $id) use (
220                 $themename,
221                 $includecomments,
222                 &$seentemplates,
223                 &$seenstrings,
224                 &$templates,
225                 &$strings,
226                 $save
227             ) {
228                 // We haven't seen this template yet so load it and it's dependencies.
229                 $subdependencies = $this->load_with_dependencies(
230                     $component,
231                     $id,
232                     $themename,
233                     $includecomments,
234                     $seentemplates,
235                     $seenstrings
236                 );
238                 foreach ($subdependencies['templates'] as $component => $ids) {
239                     foreach ($ids as $id => $value) {
240                         // Include the child themes in our results.
241                         list($templates, $seentemplates) = $save($templates, $seentemplates, $component, $id, $value);
242                     }
243                 };
245                 foreach ($subdependencies['strings'] as $component => $ids) {
246                     foreach ($ids as $id => $value) {
247                         // Include any strings that the child templates need in our results.
248                         list($strings, $seenstrings) = $save($strings, $seenstrings, $component, $id, $value);
249                     }
250                 }
251             }
252         );
254         return [
255             'templates' => $templates,
256             'strings' => $strings
257         ];
258     }
260     /**
261      * Scan over a template source string and return a list of dependencies it requires.
262      * At the moment the list will only include other templates and strings.
263      *
264      * The return format is an array indexed with the dependency type (e.g. templates / strings) then
265      * the component (e.g. core_message) with it's value being an array of the items required
266      * in that component.
267      *
268      * For example:
269      * If we have a template foo that includes 2 templates, bar and baz, and also 2 strings
270      * 'home' and 'help' from the core component then the return value would look like:
271      *
272      * [
273      *      'templates' => [
274      *          'core' => ['foo', 'bar', 'baz']
275      *      ],
276      *      'strings' => [
277      *          'core' => ['home', 'help']
278      *      ]
279      * ]
280      *
281      * @param string $source The template source
282      * @return array
283      */
284     protected function scan_template_source_for_dependencies(string $source) : array {
285         $tokenizer = new Mustache_Tokenizer();
286         $tokens = $tokenizer->scan($source);
287         $templates = [];
288         $strings = [];
289         $addtodependencies = function($dependencies, $component, $id) {
290             $id = trim($id);
291             $component = trim($component);
293             if (!isset($dependencies[$component])) {
294                 // Initialise the component if we haven't seen it before.
295                 $dependencies[$component] = [];
296             }
298             // Add this id to the list of dependencies.
299             array_push($dependencies[$component], $id);
301             return $dependencies;
302         };
304         foreach ($tokens as $index => $token) {
305             $type = $token['type'];
306             $name = isset($token['name']) ? $token['name'] : null;
308             if ($name) {
309                 switch ($type) {
310                     case Mustache_Tokenizer::T_PARTIAL:
311                         list($component, $id) = explode('/', $name, 2);
312                         $templates = $addtodependencies($templates, $component, $id);
313                         break;
314                     case Mustache_Tokenizer::T_PARENT:
315                         list($component, $id) = explode('/', $name, 2);
316                         $templates = $addtodependencies($templates, $component, $id);
317                         break;
318                     case Mustache_Tokenizer::T_SECTION:
319                         if ($name == 'str') {
320                             // The token that containts the string identifiers (key and component) should
321                             // immediately follow the #str token.
322                             $identifiertoken = isset($tokens[$index + 1]) ? $tokens[$index + 1] : null;
324                             if ($identifiertoken) {
325                                 // The string identifier is the key and component comma separated.
326                                 $identifierstring = $identifiertoken['value'];
327                                 $parts = explode(',', $identifierstring);
328                                 $id = $parts[0];
329                                 // Default to 'core' for the component, if not specified.
330                                 $component = isset($parts[1]) ? $parts[1] : 'core';
331                                 $strings = $addtodependencies($strings, $component, $id);
332                             }
333                         }
334                         break;
335                 }
336             }
337         }
339         return [
340             'templates' => $templates,
341             'strings' => $strings
342         ];
343     }