Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.94% covered (danger)
32.94%
28 / 85
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
TemplateParser
32.94% covered (danger)
32.94%
28 / 85
33.33% covered (danger)
33.33%
1 / 3
63.96
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 compile
37.68% covered (danger)
37.68%
26 / 69
0.00% covered (danger)
0.00%
0 / 1
40.28
 processTemplate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * TemplateParser.php
5 *
6 * This file is part of the Codex design system, the official design system
7 * for Wikimedia projects. It provides the `TemplateParser` class, which is responsible
8 * for compiling and rendering Mustache templates with localization and helper support.
9 *
10 * The TemplateParser centralizes template compilation and rendering logic,
11 * enhancing reusability and maintainability.
12 *
13 * @category Parser
14 * @package  Codex\Parser
15 * @since    0.3.0
16 * @author   Doğu Abaris <abaris@null.net>
17 * @license  https://www.gnu.org/copyleft/gpl.html GPL-2.0-or-later
18 * @link     https://doc.wikimedia.org/codex/main/ Codex Documentation
19 */
20
21namespace Wikimedia\Codex\Parser;
22
23use LightnCandy\Flags;
24use LightnCandy\LightnCandy;
25use RuntimeException;
26use Wikimedia\Codex\Contract\ILocalizer;
27
28/**
29 * TemplateParser is responsible for compiling and rendering Mustache templates.
30 *
31 * This class provides methods to compile Mustache templates into PHP rendering functions
32 * and render templates with localization and custom helper support.
33 *
34 * @category Parser
35 * @package  Codex\Parser
36 * @since    0.3.0
37 * @author   Doğu Abaris <abaris@null.net>
38 * @license  https://www.gnu.org/copyleft/gpl.html GPL-2.0-or-later
39 * @link     https://doc.wikimedia.org/codex/main/ Codex Documentation
40 */
41class TemplateParser {
42
43    /**
44     * Path to the Mustache templates directory.
45     */
46    private string $templateDir;
47
48    /**
49     * Array of cached rendering functions.
50     */
51    private array $renderers = [];
52
53    /**
54     * Compilation flags for LightnCandy.
55     */
56    private int $compileFlags;
57
58    /**
59     * The localization instance implementing ILocalizer.
60     */
61    private ILocalizer $localizer;
62
63    /**
64     * Constructor to initialize the TemplateParser.
65     *
66     * @since 0.3.0
67     *
68     * @param string $templateDir Path to the template directory.
69     * @param ILocalizer $localizer The localizer instance for supporting translations and localization.
70     */
71    public function __construct( string $templateDir, ILocalizer $localizer ) {
72        $this->templateDir = $templateDir;
73        $this->localizer = $localizer;
74
75        $this->compileFlags =
76            Flags::FLAG_ERROR_EXCEPTION |
77            Flags::FLAG_HANDLEBARS |
78            Flags::FLAG_ADVARNAME |
79            Flags::FLAG_RUNTIMEPARTIAL |
80            Flags::FLAG_EXTHELPER |
81            Flags::FLAG_NOESCAPE |
82            Flags::FLAG_RENDER_DEBUG |
83            Flags::FLAG_MUSTACHE |
84            Flags::FLAG_ERROR_EXCEPTION |
85            Flags::FLAG_NOHBHELPERS |
86            Flags::FLAG_MUSTACHELOOKUP;
87    }
88
89    /**
90     * Compiles the Mustache template into a PHP rendering function.
91     *
92     * @since 0.3.0
93     *
94     * @param string $templateName Name of the template file (without extension).
95     *
96     * @return callable Render function for the template.
97     * @throws RuntimeException If the template file cannot be found or compilation fails.
98     * @suppress PhanTypeMismatchArgument
99     */
100    public function compile( string $templateName ): callable {
101        unset( $this->renderers[$templateName] );
102
103        if ( isset( $this->renderers[$templateName] ) ) {
104            return $this->renderers[$templateName];
105        }
106
107        $templatePath = "$this->templateDir/$templateName.mustache";
108
109        if ( !file_exists( $templatePath ) ) {
110            throw new RuntimeException( "Template file not found: $templatePath" );
111        }
112
113        $templateContent = file_get_contents( $templatePath );
114
115        if ( $templateContent === false ) {
116            throw new RuntimeException( "Unable to read template file: $templatePath" );
117        }
118
119        $phpCode = LightnCandy::compile( $templateContent, [
120            'flags' => $this->compileFlags,
121            'helpers' => [
122                'i18n' => function ( $options ) {
123                    // Extract the block content as the string
124                    $rawText = trim( $options['fn']() );
125
126                    $renderedText = trim( $rawText );
127                    // Split by '|' to separate the key and parameters.
128                    // XXX This assumes that the expanded content of parameters does not contain pipes.
129                    $parts = explode( '|', $renderedText );
130                    // The first part is the message key, the rest are parameters
131                    $key = trim( array_shift( $parts ) );
132                    $params = [];
133                    foreach ( $parts as $part ) {
134                        $params[] = trim( $part );
135                    }
136
137                    $message = $this->localizer->msg( $key, [ 'variables' => $params ] );
138
139                    return htmlspecialchars( $message, ENT_QUOTES, 'UTF-8' );
140                },
141                'renderClasses' => static function ( $options ) {
142                    $renderedAttributes = $options['fn']();
143                    if ( preg_match( '/class="([^"]*)"/', $renderedAttributes, $matches ) ) {
144                        return ' ' . $matches[1];
145                    }
146
147                    return '';
148                },
149                'renderAttributes' => static function ( $options ) {
150                    $renderedAttributes = $options['fn']();
151                    $attribs = trim( preg_replace( '/\s*class="[^"]*"/', '', $renderedAttributes ) );
152
153                    return $attribs !== '' ? ' ' . $attribs : '';
154                },
155            ],
156            'basedir' => $this->templateDir,
157            'fileext' => '.mustache',
158            'partialresolver' => function ( $cx, $partialName ) use ( $templateName, &$files ) {
159                $filename = "$this->templateDir/$partialName.mustache";
160                if ( !file_exists( $filename ) ) {
161                    throw new RuntimeException(
162                        sprintf(
163                            'Could not compile template `%s`: Could not find partial `%s` at %s',
164                            $templateName,
165                            $partialName,
166                            $filename
167                        )
168                    );
169                }
170
171                $fileContents = file_get_contents( $filename );
172
173                if ( $fileContents === false ) {
174                    throw new RuntimeException(
175                        sprintf(
176                            'Could not compile template `%s`: Could not find partial `%s` at %s',
177                            $templateName,
178                            $partialName,
179                            $filename
180                        )
181                    );
182                }
183
184                $files[] = $filename;
185
186                return $fileContents;
187            },
188        ] );
189
190        if ( !$phpCode ) {
191            throw new RuntimeException( "Failed to compile template: $templateName" );
192        }
193
194        // phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.eval
195        $renderFunction = eval( $phpCode );
196        if ( !is_callable( $renderFunction ) ) {
197            throw new RuntimeException( "Compiled template is not callable: $templateName" );
198        }
199
200        $this->renderers[$templateName] = $renderFunction;
201
202        return $renderFunction;
203    }
204
205    /**
206     * Processes the template with provided data and returns the rendered HTML.
207     *
208     * @since 0.3.0
209     *
210     * @param string $templateName Name of the template file (without extension).
211     * @param array $data Data to render within the template.
212     *
213     * @return string Rendered HTML.
214     * @throws RuntimeException If rendering fails.
215     */
216    public function processTemplate( string $templateName, array $data ): string {
217        $renderFunction = $this->compile( $templateName );
218
219        return $renderFunction( $data );
220    }
221}