Passed
Push — master ( 28c60c...6c6d7e )
by Marwan
10:15
created

View::page()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

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

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

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

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

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

431
            $name = basename($file, /** @scrutinizer ignore-type */ Config::get('view.fileExtension'));
Loading history...
432
            throw new \Exception("Something went wrong when trying to compile the {$type} with the name '{$name}' in {$file}");
433
        }
434
435 5
        return $buffer;
436
    }
437
438
    /**
439
     * Requires a PHP file and pass it the passed variables.
440
     *
441
     * @param string $file An absolute path to the file that should be compiled.
442
     * @param array|null $variables [optional] An associative array of the variables to pass.
443
     *
444
     * @return void
445
     *
446
     * @throws \Exception If the file could not be loaded.
447
     */
448 6
    private static function require(string $file, ?array $variables = null): void
449
    {
450 6
        $file = self::findOrInherit($file);
451
452 6
        if (!file_exists($file)) {
453 4
            throw new \Exception(
454 4
                "Could not load the file with the path '{$file}' nor fall back to a parent. Check if the file exists!"
455
            );
456
        }
457
458 6
        $_file = $file;
459 6
        unset($file);
460
461 6
        if ($variables !== null) {
462 5
            extract($variables, EXTR_OVERWRITE);
463 5
            unset($variables);
464
        }
465
466 6
        require($_file);
467 6
        unset($_file);
468 6
    }
469
470
    /**
471
     * Finds a file in the active theme or inherit it from parent theme.
472
     *
473
     * @param string $file
474
     *
475
     * @return string
476
     */
477 6
    private static function findOrInherit(string $file): string
478
    {
479 6
        if (file_exists($file)) {
480 1
            return $file;
481
        }
482
483 6
        if (Config::get('view.inherit')) {
484 6
            $active = Config::get('theme.active');
485 6
            $parent = Config::get('theme.parent');
486 6
            $themes = Config::get('global.paths.themes');
487 6
            $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

487
            $nameWrapper = basename(/** @scrutinizer ignore-type */ $themes) . DIRECTORY_SEPARATOR . '%s';
Loading history...
488
489 6
            foreach ((array)$parent as $substitute) {
490 6
                $fallbackFile = strtr($file, [
491 6
                    sprintf($nameWrapper, $active) => sprintf($nameWrapper, $substitute)
492
                ]);
493
494 6
                if (file_exists($fallbackFile)) {
495 6
                    $file = $fallbackFile;
496 6
                    break;
497
                }
498
            }
499
        }
500
501 6
        return $file;
502
    }
503
504
    /**
505
     * Returns a normalized path of a page from the cache directory.
506
     *
507
     * @param string $pageName
508
     *
509
     * @return string
510
     */
511 1
    private static function resolveCachePath(string $pageName): string
512
    {
513 1
        static $cacheDir = null;
514
515 1
        if ($cacheDir === null) {
516 1
            $cacheDir = Config::get('global.paths.storage') . '/cache/views/';
517
        }
518
519 1
        $cacheName = sprintf(
520 1
            '%s/%s',
521
            $pageName,
522 1
            'index'
523
        );
524
525 1
        return static::resolvePath($cacheDir, $cacheName, '.html');
526
    }
527
528
    /**
529
     * Returns a normalized path to a file based on OS.
530
     *
531
     * @param string $directory
532
     * @param string $filename
533
     * @param string|null $extension
534
     *
535
     * @return string
536
     */
537 6
    private static function resolvePath(string $directory, string $filename, ?string $extension = null): string
538
    {
539 6
        $extension = $extension ?? Config::get('view.fileExtension') ?? self::DEFAULTS['fileExtension'];
540
541 6
        return Path::normalize(
542 6
            $directory,
543
            $filename,
544
            $extension
545
        );
546
    }
547
}
548