Passed
Push — master ( 9d4b70...966dd2 )
by Marwan
04:50 queued 03:21
created

View::resolvePath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 5
c 1
b 0
f 0
nc 1
nop 3
dl 0
loc 8
ccs 4
cts 4
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\App;
15
use MAKS\Velox\Backend\Event;
16
use MAKS\Velox\Backend\Config;
17
use MAKS\Velox\Frontend\Engine;
18
use MAKS\Velox\Frontend\HTML;
19
use MAKS\Velox\Frontend\Path;
20
use MAKS\Velox\Helper\Misc;
21
22
/**
23
 * A class that renders view files (Layouts, Pages, and Partials) with the ability to include additional files and divide page content into sections and cache rendered views.
24
 *
25
 * Example:
26
 * ```
27
 * // render a view
28
 * $html = View::render('somePage', ['someVar' => 'someValue'], 'someLayout');
29
 *
30
 * // render a view, cache it and get it from cache on subsequent calls
31
 * $html = View::cache('somePage', ['someVar' => 'someValue'], 'someLayout');
32
 *
33
 * // delete cached views
34
 * View::clearCache();
35
 *
36
 * // set section value
37
 * View::section('name', $content);
38
 *
39
 * // start capturing section content
40
 * View::sectionStart('name');
41
 *
42
 * // end capturing section content
43
 * View::sectionEnd();
44
 *
45
 * // reset (empty) section content
46
 * View::sectionReset('name');
47
 *
48
 * // get section content
49
 * View::yield('name', 'fallback');
50
 *
51
 * // include a file
52
 * View::include('path/to/a/file');
53
 *
54
 * // render a layout from theme layouts
55
 * $html = View::layout('layoutName', $vars);
56
 *
57
 * // render a page from theme pages
58
 * $html = View::page('pageName', $vars);
59
 *
60
 * // render a partial from theme partials
61
 * $html = View::partial('partialName', $vars);
62
 * ```
63
 *
64
 * @since 1.0.0
65
 * @api
66
 */
67
class View
68
{
69
    /**
70
     * This event will be dispatched before rendering a view.
71
     * This event will be passed a reference to the array that will be passed to the view as variables.
72
     *
73
     * @var string
74
     */
75
    public const BEFORE_RENDER = 'view.before.render';
76
77
    /**
78
     * This event will be dispatched when a view is cached.
79
     * This event will not be passed any arguments.
80
     *
81
     * @var string
82
     */
83
    public const ON_CACHE = 'view.on.cache';
84
85
    /**
86
     * This event will be dispatched when views cache is cleared.
87
     * This event will not be passed any arguments.
88
     *
89
     * @var string
90
     */
91
    public const ON_CACHE_CLEAR = 'view.on.cacheClear';
92
93
94
    /**
95
     * The default values of class parameters.
96
     *
97
     * @var array
98
     */
99
    public const DEFAULTS = [
100
        'name' => '__default__',
101
        'variables' => [],
102
        'fileExtension' => '.phtml',
103
        'inherit' => true,
104
        'minify' => true,
105
        'cache' => false,
106
        'cacheExclude' => ['__default__'],
107
        'cacheAsIndex' => false,
108
        'cacheWithTimestamp' => true,
109
        'engine' => [
110
            'enabled' => true,
111
            'cache'   => true,
112
            'debug'   => false,
113
        ]
114
    ];
115
116
117
    /**
118
     * Sections buffer.
119
     */
120
    protected static array $sections = [];
121
122
    /**
123
     * Sections stack.
124
     */
125
    protected static array $stack = [];
126
127
128
    /**
129
     * Pushes content to the buffer of the section with the given name.
130
     * Note that a section will not be rendered unless it's yielded.
131
     *
132
     * @param string $name The name of the section.
133
     * @param string $content The content of the section.
134
     *
135
     * @return void
136
     */
137 6
    public static function section(string $name, string $content): void
138
    {
139 6
        if (!isset(static::$sections[$name])) {
140 4
            static::$sections[$name] = [];
141
        }
142
143 6
        static::$sections[$name][] = $content;
144 6
    }
145
146
    /**
147
     * Resets (empties) the buffer of the section with the given name.
148
     *
149
     * @param string $name The name of the section.
150
     *
151
     * @return void
152
     */
153 3
    public static function sectionReset(string $name): void
154
    {
155 3
        unset(static::$sections[$name]);
156 3
    }
157
158
    /**
159
     * Starts capturing buffer of the section with the given name. Works in conjunction with `self::sectionEnd()`.
160
     * Note that a section will not be rendered unless it's yielded.
161
     *
162
     * @param string $name The name of the section.
163
     *
164
     * @return void
165
     */
166 1
    public static function sectionStart(string $name): void
167
    {
168 1
        if (!isset(static::$sections[$name])) {
169 1
            static::$sections[$name] = [];
170
        }
171
172 1
        array_push(static::$stack, $name);
173
174 1
        ob_start();
175 1
    }
176
177
    /**
178
     * Ends capturing buffer of the section with the given name. Works in conjunction with `self::sectionStart()`.
179
     * Note that a section will not be rendered unless it's yielded.
180
     *
181
     * @return void
182
     *
183
     * @throws \Exception If no section has been started.
184
     */
185 1
    public static function sectionEnd(): void
186
    {
187 1
        if (!count(static::$stack) || !ob_get_level()) {
188 1
            $variables = ['class', 'function', 'file', 'line'];
189 1
            $backtrace = Misc::backtrace($variables, 1);
190 1
            $backtrace = is_array($backtrace) ? $backtrace : array_map('strtoupper', $variables);
191
192 1
            throw new \Exception(
193 1
                vsprintf('Not in a context to end a section! Call to %s::%s() in %s on line %s is superfluous', $backtrace)
194
            );
195
        }
196
197 1
        $buffer = ob_get_clean();
198
199 1
        $name = array_pop(static::$stack);
200
201 1
        static::$sections[$name][] = $buffer ?: '';
202 1
    }
203
204
    /**
205
     * Returns content of the section with the given name.
206
     *
207
     * @param string $name The name of the section.
208
     * @param string $default [optional] The default value to yield if the section has no content or is an empty string.
209
     *
210
     * @return string
211
     */
212 4
    public static function yield(string $name, string $default = ''): string
213
    {
214 4
        $section = '';
215
216 4
        if (isset(static::$sections[$name])) {
217 3
            foreach (static::$sections[$name] as $buffer) {
218
                // buffers are added in reverse order
219 3
                $section = $buffer . $section;
220
            }
221
222 3
            static::sectionReset($name);
223
        }
224
225 4
        return strlen(trim($section)) ? $section : $default;
226
    }
227
228
    /**
229
     * Includes a file from the active theme directory.
230
     * Can also be used as a mean of extending a layout if it was put at the end of it.
231
     *
232
     * @param string $file The path of the file starting from theme root.
233
     * @param array|null $variables [optional] An associative array of the variables to pass.
234
     *
235
     * @return void
236
     */
237 1
    public static function include(string $file, ?array $variables = null): void
238
    {
239 1
        $path = Config::get('theme.paths.root');
240
241 1
        $include = self::resolvePath($path, $file);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type null; however, parameter $directory of MAKS\Velox\Frontend\View::resolvePath() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

241
        $include = self::resolvePath(/** @scrutinizer ignore-type */ $path, $file);
Loading history...
242
243 1
        self::require($include, $variables);
244 1
    }
245
246
    /**
247
     * Renders a theme layout with the passed variables.
248
     *
249
     * @param string $name The name of the layout.
250
     * @param array $variables An associative array of the variables to pass.
251
     *
252
     * @return string
253
     */
254 6
    public static function layout(string $name, array $variables = []): string
255
    {
256 6
        $path = Config::get('theme.paths.layouts');
257
258 6
        $variables['defaultLayoutVars'] = Config::get('view.defaultLayoutVars');
259
260 6
        $layout = self::resolvePath($path, $name);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type null; however, parameter $directory of MAKS\Velox\Frontend\View::resolvePath() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

260
        $layout = self::resolvePath(/** @scrutinizer ignore-type */ $path, $name);
Loading history...
261
262 6
        return static::compile($layout, __FUNCTION__, $variables);
263
    }
264
265
    /**
266
     * Renders a theme page with the passed variables.
267
     *
268
     * @param string $name The name of the page.
269
     * @param array $variables An associative array of the variables to pass.
270
     *
271
     * @return string
272
     */
273 6
    public static function page(string $name, array $variables = []): string
274
    {
275 6
        $path = Config::get('theme.paths.pages');
276
277 6
        $variables['defaultPageVars'] = Config::get('view.defaultPageVars');
278
279 6
        $page = self::resolvePath($path, $name);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type null; however, parameter $directory of MAKS\Velox\Frontend\View::resolvePath() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

279
        $page = self::resolvePath(/** @scrutinizer ignore-type */ $path, $name);
Loading history...
280
281 6
        return static::compile($page, __FUNCTION__, $variables);
282
    }
283
284
    /**
285
     * Renders a theme partial with the passed variables.
286
     *
287
     * @param string $name The name of the partial.
288
     * @param array $variables An associative array of the variables to pass.
289
     *
290
     * @return string
291
     */
292 4
    public static function partial(string $name, array $variables = []): string
293
    {
294 4
        $path = Config::get('theme.paths.partials');
295
296 4
        $variables['defaultPartialVars'] = Config::get('view.defaultPartialVars');
297
298 4
        $partial = self::resolvePath($path, $name);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type null; however, parameter $directory of MAKS\Velox\Frontend\View::resolvePath() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

298
        $partial = self::resolvePath(/** @scrutinizer ignore-type */ $path, $name);
Loading history...
299
300 4
        return static::compile($partial, __FUNCTION__, $variables);
301
    }
302
303
    /**
304
     * Renders a view (a Page wrapped in a Layout) with the passed variables, the Page content will be sent to "{view.defaultSectionName}" section.
305
     *
306
     * @param string $page The name of the page.
307
     * @param array $variables [optional] An associative array of the variables to pass.
308
     * @param string|null $layout [optional] The name of the Layout to use.
309
     *
310
     * @return string
311
     */
312 5
    public static function render(string $page, array $variables = [], ?string $layout = null): string
313
    {
314 5
        $viewConfig = Config::get('view');
315 5
        $layout     = $layout ?? $viewConfig['defaultLayoutName'];
316 5
        $section    = $viewConfig['defaultSectionName'];
317 5
        $minify     = $viewConfig['minify'];
318 5
        $cache      = $viewConfig['cache'];
319
320 5
        if ($cache) {
321 1
            return static::cache($page, $variables, $layout);
322
        }
323
324 5
        Event::dispatch(self::BEFORE_RENDER, [&$variables]);
325
326 5
        static::section($section, static::page($page, $variables));
327
328 5
        $view = static::layout($layout, $variables);
329
330 5
        return $minify ? HTML::minify($view) : $view;
331
    }
332
333
    /**
334
     * Renders a view with the passed variables and cache it as HTML, subsequent calls to this function will return the cached version.
335
     * This function is exactly like `self::render()` but with caching capabilities.
336
     *
337
     * @param string $page The name of the page.
338
     * @param array $variables [optional] An associative array of the variables to pass.
339
     * @param string|null $layout [optional] The name of the Layout to use.
340
     *
341
     * @return string
342
     */
343 1
    public static function cache(string $page, array $variables = [], ?string $layout = null)
344
    {
345 1
        $viewConfig         = Config::get('view');
346 1
        $cacheEnabled       = $viewConfig['cache'];
347 1
        $cacheExclude       = $viewConfig['cacheExclude'];
348 1
        $cacheAsIndex       = $viewConfig['cacheAsIndex'];
349 1
        $cacheWithTimestamp = $viewConfig['cacheWithTimestamp'];
350 1
        $cacheDir           = Config::get('global.paths.storage') . '/cache/views/';
351
352 1
        if (!file_exists($cacheDir)) {
353 1
            mkdir($cacheDir, 0744, true);
354
        }
355
356 1
        $cacheFile          = static::resolveCachePath($page);
357 1
        $cacheFileDirectory = dirname($cacheFile);
358 1
        $fileExists         = file_exists($cacheFile);
359
360 1
        $content = null;
361
362 1
        if (!$cacheEnabled || !$fileExists) {
363 1
            Config::set('view.cache', false);
364 1
            $view = static::render($page, $variables, $layout);
365 1
            Config::set('view.cache', true);
366
367 1
            if (in_array($page, (array)$cacheExclude)) {
368 1
                return $view;
369
            }
370
371 1
            if ($cacheAsIndex) {
372 1
                if (!file_exists($cacheFileDirectory)) {
373 1
                    mkdir($cacheFileDirectory, 0744, true);
374
                }
375
            } else {
376 1
                $cacheFile = preg_replace('/\/+|\\+/', '___', $page);
377 1
                $cacheFile = static::resolvePath($cacheDir, $cacheFile, '.html');
378
            }
379
380 1
            $comment = '';
381 1
            if ($cacheWithTimestamp) {
382 1
                $timestamp = date('l jS \of F Y h:i:s A (Ymdhis)');
383 1
                $comment   = sprintf('<!-- [CACHE] Generated on %s -->', $timestamp);
384
            }
385
386 1
            $content = preg_replace(
387 1
                '/<!DOCTYPE html>/i',
388 1
                '$0' . $comment,
389
                $view
390
            );
391
392 1
            file_put_contents($cacheFile, $content, LOCK_EX);
393
394 1
            Event::dispatch(self::ON_CACHE);
395
396 1
            App::log('Generated cache for the "{page}" page', ['page' => $page], 'system');
397
        }
398
399 1
        $content = $content ?? file_get_contents($cacheFile);
400
401 1
        return $content;
402
    }
403
404
    /**
405
     * Deletes all cached views generated by `self::cache()`.
406
     *
407
     * @return void
408
     */
409 1
    public static function clearCache(): void
410
    {
411 1
        $clear = static function ($path) use (&$clear) {
412 1
            static $base = null;
413 1
            if (!$base) {
414 1
                $base = $path;
415
            }
416
417 1
            $items = glob($path . '/*');
418 1
            foreach ($items as $item) {
419 1
                is_dir($item) ? $clear($item) : unlink($item);
420
            }
421
422 1
            if ($path !== $base) {
423 1
                file_exists($path) && rmdir($path);
424
            }
425 1
        };
426
427 1
        $clear(Config::get('global.paths.storage') . '/cache/views/');
428 1
        $clear(Config::get('global.paths.storage') . '/temp/views/');
429
430 1
        Event::dispatch(self::ON_CACHE_CLEAR);
431
432 1
        App::log('Cleared views cache', null, 'system');
433 1
    }
434
435
    /**
436
     * Compiles a PHP file with the passed variables.
437
     *
438
     * @param string $file An absolute path to the file that should be compiled.
439
     * @param string $type The type of the file (just a name to make for friendly exceptions).
440
     * @param array|null [optional] An associative array of the variables to pass.
441
     *
442
     * @return string
443
     *
444
     * @throws \Exception If failed to compile the file.
445
     */
446 8
    protected static function compile(string $file, string $type, ?array $variables = null): string
447
    {
448 8
        ob_start();
449
450
        try {
451 8
            self::require($file, $variables);
452 3
        } catch (\Exception $error) {
453
            // clean started buffer before throwing the exception
454 3
            ob_end_clean();
455
456 3
            throw $error;
457
        }
458
459 8
        $buffer = ob_get_contents();
460 8
        ob_end_clean();
461
462 8
        if ($buffer === false) {
463
            $name = basename($file, Config::get('view.fileExtension'));
0 ignored issues
show
Bug introduced by
It seems like MAKS\Velox\Backend\Confi...t('view.fileExtension') can also be of type null; however, parameter $suffix of basename() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

463
            $name = basename($file, /** @scrutinizer ignore-type */ Config::get('view.fileExtension'));
Loading history...
464
            throw new \Exception("Something went wrong when trying to compile the {$type} with the name '{$name}' in {$file}");
465
        }
466
467 8
        return trim($buffer);
468
    }
469
470
    /**
471
     * Requires a PHP file and pass it the passed variables.
472
     *
473
     * @param string $file An absolute path to the file that should be compiled.
474
     * @param array|null $variables [optional] An associative array of the variables to pass.
475
     *
476
     * @return void
477
     *
478
     * @throws \Exception If the file could not be loaded.
479
     */
480 9
    private static function require(string $file, ?array $variables = null): void
481
    {
482 9
        $file = self::findOrInherit($file);
483
484 9
        if (!file_exists($file)) {
485 4
            throw new \Exception(
486 4
                "Could not load the file with the path '{$file}' nor fall back to a parent. Check if the file exists!"
487
            );
488
        }
489
490 9
        $_file = static::parse($file);
491 9
        unset($file);
492
493 9
        if ($variables !== null) {
494 8
            extract($variables, EXTR_OVERWRITE);
495 8
            unset($variables);
496
        }
497
498 9
        require($_file);
499 9
        unset($_file);
500 9
    }
501
502
    /**
503
     * Parses a file through the templating engine and returns a path to the compiled file.
504
     *
505
     * @param string $file The file to parse.
506
     *
507
     * @return string
508
     */
509 9
    private static function parse(string $file): string
510
    {
511 9
        if (!Config::get('view.engine.enabled', true)) {
512 1
            return $file;
513
        }
514
515 8
        static $engine = null;
516
517 8
        if ($engine === null) {
518 1
            $engine = new Engine(
519 1
                Config::get('global.paths.themes') . '/',
520 1
                Config::get('view.fileExtension') ?? self::DEFAULTS['fileExtension'],
521 1
                Config::get('global.paths.storage') . '/temp/views/',
522 1
                Config::get('view.engine.cache') ?? self::DEFAULTS['engine']['cache'],
523 1
                Config::get('view.engine.debug') ?? self::DEFAULTS['engine']['debug']
524
            );
525
        }
526
527 8
        $file = $engine->getCompiledFile(strtr($file, [
528 8
            Path::normalize(Config::get('global.paths.themes'), '') => ''
0 ignored issues
show
Bug introduced by
It seems like MAKS\Velox\Backend\Confi...('global.paths.themes') can also be of type null; however, parameter $directory of MAKS\Velox\Frontend\Path::normalize() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

528
            Path::normalize(/** @scrutinizer ignore-type */ Config::get('global.paths.themes'), '') => ''
Loading history...
529
        ]));
530
531 8
        return $file;
532
    }
533
534
    /**
535
     * Finds a file in the active theme or inherit it from parent theme.
536
     *
537
     * @param string $file
538
     *
539
     * @return string
540
     */
541 9
    private static function findOrInherit(string $file): string
542
    {
543 9
        if (file_exists($file)) {
544 3
            return $file;
545
        }
546
547 7
        if (Config::get('view.inherit')) {
548 7
            $active = Config::get('theme.active');
549 7
            $parent = Config::get('theme.parent');
550 7
            $themes = Config::get('global.paths.themes');
551 7
            $nameWrapper = basename($themes) . DIRECTORY_SEPARATOR . '%s';
0 ignored issues
show
Bug introduced by
It seems like $themes can also be of type null; however, parameter $path of basename() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

551
            $nameWrapper = basename(/** @scrutinizer ignore-type */ $themes) . DIRECTORY_SEPARATOR . '%s';
Loading history...
552
553 7
            foreach ((array)$parent as $substitute) {
554 7
                $fallbackFile = strtr($file, [
555 7
                    sprintf($nameWrapper, $active) => sprintf($nameWrapper, $substitute)
556
                ]);
557
558 7
                if (file_exists($fallbackFile)) {
559 7
                    $file = $fallbackFile;
560 7
                    break;
561
                }
562
            }
563
        }
564
565 7
        return $file;
566
    }
567
568
    /**
569
     * Returns a normalized path of a page from the cache directory.
570
     *
571
     * @param string $pageName
572
     *
573
     * @return string
574
     */
575 1
    private static function resolveCachePath(string $pageName): string
576
    {
577 1
        static $cacheDir = null;
578
579 1
        if ($cacheDir === null) {
580 1
            $cacheDir = Config::get('global.paths.storage') . '/cache/views/';
581
        }
582
583 1
        $cacheName = sprintf(
584 1
            '%s/%s',
585
            $pageName,
586 1
            'index'
587
        );
588
589 1
        return static::resolvePath($cacheDir, $cacheName, '.html');
590
    }
591
592
    /**
593
     * Returns a normalized path to a file based on OS.
594
     *
595
     * @param string $directory
596
     * @param string $filename
597
     * @param string|null $extension
598
     *
599
     * @return string
600
     */
601 9
    private static function resolvePath(string $directory, string $filename, ?string $extension = null): string
602
    {
603 9
        $extension = $extension ?? Config::get('view.fileExtension') ?? self::DEFAULTS['fileExtension'];
604
605 9
        return Path::normalize(
606 9
            $directory,
607
            $filename,
608
            $extension
609
        );
610
    }
611
}
612