Passed
Push — master ( 6923e5...88cee3 )
by Marwan
02:08
created

Engine::importIncludes()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 27
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 4

Importance

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