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
![]() |
|||||
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
![]() |
|||||
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
![]() |
|||||
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
![]() |
|||||
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 |