Merge branch 'MDL-57636_master' of https://github.com/dasistwas/moodle
[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      * @return array
142      */
143     public function load_with_dependencies(
144         string $templatecomponent,
145         string $templatename,
146         string $themename,
147         bool $includecomments = false,
148         array $seentemplates = [],
149         array $seenstrings = []
150     ) : array {
151         // Initialise the return values.
152         $templates = [];
153         $strings = [];
154         $templatecomponent = trim($templatecomponent);
155         $templatename = trim($templatename);
156         // Get the requested template source.
157         $templatesource = $this->load($templatecomponent, $templatename, $themename, $includecomments);
158         // This is a helper function to save a value in one of the result arrays (either $templates or $strings).
159         $save = function(array $results, array $seenlist, string $component, string $id, $value) {
160             if (!isset($results[$component])) {
161                 // If the results list doesn't already contain this component then initialise it.
162                 $results[$component] = [];
163             }
165             // Save the value.
166             $results[$component][$id] = $value;
167             // Record that this item has been processed.
168             array_push($seenlist, "$component/$id");
169             // Return the updated results and seen list.
170             return [$results, $seenlist];
171         };
172         // This is a helper function for processing a dependency. Does stuff like ignore duplicate processing,
173         // common result formatting etc.
174         $handler = function(array $dependency, array $ignorelist, callable $processcallback) {
175             foreach ($dependency as $component => $ids) {
176                 foreach ($ids as $id) {
177                     $dependencyid = "$component/$id";
178                     if (array_search($dependencyid, $ignorelist) === false) {
179                         $processcallback($component, $id);
180                         // Add this to our ignore list now that we've processed it so that we don't
181                         // process it again.
182                         array_push($ignorelist, $dependencyid);
183                     }
184                 }
185             }
187             return $ignorelist;
188         };
190         // Save this template as the first result in the $templates result array.
191         list($templates, $seentemplates) = $save($templates, $seentemplates, $templatecomponent, $templatename, $templatesource);
193         // Check the template for any dependencies that need to be loaded.
194         $dependencies = $this->scan_template_source_for_dependencies($templatesource);
196         // Load all of the lang strings that this template requires and add them to the
197         // returned values.
198         $seenstrings = $handler(
199             $dependencies['strings'],
200             $seenstrings,
201             // Include $strings and $seenstrings by reference so that their values can be updated
202             // outside of this anonymous function.
203             function($component, $id) use ($save, &$strings, &$seenstrings) {
204                 $string = get_string($id, $component);
205                 // Save the string in the $strings results array.
206                 list($strings, $seenstrings) = $save($strings, $seenstrings, $component, $id, $string);
207             }
208         );
210         // Load any child templates that we've found in this template and add them to
211         // the return list of dependencies.
212         $seentemplates = $handler(
213             $dependencies['templates'],
214             $seentemplates,
215             // Include $strings, $seenstrings, $templates, and $seentemplates by reference so that their values can be updated
216             // outside of this anonymous function.
217             function($component, $id) use (
218                 $themename,
219                 $includecomments,
220                 &$seentemplates,
221                 &$seenstrings,
222                 &$templates,
223                 &$strings,
224                 $save
225             ) {
226                 // We haven't seen this template yet so load it and it's dependencies.
227                 $subdependencies = $this->load_with_dependencies(
228                     $component,
229                     $id,
230                     $themename,
231                     $includecomments,
232                     $seentemplates,
233                     $seenstrings
234                 );
236                 foreach ($subdependencies['templates'] as $component => $ids) {
237                     foreach ($ids as $id => $value) {
238                         // Include the child themes in our results.
239                         list($templates, $seentemplates) = $save($templates, $seentemplates, $component, $id, $value);
240                     }
241                 };
243                 foreach ($subdependencies['strings'] as $component => $ids) {
244                     foreach ($ids as $id => $value) {
245                         // Include any strings that the child templates need in our results.
246                         list($strings, $seenstrings) = $save($strings, $seenstrings, $component, $id, $value);
247                     }
248                 }
249             }
250         );
252         return [
253             'templates' => $templates,
254             'strings' => $strings
255         ];
256     }
258     /**
259      * Scan over a template source string and return a list of dependencies it requires.
260      * At the moment the list will only include other templates and strings.
261      *
262      * The return format is an array indexed with the dependency type (e.g. templates / strings) then
263      * the component (e.g. core_message) with it's value being an array of the items required
264      * in that component.
265      *
266      * For example:
267      * If we have a template foo that includes 2 templates, bar and baz, and also 2 strings
268      * 'home' and 'help' from the core component then the return value would look like:
269      *
270      * [
271      *      'templates' => [
272      *          'core' => ['foo', 'bar', 'baz']
273      *      ],
274      *      'strings' => [
275      *          'core' => ['home', 'help']
276      *      ]
277      * ]
278      *
279      * @param string $source The template source
280      * @return array
281      */
282     protected function scan_template_source_for_dependencies(string $source) : array {
283         $tokenizer = new Mustache_Tokenizer();
284         $tokens = $tokenizer->scan($source);
285         $templates = [];
286         $strings = [];
287         $addtodependencies = function($dependencies, $component, $id) {
288             $id = trim($id);
289             $component = trim($component);
291             if (!isset($dependencies[$component])) {
292                 // Initialise the component if we haven't seen it before.
293                 $dependencies[$component] = [];
294             }
296             // Add this id to the list of dependencies.
297             array_push($dependencies[$component], $id);
299             return $dependencies;
300         };
302         foreach ($tokens as $index => $token) {
303             $type = $token['type'];
304             $name = isset($token['name']) ? $token['name'] : null;
306             if ($name) {
307                 switch ($type) {
308                     case Mustache_Tokenizer::T_PARTIAL:
309                         list($component, $id) = explode('/', $name);
310                         $templates = $addtodependencies($templates, $component, $id);
311                         break;
312                     case Mustache_Tokenizer::T_PARENT:
313                         list($component, $id) = explode('/', $name);
314                         $templates = $addtodependencies($templates, $component, $id);
315                         break;
316                     case Mustache_Tokenizer::T_SECTION:
317                         if ($name == 'str') {
318                             // The token that containts the string identifiers (key and component) should
319                             // immediately follow the #str token.
320                             $identifiertoken = isset($tokens[$index + 1]) ? $tokens[$index + 1] : null;
322                             if ($identifiertoken) {
323                                 // The string identifier is the key and component comma separated.
324                                 $identifierstring = $identifiertoken['value'];
325                                 $parts = explode(',', $identifierstring);
326                                 $id = $parts[0];
327                                 // Default to 'core' for the component, if not specified.
328                                 $component = isset($parts[1]) ? $parts[1] : 'core';
329                                 $strings = $addtodependencies($strings, $component, $id);
330                             }
331                         }
332                         break;
333                 }
334             }
335         }
337         return [
338             'templates' => $templates,
339             'strings' => $strings
340         ];
341     }