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

View   B

Complexity

Total Complexity 52

Size/Duplication

Total Lines 517
Duplicated Lines 0 %

Test Coverage

Coverage 98.9%

Importance

Changes 9
Bugs 0 Features 0
Metric Value
eloc 182
c 9
b 0
f 0
dl 0
loc 517
ccs 180
cts 182
cp 0.989
rs 7.44
wmc 52

18 Methods

Rating   Name   Duplication   Size   Complexity  
A sectionEnd() 0 17 5
A yield() 0 14 4
A section() 0 7 2
A sectionReset() 0 3 1
A sectionStart() 0 9 2
A layout() 0 9 1
A partial() 0 9 1
A render() 0 19 3
B cache() 0 59 8
A page() 0 9 1
A compile() 0 22 3
A findOrInherit() 0 25 5
A resolveCachePath() 0 15 2
A parse() 0 23 3
A clearCache() 0 24 6
A require() 0 20 3
A include() 0 7 1
A resolvePath() 0 8 1

How to fix   Complexity   

Complex Class

Complex classes like View often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use View, and based on these observations, apply Extract Interface, too.

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
     * The default values of class parameters.
71
     *
72
     * @var array
73
     */
74
    public const DEFAULTS = [
75
        'name' => '__default__',
76
        'variables' => [],
77
        'fileExtension' => '.phtml',
78
        'inherit' => true,
79
        'minify' => true,
80
        'cache' => false,
81
        'cacheExclude' => ['__default__'],
82
        'cacheAsIndex' => false,
83
        'cacheWithTimestamp' => true,
84
        'engine' => [
85
            'enabled' => true,
86
            'cache'   => true,
87
            'debug'   => false,
88
        ]
89
    ];
90
91
92
    /**
93
     * Sections buffer.
94
     */
95
    protected static array $sections = [];
96
97
    /**
98
     * Sections stack.
99
     */
100
    protected static array $stack = [];
101
102
103
    /**
104
     * Pushes content to the buffer of the section with the given name.
105
     * Note that a section will not be rendered unless it's yielded.
106
     *
107
     * @param string $name The name of the section.
108
     * @param string $content The content of the section.
109
     *
110
     * @return void
111
     */
112 6
    public static function section(string $name, string $content): void
113
    {
114 6
        if (!isset(static::$sections[$name])) {
115 4
            static::$sections[$name] = [];
116
        }
117
118 6
        static::$sections[$name][] = $content;
119 6
    }
120
121
    /**
122
     * Resets (empties) the buffer of the section with the given name.
123
     *
124
     * @param string $name The name of the section.
125
     *
126
     * @return void
127
     */
128 3
    public static function sectionReset(string $name): void
129
    {
130 3
        unset(static::$sections[$name]);
131 3
    }
132
133
    /**
134
     * Starts capturing buffer of the section with the given name. Works in conjunction with `self::sectionEnd()`.
135
     * Note that a section will not be rendered unless it's yielded.
136
     *
137
     * @param string $name The name of the section.
138
     *
139
     * @return void
140
     */
141 1
    public static function sectionStart(string $name): void
142
    {
143 1
        if (!isset(static::$sections[$name])) {
144 1
            static::$sections[$name] = [];
145
        }
146
147 1
        array_push(static::$stack, $name);
148
149 1
        ob_start();
150 1
    }
151
152
    /**
153
     * Ends capturing buffer of the section with the given name. Works in conjunction with `self::sectionStart()`.
154
     * Note that a section will not be rendered unless it's yielded.
155
     *
156
     * @return void
157
     *
158
     * @throws \Exception If no section has been started.
159
     */
160 1
    public static function sectionEnd(): void
161
    {
162 1
        if (!count(static::$stack) || !ob_get_level()) {
163 1
            $variables = ['class', 'function', 'file', 'line'];
164 1
            $backtrace = Misc::backtrace($variables, 1);
165 1
            $backtrace = is_array($backtrace) ? $backtrace : array_map('strtoupper', $variables);
166
167 1
            throw new \Exception(
168 1
                vsprintf('Not in a context to end a section! Call to %s::%s() in %s on line %s is superfluous', $backtrace)
169
            );
170
        }
171
172 1
        $buffer = ob_get_clean();
173
174 1
        $name = array_pop(static::$stack);
175
176 1
        static::$sections[$name][] = $buffer ?: '';
177 1
    }
178
179
    /**
180
     * Returns content of the section with the given name.
181
     *
182
     * @param string $name The name of the section.
183
     * @param string $default [optional] The default value to yield if the section has no content or is an empty string.
184
     *
185
     * @return string
186
     */
187 4
    public static function yield(string $name, string $default = ''): string
188
    {
189 4
        $section = '';
190
191 4
        if (isset(static::$sections[$name])) {
192 3
            foreach (static::$sections[$name] as $buffer) {
193
                // buffers are added in reverse order
194 3
                $section = $buffer . $section;
195
            }
196
197 3
            static::sectionReset($name);
198
        }
199
200 4
        return strlen(trim($section)) ? $section : $default;
201
    }
202
203
    /**
204
     * Includes a file from the active theme directory.
205
     * Can also be used as a mean of extending a layout if it was put at the end of it.
206
     *
207
     * @param string $file The path of the file starting from theme root.
208
     * @param array|null $variables [optional] An associative array of the variables to pass.
209
     *
210
     * @return void
211
     */
212 1
    public static function include(string $file, ?array $variables = null): void
213
    {
214 1
        $path = Config::get('theme.paths.root');
215
216 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

216
        $include = self::resolvePath(/** @scrutinizer ignore-type */ $path, $file);
Loading history...
217
218 1
        self::require($include, $variables);
219 1
    }
220
221
    /**
222
     * Renders a theme layout with the passed variables.
223
     *
224
     * @param string $name The name of the layout.
225
     * @param array $variables An associative array of the variables to pass.
226
     *
227
     * @return string
228
     */
229 6
    public static function layout(string $name, array $variables = []): string
230
    {
231 6
        $path = Config::get('theme.paths.layouts');
232
233 6
        $variables['defaultLayoutVars'] = Config::get('view.defaultLayoutVars');
234
235 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

235
        $layout = self::resolvePath(/** @scrutinizer ignore-type */ $path, $name);
Loading history...
236
237 6
        return static::compile($layout, __FUNCTION__, $variables);
238
    }
239
240
    /**
241
     * Renders a theme page with the passed variables.
242
     *
243
     * @param string $name The name of the page.
244
     * @param array $variables An associative array of the variables to pass.
245
     *
246
     * @return string
247
     */
248 6
    public static function page(string $name, array $variables = []): string
249
    {
250 6
        $path = Config::get('theme.paths.pages');
251
252 6
        $variables['defaultPageVars'] = Config::get('view.defaultPageVars');
253
254 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

254
        $page = self::resolvePath(/** @scrutinizer ignore-type */ $path, $name);
Loading history...
255
256 6
        return static::compile($page, __FUNCTION__, $variables);
257
    }
258
259
    /**
260
     * Renders a theme partial with the passed variables.
261
     *
262
     * @param string $name The name of the partial.
263
     * @param array $variables An associative array of the variables to pass.
264
     *
265
     * @return string
266
     */
267 4
    public static function partial(string $name, array $variables = []): string
268
    {
269 4
        $path = Config::get('theme.paths.partials');
270
271 4
        $variables['defaultPartialVars'] = Config::get('view.defaultPartialVars');
272
273 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

273
        $partial = self::resolvePath(/** @scrutinizer ignore-type */ $path, $name);
Loading history...
274
275 4
        return static::compile($partial, __FUNCTION__, $variables);
276
    }
277
278
    /**
279
     * Renders a view (a Page wrapped in a Layout) with the passed variables, the Page content will be sent to "{view.defaultSectionName}" section.
280
     *
281
     * @param string $page The name of the page.
282
     * @param array $variables [optional] An associative array of the variables to pass.
283
     * @param string|null $layout [optional] The name of the Layout to use.
284
     *
285
     * @return string
286
     */
287 5
    public static function render(string $page, array $variables = [], ?string $layout = null): string
288
    {
289 5
        $viewConfig = Config::get('view');
290 5
        $layout     = $layout ?? $viewConfig['defaultLayoutName'];
291 5
        $section    = $viewConfig['defaultSectionName'];
292 5
        $minify     = $viewConfig['minify'];
293 5
        $cache      = $viewConfig['cache'];
294
295 5
        if ($cache) {
296 1
            return static::cache($page, $variables, $layout);
297
        }
298
299 5
        Event::dispatch('view.before.render', [&$variables]);
300
301 5
        static::section($section, static::page($page, $variables));
302
303 5
        $view = static::layout($layout, $variables);
304
305 5
        return $minify ? HTML::minify($view) : $view;
306
    }
307
308
    /**
309
     * Renders a view with the passed variables and cache it as HTML, subsequent calls to this function will return the cached version.
310
     * This function is exactly like `self::render()` but with caching capabilities.
311
     *
312
     * @param string $page The name of the page.
313
     * @param array $variables [optional] An associative array of the variables to pass.
314
     * @param string|null $layout [optional] The name of the Layout to use.
315
     *
316
     * @return string
317
     */
318 1
    public static function cache(string $page, array $variables = [], ?string $layout = null)
319
    {
320 1
        $viewConfig         = Config::get('view');
321 1
        $cacheEnabled       = $viewConfig['cache'];
322 1
        $cacheExclude       = $viewConfig['cacheExclude'];
323 1
        $cacheAsIndex       = $viewConfig['cacheAsIndex'];
324 1
        $cacheWithTimestamp = $viewConfig['cacheWithTimestamp'];
325 1
        $cacheDir           = Config::get('global.paths.storage') . '/cache/views/';
326
327 1
        if (!file_exists($cacheDir)) {
328 1
            mkdir($cacheDir, 0744, true);
329
        }
330
331 1
        $cacheFile          = static::resolveCachePath($page);
332 1
        $cacheFileDirectory = dirname($cacheFile);
333 1
        $fileExists         = file_exists($cacheFile);
334
335 1
        $content = null;
336
337 1
        if (!$cacheEnabled || !$fileExists) {
338 1
            Config::set('view.cache', false);
339 1
            $view = static::render($page, $variables, $layout);
340 1
            Config::set('view.cache', true);
341
342 1
            if (in_array($page, (array)$cacheExclude)) {
343 1
                return $view;
344
            }
345
346 1
            if ($cacheAsIndex) {
347 1
                if (!file_exists($cacheFileDirectory)) {
348 1
                    mkdir($cacheFileDirectory, 0744, true);
349
                }
350
            } else {
351 1
                $cacheFile = preg_replace('/\/+|\\+/', '___', $page);
352 1
                $cacheFile = static::resolvePath($cacheDir, $cacheFile, '.html');
353
            }
354
355 1
            $comment = '';
356 1
            if ($cacheWithTimestamp) {
357 1
                $timestamp = date('l jS \of F Y h:i:s A (Ymdhis)');
358 1
                $comment   = sprintf('<!-- [CACHE] Generated on %s -->', $timestamp);
359
            }
360
361 1
            $content = preg_replace(
362 1
                '/<!DOCTYPE html>/i',
363 1
                '$0' . $comment,
364
                $view
365
            );
366
367 1
            file_put_contents($cacheFile, $content, LOCK_EX);
368
369 1
            Event::dispatch('view.on.cache');
370
371 1
            App::log('Generated cache for the "{page}" page', ['page' => $page], 'system');
372
        }
373
374 1
        $content = $content ?? file_get_contents($cacheFile);
375
376 1
        return $content;
377
    }
378
379
    /**
380
     * Deletes all cached views generated by `self::cache()`.
381
     *
382
     * @return void
383
     */
384 1
    public static function clearCache(): void
385
    {
386 1
        $clear = static function ($path) use (&$clear) {
387 1
            static $base = null;
388 1
            if (!$base) {
389 1
                $base = $path;
390
            }
391
392 1
            $items = glob($path . '/*');
393 1
            foreach ($items as $item) {
394 1
                is_dir($item) ? $clear($item) : unlink($item);
395
            }
396
397 1
            if ($path !== $base) {
398 1
                file_exists($path) && rmdir($path);
399
            }
400 1
        };
401
402 1
        $clear(Config::get('global.paths.storage') . '/cache/views/');
403 1
        $clear(Config::get('global.paths.storage') . '/temp/views/');
404
405 1
        Event::dispatch('view.on.cacheClear');
406
407 1
        App::log('Cleared views cache', null, 'system');
408 1
    }
409
410
    /**
411
     * Compiles a PHP file with the passed variables.
412
     *
413
     * @param string $file An absolute path to the file that should be compiled.
414
     * @param string $type The type of the file (just a name to make for friendly exceptions).
415
     * @param array|null [optional] An associative array of the variables to pass.
416
     *
417
     * @return string
418
     *
419
     * @throws \Exception If failed to compile the file.
420
     */
421 8
    protected static function compile(string $file, string $type, ?array $variables = null): string
422
    {
423 8
        ob_start();
424
425
        try {
426 8
            self::require($file, $variables);
427 3
        } catch (\Exception $error) {
428
            // clean started buffer before throwing the exception
429 3
            ob_end_clean();
430
431 3
            throw $error;
432
        }
433
434 8
        $buffer = ob_get_contents();
435 8
        ob_end_clean();
436
437 8
        if ($buffer === false) {
438
            $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

438
            $name = basename($file, /** @scrutinizer ignore-type */ Config::get('view.fileExtension'));
Loading history...
439
            throw new \Exception("Something went wrong when trying to compile the {$type} with the name '{$name}' in {$file}");
440
        }
441
442 8
        return trim($buffer);
443
    }
444
445
    /**
446
     * Requires a PHP file and pass it the passed variables.
447
     *
448
     * @param string $file An absolute path to the file that should be compiled.
449
     * @param array|null $variables [optional] An associative array of the variables to pass.
450
     *
451
     * @return void
452
     *
453
     * @throws \Exception If the file could not be loaded.
454
     */
455 9
    private static function require(string $file, ?array $variables = null): void
456
    {
457 9
        $file = self::findOrInherit($file);
458
459 9
        if (!file_exists($file)) {
460 4
            throw new \Exception(
461 4
                "Could not load the file with the path '{$file}' nor fall back to a parent. Check if the file exists!"
462
            );
463
        }
464
465 9
        $_file = static::parse($file);
466 9
        unset($file);
467
468 9
        if ($variables !== null) {
469 8
            extract($variables, EXTR_OVERWRITE);
470 8
            unset($variables);
471
        }
472
473 9
        require($_file);
474 9
        unset($_file);
475 9
    }
476
477
    /**
478
     * Parses a file through the templating engine and returns a path to the compiled file.
479
     *
480
     * @param string $file The file to parse.
481
     *
482
     * @return string
483
     */
484 9
    private static function parse(string $file): string
485
    {
486 9
        if (!Config::get('view.engine.enabled', true)) {
487 1
            return $file;
488
        }
489
490 8
        static $engine = null;
491
492 8
        if ($engine === null) {
493 1
            $engine = new Engine(
494 1
                Config::get('global.paths.themes') . '/',
495 1
                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 $templatesFileDirectory of MAKS\Velox\Frontend\Engine::__construct() 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

495
                /** @scrutinizer ignore-type */ Config::get('view.fileExtension'),
Loading history...
496 1
                Config::get('global.paths.storage') . '/temp/views/',
497 1
                Config::get('view.engine.cache', self::DEFAULTS['engine']['cache']),
498 1
                Config::get('view.engine.debug', self::DEFAULTS['engine']['debug'])
499
            );
500
        }
501
502 8
        $file = $engine->getCompiledFile(strtr($file, [
503 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

503
            Path::normalize(/** @scrutinizer ignore-type */ Config::get('global.paths.themes'), '') => ''
Loading history...
504
        ]));
505
506 8
        return $file;
507
    }
508
509
    /**
510
     * Finds a file in the active theme or inherit it from parent theme.
511
     *
512
     * @param string $file
513
     *
514
     * @return string
515
     */
516 9
    private static function findOrInherit(string $file): string
517
    {
518 9
        if (file_exists($file)) {
519 3
            return $file;
520
        }
521
522 7
        if (Config::get('view.inherit')) {
523 7
            $active = Config::get('theme.active');
524 7
            $parent = Config::get('theme.parent');
525 7
            $themes = Config::get('global.paths.themes');
526 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

526
            $nameWrapper = basename(/** @scrutinizer ignore-type */ $themes) . DIRECTORY_SEPARATOR . '%s';
Loading history...
527
528 7
            foreach ((array)$parent as $substitute) {
529 7
                $fallbackFile = strtr($file, [
530 7
                    sprintf($nameWrapper, $active) => sprintf($nameWrapper, $substitute)
531
                ]);
532
533 7
                if (file_exists($fallbackFile)) {
534 7
                    $file = $fallbackFile;
535 7
                    break;
536
                }
537
            }
538
        }
539
540 7
        return $file;
541
    }
542
543
    /**
544
     * Returns a normalized path of a page from the cache directory.
545
     *
546
     * @param string $pageName
547
     *
548
     * @return string
549
     */
550 1
    private static function resolveCachePath(string $pageName): string
551
    {
552 1
        static $cacheDir = null;
553
554 1
        if ($cacheDir === null) {
555 1
            $cacheDir = Config::get('global.paths.storage') . '/cache/views/';
556
        }
557
558 1
        $cacheName = sprintf(
559 1
            '%s/%s',
560
            $pageName,
561 1
            'index'
562
        );
563
564 1
        return static::resolvePath($cacheDir, $cacheName, '.html');
565
    }
566
567
    /**
568
     * Returns a normalized path to a file based on OS.
569
     *
570
     * @param string $directory
571
     * @param string $filename
572
     * @param string|null $extension
573
     *
574
     * @return string
575
     */
576 9
    private static function resolvePath(string $directory, string $filename, ?string $extension = null): string
577
    {
578 9
        $extension = $extension ?? Config::get('view.fileExtension') ?? self::DEFAULTS['fileExtension'];
579
580 9
        return Path::normalize(
581 9
            $directory,
582
            $filename,
583
            $extension
584
        );
585
    }
586
}
587