Passed
Push — master ( 70bf0a...4ee951 )
by Marwan
01:37
created

View::resolveCachePath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 6
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 11
ccs 6
cts 6
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
     * The default directory of the cached views.
87
     *
88
     * @var string
89
     */
90
    public const VIEWS_CACHE_DIR = BASE_PATH . '/storage/cache/views';
91
92
93
    /**
94
     * Sections buffer.
95
     */
96
    protected static array $sections = [];
97
98
    /**
99
     * Sections stack.
100
     */
101
    protected static array $stack = [];
102
103
104
    /**
105
     * Pushes content to the buffer of the section with the given name.
106
     * Note that a section will not be rendered unless it's yielded.
107
     *
108
     * @param string $name The name of the section.
109
     * @param string $content The content of the section.
110
     *
111
     * @return void
112
     */
113 3
    public static function section(string $name, string $content): void
114
    {
115 3
        if (!isset(static::$sections[$name])) {
116 2
            static::$sections[$name] = [];
117
        }
118
119 3
        static::$sections[$name][] = $content;
120 3
    }
121
122
    /**
123
     * Resets (empties) the buffer of the section with the given name.
124
     *
125
     * @param string $name The name of the section.
126
     *
127
     * @return void
128
     */
129 1
    public static function sectionReset(string $name): void
130
    {
131 1
        unset(static::$sections[$name]);
132 1
    }
133
134
    /**
135
     * Starts capturing buffer of the section with the given name. Works in conjunction with `self::sectionEnd()`.
136
     * Note that a section will not be rendered unless it's yielded.
137
     *
138
     * @param string $name The name of the section.
139
     *
140
     * @return void
141
     */
142 1
    public static function sectionStart(string $name): void
143
    {
144 1
        if (!isset(static::$sections[$name])) {
145 1
            static::$sections[$name] = [];
146
        }
147
148 1
        array_push(static::$stack, $name);
149
150 1
        ob_start();
151 1
    }
152
153
    /**
154
     * Ends capturing buffer of the section with the given name. Works in conjunction with `self::sectionStart()`.
155
     * Note that a section will not be rendered unless it's yielded.
156
     *
157
     * @return void
158
     *
159
     * @throws \Exception If no section has been started.
160
     */
161 1
    public static function sectionEnd(): void
162
    {
163 1
        if (!count(static::$stack) || !ob_get_level()) {
164 1
            $variables = ['class', 'function', 'file', 'line'];
165 1
            $backtrace = Misc::backtrace($variables, 1);
166 1
            $backtrace = is_array($backtrace) ? $backtrace : array_map('strtoupper', $variables);
167
168 1
            throw new \Exception(
169 1
                vsprintf('Not in a context to end a section! Call to %s::%s() in %s on line %s is superfluous', $backtrace)
170
            );
171
        }
172
173 1
        $buffer = ob_get_clean();
174
175 1
        $name = array_pop(static::$stack);
176
177 1
        static::$sections[$name][] = $buffer ?: '';
178 1
    }
179
180
    /**
181
     * Returns content of the section with the given name.
182
     *
183
     * @param string $name The name of the section.
184
     * @param string $default [optional] The default value to yield if the section has no content or is an empty string.
185
     *
186
     * @return string
187
     */
188 2
    public static function yield(string $name, string $default = ''): string
189
    {
190 2
        $section = '';
191
192 2
        if (isset(static::$sections[$name])) {
193 1
            foreach (static::$sections[$name] as $buffer) {
194
                // buffers are added in reverse order
195 1
                $section = $buffer . $section;
196
            }
197
198 1
            static::sectionReset($name);
199
        }
200
201 2
        return strlen(trim($section)) ? $section : $default;
202
    }
203
204
    /**
205
     * Includes a file from the active theme directory.
206
     * Can also be used as a mean of extending a layout if it was put at the end of it.
207
     *
208
     * @param string $file The path of the file starting from theme root.
209
     *
210
     * @return void
211
     */
212 1
    public static function include(string $file): void
213
    {
214 1
        $path = Config::get('theme.paths.root');
215
216 1
        $include = self::resolvePath($path, $file);
217
218 1
        self::require($include);
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 3
    public static function layout(string $name, array $variables = []): string
230
    {
231 3
        $path = Config::get('theme.paths.layouts');
232
233 3
        $variables['defaultLayoutVars'] = Config::get('view.defaultLayoutVars');
234
235 3
        $layout = self::resolvePath($path, $name);
236
237 3
        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 3
    public static function page(string $name, array $variables = []): string
249
    {
250 3
        $path = Config::get('theme.paths.pages');
251
252 3
        $variables['defaultPageVars'] = Config::get('view.defaultPageVars');
253
254 3
        $page = self::resolvePath($path, $name);
255
256 3
        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 2
    public static function partial(string $name, array $variables = []): string
268
    {
269 2
        $path = Config::get('theme.paths.partials');
270
271 2
        $variables['defaultPartialVars'] = Config::get('view.defaultPartialVars');
272
273 2
        $partial = self::resolvePath($path, $name);
274
275 2
        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 2
    public static function render(string $page, array $variables = [], ?string $layout = null): string
288
    {
289 2
        $viewConfig = Config::get('view');
290 2
        $layout     = $layout ?? $viewConfig['defaultLayoutName'];
291 2
        $section    = $viewConfig['defaultSectionName'];
292 2
        $minify     = $viewConfig['minify'];
293 2
        $cache      = $viewConfig['cache'];
294
295 2
        if ($cache) {
296 1
            return static::cache($page, $variables, $layout);
297
        }
298
299 2
        Event::dispatch('view.before.render', [&$variables]);
300
301 2
        static::section($section, static::page($page, $variables));
302
303 2
        $view = static::layout($layout, $variables);
304
305 2
        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           = static::VIEWS_CACHE_DIR;
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
        $cacheDir = static::VIEWS_CACHE_DIR;
387
388 1
        $clear = static function ($path) use (&$clear) {
389 1
            static $base = null;
390 1
            if (!$base) {
391 1
                $base = $path;
392
            }
393
394 1
            $items = glob($path . '/*');
395 1
            foreach ($items as $item) {
396 1
                is_dir($item) ? $clear($item) : unlink($item);
397
            }
398
399 1
            if ($path !== $base) {
400 1
                rmdir($path);
401
            }
402 1
        };
403
404 1
        $clear($cacheDir);
405
406 1
        Event::dispatch('view.on.cacheClear');
407
408 1
        App::log('Cleared views cache', null, 'system');
409 1
    }
410
411
    /**
412
     * Compiles a PHP file with the passed variables.
413
     *
414
     * @param string $file An absolute path to the file that should be compiled.
415
     * @param string $type The type of the file (just a name to make for friendly exceptions).
416
     * @param array|null [optional] An associative array of the variables to pass.
417
     *
418
     * @return string
419
     *
420
     * @throws \Exception If failed to compile the file.
421
     */
422 5
    protected static function compile(string $file, string $type, ?array $variables = null): string
423
    {
424 5
        ob_start();
425
426 5
        if (is_array($variables)) {
427 5
            extract($variables, EXTR_OVERWRITE);
428
        }
429
430
        try {
431 5
            self::require($file, $variables);
432 3
        } catch (\Exception $error) {
433
            // clean started buffer before throwing the exception
434 3
            ob_end_clean();
435
436 3
            throw $error;
437
        }
438
439 5
        $buffer = ob_get_contents();
440 5
        ob_end_clean();
441
442 5
        if ($buffer === false) {
443
            $name = basename($file, Config::get('view.fileExtension'));
444
            throw new \Exception("Something went wrong when trying to compile the {$type} with the name '{$name}' in {$file}");
445
        }
446
447 5
        return $buffer;
448
    }
449
450
    /**
451
     * Requires a PHP file and pass it the passed variables.
452
     *
453
     * @param string $file An absolute path to the file that should be compiled.
454
     * @param array|null $variables [optional] An associative array of the variables to pass.
455
     *
456
     * @return void
457
     *
458
     * @throws \Exception If the file could not be loaded.
459
     */
460 6
    private static function require(string $file, ?array $variables = null): void
461
    {
462 6
        $file = self::findOrInherit($file);
463
464 6
        if (!file_exists($file)) {
465 4
            throw new \Exception(
466 4
                "Could not load the file with the path '{$file}' nor fall back to a parent. Check if the file exists!"
467
            );
468
        }
469
470 6
        if (is_array($variables)) {
471 5
            extract($variables, EXTR_OVERWRITE);
472
        }
473
474 6
        require($file);
475 6
    }
476
477
    /**
478
     * Finds a file in the active theme or inherit it from parent theme.
479
     *
480
     * @param string $file
481
     *
482
     * @return string
483
     */
484 6
    private static function findOrInherit(string $file): string
485
    {
486 6
        if (file_exists($file)) {
487 1
            return $file;
488
        }
489
490 6
        if (Config::get('view.inherit')) {
491 6
            $active = Config::get('theme.active');
492 6
            $parent = Config::get('theme.parent');
493 6
            $themes = Config::get('global.paths.themes');
494 6
            $nameWrapper = basename($themes) . DIRECTORY_SEPARATOR . '%s';
495
496 6
            foreach ((array)$parent as $substitute) {
497 6
                $fallbackFile = strtr($file, [
498 6
                    sprintf($nameWrapper, $active) => sprintf($nameWrapper, $substitute)
499
                ]);
500
501 6
                if (file_exists($fallbackFile)) {
502 6
                    $file = $fallbackFile;
503 6
                    break;
504
                }
505
            }
506
        }
507
508 6
        return $file;
509
    }
510
511
    /**
512
     * Returns a normalized path of a page from the cache directory.
513
     *
514
     * @param string $pageName
515
     *
516
     * @return string
517
     */
518 1
    private static function resolveCachePath(string $pageName): string
519
    {
520 1
        $cacheDir = static::VIEWS_CACHE_DIR;
521
522 1
        $cacheName = sprintf(
523 1
            '%s/%s',
524
            $pageName,
525 1
            'index'
526
        );
527
528 1
        return static::resolvePath($cacheDir, $cacheName, '.html');
529
    }
530
531
    /**
532
     * Returns a normalized path to a file based on OS.
533
     *
534
     * @param string $directory
535
     * @param string $filename
536
     * @param string|null $extension
537
     *
538
     * @return string
539
     */
540 6
    private static function resolvePath(string $directory, string $filename, ?string $extension = null): string
541
    {
542 6
        $extension = $extension ?? Config::get('view.fileExtension') ?? self::DEFAULTS['fileExtension'];
543
544 6
        return Path::normalize(
545 6
            $directory,
546
            $filename,
547
            $extension
548
        );
549
    }
550
}
551