Passed
Push — master ( 6c6d7e...b48c4f )
by Marwan
01:39
created

Engine::isDebug()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
1
<?php
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Frontend;
13
14
use MAKS\Velox\Frontend\Path;
15
use MAKS\Velox\Helper\Misc;
16
17
/**
18
 * A class that serves as a templating engine for view files.
19
 * This templating engine is regular expression based.
20
 *
21
 * Templating tags:
22
 * - Create a block `{! @block name !}` ... `{! @endblock !}`, use `{! @super !}` to inherit parent block.
23
 * - Print a block `{! @block(name) !}`.
24
 * - Extend a file `{! @extends 'theme/layouts/file' !}`, blocks of this file will be inherited.
25
 * - Include a file `{! @include 'theme/includes/file' !}`, this will get rendered before inclusion.
26
 * - Embed a file `{! @embed 'theme/components/file' !}`, this will be included as is.
27
 * - Control structures `{! @if ($var) !}` ... `{! @endif !}`, `{! @foreach($vars as $var) !}` ... `{! @endforeach !}`.
28
 * - Variable assignments `{! $var = '' !}`, content can be a variable or any valid PHP expression.
29
 * - Print a variable `{{ $var }}`, content can be a variable or any PHP expression that can be casted to a string.
30
 * - Print a variable without escaping `{{{ $var }}}`, content can be a variable or any PHP expression that can be casted to a string.
31
 * - Comment something `{# This is a comment #}`, this will be a PHP comment (will not be available in final HTML).
32
 */
33
class Engine
34
{
35
    /**
36
     * Regular expressions for syntax tokens.
37
     */
38
    public const REGEX = [
39
        'dependency'             => '/{!\s*@(extends|embed)\s+[\'"]?(.*?)[\'"]?\s*!}/s',
40
        'include'                => '/{!\s*@include\s+[\'"]?(.*?)[\'"]?\s*!}/s',
41
        'block'                  => '/{!\s*@block\s+(.*?)\s*!}(.*?){!\s*@endblock\s*!}(?!.+{!\s*@endblock\s*!})/s',
42
        'block.child'            => '/{!\s*@block\s+.*?\s*!}.*({!\s*@block\s+(.*?)\s*!}(.*?){!\s*@endblock\s*!}).*{!\s*@endblock\s*!}/s',
43
        'block.print'            => '/{!\s*@block\((.*?)\)\s*!}/s',
44
        'block.super'            => '/{!\s*@super\s*!}/s',
45
        'php'                    => '/{!\s*(.+?)\s*!}/s',
46
        'comment'                => '/{#\s*(.+?)\s*#}/s',
47
        'controlStructure'       => '/@(if|else|elseif|endif|do|while|endwhile|for|endfor|foreach|endforeach|continue|switch|endswitch|break|return|require|include)/s',
48
        'controlStructure.start' => '/^@(if|else|elseif|while|for|foreach|switch)/s',
49
        'controlStructure.end'   => '/@(endif|endwhile|endfor|endforeach|endswitch)$/s',
50
        'print'                  => '/{{\s*(.+?)\s*}}/s',
51
        'print.unescaped'        => '/{{{\s*(.+?)\s*}}}/s',
52
    ];
53
54
55
    /**
56
     * Currently captured template blocks.
57
     */
58
    private array $blocks = [];
59
60
    /**
61
     * Template files directory.
62
     */
63
    protected string $templatesDirectory;
64
65
    /**
66
     * Template files file extension.
67
     */
68
    protected string $templatesFileDirectory;
69
70
    /**
71
     * Template files cache directory.
72
     */
73
    protected string $cacheDirectory;
74
75
    /**
76
     * Whether or not to cache compiled template files.
77
     */
78
    protected bool $cache;
79
80
81
    /**
82
     * Whether or not to add debugging info to the compiled template.
83
     */
84
    public static bool $debug = false;
85
86
87
    /**
88
     * Class constructor.
89
     */
90 8
    public function __construct(
91
        string $templatesDirectory     = './templates',
92
        string $templatesFileDirectory = '.phtml',
93
        string $cacheDirectory         = './cache/',
94
        bool $cache                    = true,
95
        bool $debug                    = false
96
    ) {
97 8
        $this->templatesDirectory     = $templatesDirectory;
98 8
        $this->templatesFileDirectory = $templatesFileDirectory;
99 8
        $this->cacheDirectory         = $cacheDirectory;
100 8
        $this->cache                  = $cache;
101
102 8
        $this->setDebug($debug);
103 8
    }
104
105
106
    /**
107
     * Renders a template file and pass the passed variables to it.
108
     *
109
     * @param string $file A relative path to template file from templates directory.
110
     * @param array $variables The variables to pass to the template.
111
     *
112
     * @return void
113
     */
114 3
    public function render(string $file, array $variables = []): void
115
    {
116 3
        $this->require(
117 3
            $this->getCompiledFile($file),
118
            $variables
119
        );
120 3
    }
121
122 1
    public function clearCache(): void
123
    {
124 1
        $files = glob(rtrim($this->cacheDirectory, '/') . '/*.php');
125
126 1
        array_map('unlink', $files);
127 1
    }
128
129
    /**
130
     * Compiles a template file and returns the path to the compiled template file from cache directory.
131
     *
132
     * @param string $file A relative path to template file from templates directory.
133
     *
134
     * @return string
135
     *
136
     * @throws \Exception If file does not exist.
137
     */
138 13
    public function getCompiledFile(string $file): string
139
    {
140 13
        $this->createCacheDirectory();
141
142 13
        $templateFile = $this->resolvePath($file);
143 13
        $cachedFile   = $this->resolveCachePath($file);
144
145 13
        $isCompiled = file_exists($cachedFile) && filemtime($cachedFile) > filemtime($templateFile);
146
147 13
        if (!$this->cache || !$isCompiled) {
148 11
            $content = vsprintf('<?php %s class_exists(\'%s\') or exit; ?> %s', [
149 11
                $this->cache ? '/* ' . $file . ' */' : 'unlink(__FILE__);',
150 11
                static::class,
151 11
                PHP_EOL . PHP_EOL . $this->getCompiledContent($file),
152
            ]);
153
154 11
            file_put_contents($cachedFile, $content);
155
        }
156
157 13
        return $cachedFile;
158
    }
159
160
    /**
161
     * Compiles a template file and returns the result after compilation.
162
     *
163
     * @param string $file A relative path to template file from templates directory.
164
     *
165
     * @return string
166
     *
167
     * @throws \Exception If file does not exist.
168
     */
169 13
    public function getCompiledContent(string $file): string
170
    {
171 13
        $file = $this->resolvePath($file);
172
173 13
        $this->assertFileExists($file);
174
175
        // execution order matters
176 12
        $code = $this->importDependencies($file);
177 12
        $code = $this->importIncludes($code);
178 12
        $code = $this->extractBlocks($code);
179 12
        $code = $this->injectBlocks($code);
180 12
        $code = $this->printUnescapedVariables($code);
181 12
        $code = $this->printVariables($code);
182 12
        $code = $this->wrapPhp($code);
183 12
        $code = $this->wrapComments($code);
184
185 12
        return $code;
186
    }
187
188
    /**
189
     * Evaluates a template file after compiling it in a temporary file and returns the result after evaluation.
190
     *
191
     * @param string $file A relative path to template file from templates directory.
192
     * @param array $variables The variables to pass to the template.
193
     *
194
     * @return string
195
     *
196
     * @throws \Exception If file does not exist.
197
     */
198 6
    public function getEvaluatedContent(string $file, array $variables = []): string
199
    {
200 6
        $this->createCacheDirectory();
201
202 6
        $content = $this->getCompiledContent($file);
203
204
        // an actual file is used here because 'require' does not work with 'php://temp'
205 6
        $file = tempnam($this->cacheDirectory, 'EVL');
206
207 6
        $temp = fopen($file, 'w');
208 6
        fwrite($temp, $content);
209 6
        fclose($temp);
210
211 6
        ob_start();
212 6
        $this->require($file, $variables);
213 6
        $content = ob_get_contents();
214 6
        ob_get_clean();
215
216 6
        unlink($file);
217
218 6
        return $content;
219
    }
220
221
    /**
222
     * Creates cache directory if it does not exist.
223
     *
224
     * @return void
225
     */
226 14
    private function createCacheDirectory(): void
227
    {
228 14
        if (!file_exists($this->cacheDirectory)) {
229 7
            mkdir($this->cacheDirectory, 0744, true);
230
        }
231 14
    }
232
233
    /**
234
     * Asserts that a file exists.
235
     *
236
     * @param string $file An absolute path to a file.
237
     *
238
     * @return void
239
     *
240
     * @throws \Exception If file does not exist.
241
     */
242 13
    private function assertFileExists(string $file): void
243
    {
244 13
        if (!file_exists($file)) {
245 1
            throw new \Exception(
246 1
                'Template file "' . $file . '" does not exist. ' .
247 1
                'The path is wrong. Hint: a parent directory may be missing'
248
            );
249
        }
250 12
    }
251
252
    /**
253
     * Requires a PHP file and pass it the passed variables.
254
     *
255
     * @param string $file An absolute path to the file that should be compiled.
256
     * @param array|null $variables [optional] An associative array of the variables to pass.
257
     *
258
     * @return void
259
     */
260 6
    protected static function require(string $file, ?array $variables = null): void
261
    {
262 6
        $_file = $file;
263 6
        unset($file);
264
265 6
        if ($variables !== null) {
266 6
            extract($variables, EXTR_OVERWRITE);
267 6
            unset($variables);
268
        }
269
270 6
        require($_file);
271 6
        unset($_file);
272 6
    }
273
274
    /**
275
     * Resolves a template file path.
276
     *
277
     * @param string $file The file path to resolve.
278
     *
279
     * @return string
280
     */
281 15
    protected function resolvePath(string $file): string
282
    {
283 15
        return Path::normalize(
284 15
            $this->templatesDirectory,
285 15
            $file,
286 15
            $this->templatesFileDirectory
287
        );
288
    }
289
290
    /**
291
     * Resolves a template file path from cache directory.
292
     *
293
     * @param string $file The file path to resolve.
294
     *
295
     * @return string
296
     */
297 13
    protected function resolveCachePath(string $file): string
298
    {
299 13
        if (!$this->cache) {
300 2
            return Path::normalize($this->cacheDirectory, md5('temporary'), '.tmp');
301
        }
302
303 11
        $templatePath = strtr($file, [$this->templatesDirectory => '', $this->templatesFileDirectory => '']);
304 11
        $templateName = Misc::transform($templatePath, 'snake');
305 11
        $cacheName    = $templateName . '_' . md5($file);
306
307 11
        return Path::normalize($this->cacheDirectory, $cacheName, '.php');
308
    }
309
310
    /**
311
     * Imports template dependencies.
312
     *
313
     * @param string $file The template file.
314
     *
315
     * @return string
316
     *
317
     * @throws \Exception If file does not exist.
318
     */
319 12
    final protected function importDependencies(string $file): string
320
    {
321 12
        $this->assertFileExists($file);
322
323 12
        $code = file_get_contents($file);
324
325 12
        $count = preg_match_all(
326 12
            static::REGEX['dependency'],
327
            $code,
328
            $matches,
329 12
            PREG_SET_ORDER
330 12
        ) ?: 0;
331
332 12
        for ($i = 0; $i < $count; $i++) {
333 2
            $match = $matches[$i][0];
334 2
            $type  = $matches[$i][1];
335 2
            $path  = $matches[$i][2];
336 2
            $path  = $this->resolvePath($path);
337
338 2
            $startComment = sprintf('<!-- %s::(\'%s\') [START] -->', $type, $path) . PHP_EOL;
339 2
            $endComment   = sprintf('<!-- %s::(\'%s\') [END] -->', $type, $path) . PHP_EOL;
340 2
            $content      = $this->importDependencies($path);
341 2
            $requirement  = vsprintf('%s%s%s', $this->isDebug()
342 1
                ? [$startComment, $content, $endComment]
343 2
                : ['', $content, '']
344
            );
345
346 2
            $code = str_replace($match, $requirement, $code);
347
        }
348
349 12
        $code = preg_replace(static::REGEX['dependency'], '', $code);
350
351 12
        return $code;
352
    }
353
354
    /**
355
     * Imports template includes.
356
     *
357
     * @param string $code The template code.
358
     *
359
     * @return string
360
     */
361 12
    final protected function importIncludes(string $code): string
362
    {
363 12
        $count = preg_match_all(
364 12
            static::REGEX['include'],
365
            $code,
366
            $matches,
367 12
            PREG_SET_ORDER
368 12
        ) ?: 0;
369
370 12
        for ($i = 0; $i < $count; $i++) {
371 6
            $match = $matches[$i][0];
372 6
            $path  = $matches[$i][1];
373
374 6
            $startComment = sprintf('<!-- include::(\'%s\') [START] -->', $path) . PHP_EOL;
375 6
            $endComment   = sprintf('<!-- include::(\'%s\') [END] -->', $path) . PHP_EOL;
376 6
            $content      = $this->getEvaluatedContent($path);
377 6
            $requirement  = vsprintf('%s%s%s', $this->isDebug()
378 1
                ? [$startComment, $content, $endComment]
379 6
                : ['', $content, '']
380
            );
381
382 6
            $code = str_replace($match, $requirement, $code);
383
        }
384
385 12
        $code = preg_replace(static::REGEX['include'], '', $code);
386
387 12
        return $code;
388
    }
389
390
    /**
391
     * Parses a template block, extract data from it, and updates class internal state.
392
     *
393
     * @param string $code The template block.
394
     *
395
     * @return string
396
     */
397 6
    final protected function parseBlock(string $code): string
398
    {
399 6
        preg_match(static::REGEX['block'], $code, $matches);
400 6
        $name  = $matches[1];
401 6
        $value = $matches[2];
402
403 6
        $comment = '';
404
405 6
        if (preg_match(static::REGEX['block.super'], $value)) {
406 2
            $value = preg_replace(
407 2
                static::REGEX['block.super'],
408 2
                sprintf('{! @block(%s) !}', $name . 'Super'),
409
                $value
410
            );
411
        }
412
413 6
        if (isset($this->blocks[$name])) {
414 2
            $this->blocks[$name . 'Super'] = $value;
415
416 2
            $comment = sprintf('<!-- block::(\'%s\') [INHERIT] -->', $name);
417
        } else {
418 6
            $this->blocks[$name] = $value;
419
420 6
            $comment = sprintf('<!-- block::(\'%s\') [ASSIGN] -->', $name);
421
        }
422
423
424 6
        return $this->isDebug() ? $comment : '';
425
    }
426
427
    /**
428
     * Extract blocks data from template code.
429
     *
430
     * @param string $code
431
     *
432
     * @return string
433
     */
434 12
    final protected function extractBlocks(string $code): string
435
    {
436 12
        $opening = '{! @block pseudo-' . md5($code) . ' !}';
437 12
        $closing = '{! @endblock !}';
438 12
        $code    = sprintf('%s%s%s', $opening, $code, $closing);
439
440 12
        while (preg_match(static::REGEX['block.child'], $code, $matches)) {
441 6
            $block = $matches[1];
442
443 6
            $code = str_replace($block, $this->parseBlock($block), $code);
444
        }
445
446 12
        $code = str_replace([$opening, $closing], ['', ''], $code);
447
448 12
        return $code;
449
    }
450
451
    /**
452
     * Injects blocks data in template code.
453
     *
454
     * @param string $code
455
     *
456
     * @return string
457
     */
458 12
    final protected function injectBlocks(string $code): string
459
    {
460 12
        while (preg_match(static::REGEX['block.print'], $code, $matches)) {
461 6
            $match = $matches[0];
462 6
            $block = $matches[1];
463
464 6
            $requirement = $this->blocks[$block] ?? '';
465
466 6
            if ($this->isDebug()) {
467 1
                $startComment     = sprintf('<!-- print::(\'%s\') [START] -->', $block) . PHP_EOL;
468 1
                $endComment       = sprintf('<!-- print::(\'%s\') [END] -->', $block) . PHP_EOL;
469 1
                $undefinedComment = sprintf('<!-- print::(\'$1\') [UNDEFINED] -->', $block);
470 1
                $requirement      = vsprintf('%s%s%s', isset($this->blocks[$block])
471 1
                    ? [$startComment, $requirement, $endComment]
472 1
                    : ['', $undefinedComment, '']
473
                );
474
            }
475
476 6
            $code = str_replace($match, $requirement, $code);
477
        }
478
479 12
        $this->blocks = [];
480
481 12
        return $code;
482
    }
483
484
    /**
485
     * Echos unescaped variables in template code.
486
     *
487
     * @param string $code
488
     *
489
     * @return string
490
     */
491 12
    final protected function printUnescapedVariables(string $code): string
492
    {
493 12
        $comment = $this->isDebug() ? '<!-- unescapedVariable::(\'$1\') [ECHO] -->' : '';
494
495 12
        return preg_replace(
496 12
            static::REGEX['print.unescaped'],
497 12
            $comment . '<?php echo $1; ?>',
498
            $code
499
        );
500
    }
501
502
    /**
503
     * Echos escaped variables in template code.
504
     *
505
     * @param string $code
506
     *
507
     * @return string
508
     */
509 12
    final protected function printVariables(string $code): string
510
    {
511 12
        $comment = $this->isDebug() ? '<!-- escapedVariable::(\'$1\') [ECHO] -->' : '';
512
513 12
        return preg_replace(
514 12
            static::REGEX['print'],
515 12
            $comment . '<?php echo htmlentities((string)$1, ENT_QUOTES, \'UTF-8\'); ?>',
516
            $code
517
        );
518
    }
519
520
    /**
521
     * Wraps PHP in template code.
522
     *
523
     * @param string $code
524
     *
525
     * @return string
526
     */
527 12
    final protected function wrapPhp(string $code): string
528
    {
529 12
        $count = preg_match_all(
530 12
            static::REGEX['php'],
531
            $code,
532
            $matches,
533 12
            PREG_SET_ORDER
534 12
        ) ?: 0;
535
536 12
        for ($i = 0; $i < $count; $i++) {
537 2
            $match = trim($matches[$i][0]);
538 2
            $php   = trim($matches[$i][1]);
539
540 2
            $php = $this->wrapControlStructures($php);
541
542 2
            $comment     = sprintf('<!-- php::(\'%s\') [PHP] -->', $php) . PHP_EOL;
543 2
            $content     = sprintf('<?php %s ?>', $php);
544 2
            $requirement = vsprintf('%s%s', $this->isDebug()
545 1
                ? [$comment, $content]
546 2
                : ['', $content]
547
            );
548
549 2
            $code = str_replace($match, $requirement, $code);
550
        }
551
552 12
        return $code;
553
    }
554
555
    /**
556
     * Wraps control structures and PHP code in template code.
557
     *
558
     * @param string $code
559
     *
560
     * @return string
561
     */
562 2
    final protected function wrapControlStructures(string $code): string
563
    {
564
        // if code starts with an opening control structure
565 2
        if (preg_match(static::REGEX['controlStructure.start'], $code)) {
566
            // check if it ends with ':' and add ':' if it does not
567 2
            if (substr($code, 0, 1) !== ':') {
568 2
                $code = ltrim($code, '@ ') . ':';
569
            }
570
        // if code ends with a closing control structure or anything else
571
        } else {
572
            // check if it ends ';' and add ';' if it does not
573 2
            if (substr($code, -1) !== ';') {
574 2
                $code = ltrim($code, '@ ') . ';';
575
            }
576
        }
577
578 2
        return $code;
579
    }
580
581
    /**
582
     * Wraps comments in template code.
583
     *
584
     * @param string $code
585
     *
586
     * @return string
587
     */
588 12
    final protected function wrapComments(string $code): string
589
    {
590 12
        $comment = $this->isDebug() ? '<!-- comment::(\'$1\') [COMMENT] -->' : '';
591
592 12
        return preg_replace(
593 12
            static::REGEX['comment'],
594 12
            $comment . '<?php /* $1 */ ?>',
595
            $code
596
        );
597
    }
598
599
    /**
600
     * Get the value of `static::$debug`.
601
     *
602
     * @return bool
603
     */
604 12
    public function isDebug(): bool
605
    {
606 12
        return self::$debug;
607
    }
608
609
    /**
610
     * Set the value of `static::$debug`
611
     *
612
     * @return $this
613
     */
614 8
    public function setDebug(bool $debug)
615
    {
616 8
        self::$debug = $debug;
617
618 8
        return $this;
619
    }
620
}
621