MarwanAlsoltany /
velox
| 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\Frontend\View\Compiler; |
||||
| 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 | * @package Velox\Frontend\View |
||||
| 65 | * @since 1.0.0 |
||||
| 66 | * @api |
||||
| 67 | */ |
||||
| 68 | class View |
||||
| 69 | { |
||||
| 70 | /** |
||||
| 71 | * This event will be dispatched before rendering a view. |
||||
| 72 | * This event will be passed a reference to the array that will be passed to the view as variables. |
||||
| 73 | * |
||||
| 74 | * @var string |
||||
| 75 | */ |
||||
| 76 | public const BEFORE_RENDER = 'view.before.render'; |
||||
| 77 | |||||
| 78 | /** |
||||
| 79 | * This event will be dispatched when a view is cached. |
||||
| 80 | * This event will not be passed any arguments. |
||||
| 81 | * |
||||
| 82 | * @var string |
||||
| 83 | */ |
||||
| 84 | public const ON_CACHE = 'view.on.cache'; |
||||
| 85 | |||||
| 86 | /** |
||||
| 87 | * This event will be dispatched when views cache is cleared. |
||||
| 88 | * This event will not be passed any arguments. |
||||
| 89 | * |
||||
| 90 | * @var string |
||||
| 91 | */ |
||||
| 92 | public const ON_CACHE_CLEAR = 'view.on.cacheClear'; |
||||
| 93 | |||||
| 94 | |||||
| 95 | /** |
||||
| 96 | * The default values of class parameters. |
||||
| 97 | * |
||||
| 98 | * @var array |
||||
| 99 | */ |
||||
| 100 | public const DEFAULTS = [ |
||||
| 101 | 'name' => '__default__', |
||||
| 102 | 'variables' => [], |
||||
| 103 | 'fileExtension' => '.phtml', |
||||
| 104 | 'inherit' => true, |
||||
| 105 | 'minify' => true, |
||||
| 106 | 'cache' => false, |
||||
| 107 | 'cacheExclude' => ['__default__'], |
||||
| 108 | 'cacheAsIndex' => false, |
||||
| 109 | 'cacheWithTimestamp' => true, |
||||
| 110 | 'engine' => [ |
||||
| 111 | 'enabled' => true, |
||||
| 112 | 'cache' => true, |
||||
| 113 | 'debug' => false, |
||||
| 114 | ] |
||||
| 115 | ]; |
||||
| 116 | |||||
| 117 | |||||
| 118 | /** |
||||
| 119 | * Sections buffer. |
||||
| 120 | */ |
||||
| 121 | protected static array $sections = []; |
||||
| 122 | |||||
| 123 | /** |
||||
| 124 | * Sections stack. |
||||
| 125 | */ |
||||
| 126 | protected static array $stack = []; |
||||
| 127 | |||||
| 128 | |||||
| 129 | /** |
||||
| 130 | * Pushes content to the buffer of the section with the given name. |
||||
| 131 | * Note that a section will not be rendered unless it's yielded. |
||||
| 132 | * |
||||
| 133 | * @param string $name The name of the section. |
||||
| 134 | * @param string $content The content of the section. |
||||
| 135 | * |
||||
| 136 | * @return void |
||||
| 137 | */ |
||||
| 138 | 7 | public static function section(string $name, string $content): void |
|||
| 139 | { |
||||
| 140 | 7 | if (!isset(static::$sections[$name])) { |
|||
| 141 | 5 | static::$sections[$name] = []; |
|||
| 142 | } |
||||
| 143 | |||||
| 144 | 7 | static::$sections[$name][] = $content; |
|||
| 145 | } |
||||
| 146 | |||||
| 147 | /** |
||||
| 148 | * Resets (empties) the buffer of the section with the given name. |
||||
| 149 | * |
||||
| 150 | * @param string $name The name of the section. |
||||
| 151 | * |
||||
| 152 | * @return void |
||||
| 153 | */ |
||||
| 154 | 4 | public static function sectionReset(string $name): void |
|||
| 155 | { |
||||
| 156 | 4 | unset(static::$sections[$name]); |
|||
| 157 | } |
||||
| 158 | |||||
| 159 | /** |
||||
| 160 | * Starts capturing buffer of the section with the given name. Works in conjunction with `self::sectionEnd()`. |
||||
| 161 | * Note that a section will not be rendered unless it's yielded. |
||||
| 162 | * |
||||
| 163 | * @param string $name The name of the section. |
||||
| 164 | * |
||||
| 165 | * @return void |
||||
| 166 | */ |
||||
| 167 | 1 | public static function sectionStart(string $name): void |
|||
| 168 | { |
||||
| 169 | 1 | if (!isset(static::$sections[$name])) { |
|||
| 170 | 1 | static::$sections[$name] = []; |
|||
| 171 | } |
||||
| 172 | |||||
| 173 | 1 | array_push(static::$stack, $name); |
|||
| 174 | |||||
| 175 | 1 | ob_start(); |
|||
| 176 | } |
||||
| 177 | |||||
| 178 | /** |
||||
| 179 | * Ends capturing buffer of the section with the given name. Works in conjunction with `self::sectionStart()`. |
||||
| 180 | * Note that a section will not be rendered unless it's yielded. |
||||
| 181 | * |
||||
| 182 | * @return void |
||||
| 183 | * |
||||
| 184 | * @throws \Exception If no section has been started. |
||||
| 185 | */ |
||||
| 186 | 1 | public static function sectionEnd(): void |
|||
| 187 | { |
||||
| 188 | 1 | if (!count(static::$stack) || !ob_get_level()) { |
|||
| 189 | 1 | $variables = ['class', 'function', 'file', 'line']; |
|||
| 190 | 1 | $backtrace = Misc::backtrace($variables, 1); |
|||
| 191 | 1 | $backtrace = is_array($backtrace) ? $backtrace : array_map('strtoupper', $variables); |
|||
| 192 | |||||
| 193 | 1 | throw new \Exception( |
|||
| 194 | 1 | vsprintf('Not in a context to end a section! Call to %s::%s() in %s on line %s is superfluous', $backtrace) |
|||
| 195 | ); |
||||
| 196 | } |
||||
| 197 | |||||
| 198 | 1 | $buffer = ob_get_clean(); |
|||
| 199 | |||||
| 200 | 1 | $name = array_pop(static::$stack); |
|||
| 201 | |||||
| 202 | 1 | static::$sections[$name][] = $buffer ?: ''; |
|||
| 203 | } |
||||
| 204 | |||||
| 205 | /** |
||||
| 206 | * Returns content of the section with the given name. |
||||
| 207 | * |
||||
| 208 | * @param string $name The name of the section. |
||||
| 209 | * @param string $default [optional] The default value to yield if the section has no content or is an empty string. |
||||
| 210 | * |
||||
| 211 | * @return string |
||||
| 212 | */ |
||||
| 213 | 5 | public static function yield(string $name, string $default = ''): string |
|||
| 214 | { |
||||
| 215 | 5 | $section = ''; |
|||
| 216 | |||||
| 217 | 5 | if (isset(static::$sections[$name])) { |
|||
| 218 | 4 | foreach (static::$sections[$name] as $buffer) { |
|||
| 219 | // buffers are added in reverse order |
||||
| 220 | 4 | $section = $buffer . $section; |
|||
| 221 | } |
||||
| 222 | |||||
| 223 | 4 | static::sectionReset($name); |
|||
| 224 | } |
||||
| 225 | |||||
| 226 | 5 | return strlen(trim($section)) ? $section : $default; |
|||
| 227 | } |
||||
| 228 | |||||
| 229 | /** |
||||
| 230 | * Includes a file from the active theme directory. |
||||
| 231 | * Can also be used as a mean of extending a layout if it was put at the end of it because the compilation is done |
||||
| 232 | * from top to bottom and from the deepest nested element to the upper most (imperative approach, there is no preprocessing). |
||||
| 233 | * |
||||
| 234 | * @param string $file The path of the file starting from theme root. |
||||
| 235 | * @param array|null $variables [optional] An associative array of the variables to pass. |
||||
| 236 | * |
||||
| 237 | * @return void |
||||
| 238 | */ |
||||
| 239 | 1 | public static function include(string $file, ?array $variables = null): void |
|||
| 240 | { |
||||
| 241 | 1 | $path = Config::get('theme.paths.root'); |
|||
| 242 | |||||
| 243 | 1 | $include = self::resolvePath($path, $file); |
|||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||||
| 244 | |||||
| 245 | 1 | Compiler::require($include, $variables); |
|||
| 246 | } |
||||
| 247 | |||||
| 248 | /** |
||||
| 249 | * Renders a theme layout with the passed variables. |
||||
| 250 | * |
||||
| 251 | * @param string $name The name of the layout. |
||||
| 252 | * @param array $variables [optional] An associative array of the variables to pass. |
||||
| 253 | * |
||||
| 254 | * @return string |
||||
| 255 | */ |
||||
| 256 | 7 | public static function layout(string $name, array $variables = []): string |
|||
| 257 | { |
||||
| 258 | 7 | $path = Config::get('theme.paths.layouts'); |
|||
| 259 | |||||
| 260 | 7 | $variables['defaultLayoutVars'] = Config::get('view.defaultLayoutVars'); |
|||
| 261 | |||||
| 262 | 7 | $layout = self::resolvePath($path, $name); |
|||
|
0 ignored issues
–
show
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
Loading history...
|
|||||
| 263 | |||||
| 264 | 7 | return Compiler::compile($layout, __FUNCTION__, $variables); |
|||
| 265 | } |
||||
| 266 | |||||
| 267 | /** |
||||
| 268 | * Renders a theme page with the passed variables. |
||||
| 269 | * |
||||
| 270 | * @param string $name The name of the page. |
||||
| 271 | * @param array $variables [optional] An associative array of the variables to pass. |
||||
| 272 | * |
||||
| 273 | * @return string |
||||
| 274 | */ |
||||
| 275 | 8 | public static function page(string $name, array $variables = []): string |
|||
| 276 | { |
||||
| 277 | 8 | $path = Config::get('theme.paths.pages'); |
|||
| 278 | |||||
| 279 | 8 | $variables['defaultPageVars'] = Config::get('view.defaultPageVars'); |
|||
| 280 | |||||
| 281 | 8 | $page = self::resolvePath($path, $name); |
|||
|
0 ignored issues
–
show
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
Loading history...
|
|||||
| 282 | |||||
| 283 | 8 | return Compiler::compile($page, __FUNCTION__, $variables); |
|||
| 284 | } |
||||
| 285 | |||||
| 286 | /** |
||||
| 287 | * Renders a theme partial with the passed variables. |
||||
| 288 | * |
||||
| 289 | * @param string $name The name of the partial. |
||||
| 290 | * @param array $variables [optional] An associative array of the variables to pass. |
||||
| 291 | * |
||||
| 292 | * @return string |
||||
| 293 | */ |
||||
| 294 | 5 | public static function partial(string $name, array $variables = []): string |
|||
| 295 | { |
||||
| 296 | 5 | $path = Config::get('theme.paths.partials'); |
|||
| 297 | |||||
| 298 | 5 | $variables['defaultPartialVars'] = Config::get('view.defaultPartialVars'); |
|||
| 299 | |||||
| 300 | 5 | $partial = self::resolvePath($path, $name); |
|||
|
0 ignored issues
–
show
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
Loading history...
|
|||||
| 301 | |||||
| 302 | 5 | return Compiler::compile($partial, __FUNCTION__, $variables); |
|||
| 303 | } |
||||
| 304 | |||||
| 305 | /** |
||||
| 306 | * Renders a view (a Page wrapped in a Layout) with the passed variables, the Page content will be sent to `{view.defaultSectionName}` section. |
||||
| 307 | * |
||||
| 308 | * @param string $page The name of the page. |
||||
| 309 | * @param array $variables [optional] An associative array of the variables to pass. |
||||
| 310 | * @param string|null $layout [optional] The name of the Layout to use. |
||||
| 311 | * |
||||
| 312 | * @return string |
||||
| 313 | */ |
||||
| 314 | 7 | public static function render(string $page, array $variables = [], ?string $layout = null): string |
|||
| 315 | { |
||||
| 316 | 7 | $viewConfig = Config::get('view'); |
|||
| 317 | 7 | $layout = $layout ?? $viewConfig['defaultLayoutName']; |
|||
| 318 | 7 | $section = $viewConfig['defaultSectionName']; |
|||
| 319 | 7 | $minify = $viewConfig['minify']; |
|||
| 320 | 7 | $cache = $viewConfig['cache']; |
|||
| 321 | |||||
| 322 | 7 | if ($cache) { |
|||
| 323 | 1 | return static::cache($page, $variables, $layout); |
|||
| 324 | } |
||||
| 325 | |||||
| 326 | 7 | Event::dispatch(self::BEFORE_RENDER, [&$variables]); |
|||
| 327 | |||||
| 328 | 7 | static::section($section, static::page($page, $variables)); |
|||
| 329 | |||||
| 330 | 6 | $view = static::layout($layout, $variables); |
|||
| 331 | |||||
| 332 | 6 | return $minify ? HTML::minify($view) : $view; |
|||
| 333 | } |
||||
| 334 | |||||
| 335 | /** |
||||
| 336 | * Renders a view with the passed variables and cache it as HTML, subsequent calls to this function will return the cached version. |
||||
| 337 | * This function is exactly like `self::render()` but with caching capabilities. |
||||
| 338 | * |
||||
| 339 | * @param string $page The name of the page. |
||||
| 340 | * @param array $variables [optional] An associative array of the variables to pass. |
||||
| 341 | * @param string|null $layout [optional] The name of the Layout to use. |
||||
| 342 | * |
||||
| 343 | * @return string |
||||
| 344 | */ |
||||
| 345 | 1 | public static function cache(string $page, array $variables = [], ?string $layout = null) |
|||
| 346 | { |
||||
| 347 | 1 | $viewConfig = Config::get('view'); |
|||
| 348 | 1 | $cacheEnabled = $viewConfig['cache']; |
|||
| 349 | 1 | $cacheExclude = $viewConfig['cacheExclude']; |
|||
| 350 | 1 | $cacheAsIndex = $viewConfig['cacheAsIndex']; |
|||
| 351 | 1 | $cacheWithTimestamp = $viewConfig['cacheWithTimestamp']; |
|||
| 352 | 1 | $cacheDir = Config::get('global.paths.storage') . '/cache/views/'; |
|||
| 353 | |||||
| 354 | 1 | if (!file_exists($cacheDir)) { |
|||
| 355 | 1 | mkdir($cacheDir, 0744, true); |
|||
| 356 | } |
||||
| 357 | |||||
| 358 | 1 | $cacheFile = static::resolveCachePath($page); |
|||
| 359 | 1 | $cacheFileDirectory = dirname($cacheFile); |
|||
| 360 | 1 | $fileExists = file_exists($cacheFile); |
|||
| 361 | |||||
| 362 | 1 | $content = null; |
|||
| 363 | |||||
| 364 | 1 | if (!$cacheEnabled || !$fileExists) { |
|||
| 365 | 1 | Config::set('view.cache', false); |
|||
| 366 | 1 | $view = static::render($page, $variables, $layout); |
|||
| 367 | 1 | Config::set('view.cache', true); |
|||
| 368 | |||||
| 369 | 1 | if (in_array($page, (array)$cacheExclude)) { |
|||
| 370 | 1 | return $view; |
|||
| 371 | } |
||||
| 372 | |||||
| 373 | 1 | if ($cacheAsIndex) { |
|||
| 374 | 1 | if (!file_exists($cacheFileDirectory)) { |
|||
| 375 | 1 | mkdir($cacheFileDirectory, 0744, true); |
|||
| 376 | } |
||||
| 377 | } else { |
||||
| 378 | 1 | $cacheFile = preg_replace('/\/+|\\+/', '___', $page); |
|||
| 379 | 1 | $cacheFile = static::resolvePath($cacheDir, $cacheFile, '.html'); |
|||
| 380 | } |
||||
| 381 | |||||
| 382 | 1 | $comment = ''; |
|||
| 383 | 1 | if ($cacheWithTimestamp) { |
|||
| 384 | 1 | $timestamp = date('l jS \of F Y h:i:s A (Ymdhis)'); |
|||
| 385 | 1 | $comment = sprintf('<!-- [CACHE] Generated on %s -->', $timestamp); |
|||
| 386 | } |
||||
| 387 | |||||
| 388 | 1 | $content = preg_replace( |
|||
| 389 | '/<!DOCTYPE html>/i', |
||||
| 390 | 1 | '$0' . $comment, |
|||
| 391 | $view |
||||
| 392 | ); |
||||
| 393 | |||||
| 394 | 1 | file_put_contents($cacheFile, $content, LOCK_EX); |
|||
| 395 | |||||
| 396 | 1 | Event::dispatch(self::ON_CACHE); |
|||
| 397 | |||||
| 398 | 1 | App::log('Generated cache for the "{page}" page', ['page' => $page], 'system'); |
|||
| 399 | } |
||||
| 400 | |||||
| 401 | 1 | $content = $content ?? file_get_contents($cacheFile); |
|||
| 402 | |||||
| 403 | 1 | return $content; |
|||
| 404 | } |
||||
| 405 | |||||
| 406 | /** |
||||
| 407 | * Deletes all cached views generated by `self::cache()`. |
||||
| 408 | * |
||||
| 409 | * @return void |
||||
| 410 | */ |
||||
| 411 | 1 | public static function clearCache(): void |
|||
| 412 | { |
||||
| 413 | 1 | $clear = static function ($path) use (&$clear) { |
|||
| 414 | static $base = null; |
||||
| 415 | 1 | if (!$base) { |
|||
| 416 | 1 | $base = $path; |
|||
| 417 | } |
||||
| 418 | |||||
| 419 | 1 | $items = glob($path . '/*'); |
|||
| 420 | 1 | foreach ($items as $item) { |
|||
| 421 | 1 | is_dir($item) ? $clear($item) : unlink($item); |
|||
| 422 | } |
||||
| 423 | |||||
| 424 | 1 | if ($path !== $base) { |
|||
| 425 | 1 | file_exists($path) && rmdir($path); |
|||
| 426 | } |
||||
| 427 | }; |
||||
| 428 | |||||
| 429 | 1 | $clear(Config::get('global.paths.storage') . '/cache/views/'); |
|||
| 430 | 1 | $clear(Config::get('global.paths.storage') . '/temp/views/'); |
|||
| 431 | |||||
| 432 | 1 | Event::dispatch(self::ON_CACHE_CLEAR); |
|||
| 433 | |||||
| 434 | 1 | App::log('Cleared views cache', null, 'system'); |
|||
| 435 | } |
||||
| 436 | |||||
| 437 | /** |
||||
| 438 | * Returns a normalized path of a page from the cache directory. |
||||
| 439 | * |
||||
| 440 | * @param string $pageName |
||||
| 441 | * |
||||
| 442 | * @return string |
||||
| 443 | */ |
||||
| 444 | 1 | private static function resolveCachePath(string $pageName): string |
|||
| 445 | { |
||||
| 446 | static $cacheDir = null; |
||||
| 447 | |||||
| 448 | 1 | if ($cacheDir === null) { |
|||
| 449 | 1 | $cacheDir = Config::get('global.paths.storage') . '/cache/views/'; |
|||
| 450 | } |
||||
| 451 | |||||
| 452 | 1 | $cacheName = sprintf( |
|||
| 453 | '%s/%s', |
||||
| 454 | $pageName, |
||||
| 455 | 'index' |
||||
| 456 | ); |
||||
| 457 | |||||
| 458 | 1 | return static::resolvePath($cacheDir, $cacheName, '.html'); |
|||
| 459 | } |
||||
| 460 | |||||
| 461 | /** |
||||
| 462 | * Returns a normalized path to a file based on OS. |
||||
| 463 | * |
||||
| 464 | * @param string $directory |
||||
| 465 | * @param string $filename |
||||
| 466 | * @param string|null $extension |
||||
| 467 | * |
||||
| 468 | * @return string |
||||
| 469 | */ |
||||
| 470 | 11 | private static function resolvePath(string $directory, string $filename, ?string $extension = null): string |
|||
| 471 | { |
||||
| 472 | 11 | $extension = $extension ?? Config::get('view.fileExtension') ?? self::DEFAULTS['fileExtension']; |
|||
| 473 | |||||
| 474 | 11 | return Path::normalize( |
|||
| 475 | $directory, |
||||
| 476 | $filename, |
||||
| 477 | $extension |
||||
| 478 | ); |
||||
| 479 | } |
||||
| 480 | } |
||||
| 481 |