Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
144 / 144
100.00% covered (success)
100.00%
35 / 35
CRAP
100.00% covered (success)
100.00%
1 / 1
View
100.00% covered (success)
100.00%
144 / 144
100.00% covered (success)
100.00%
35 / 35
68
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __destruct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 setBaseDir
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getBaseDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setExtension
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getExtension
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLayoutPrefix
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLayoutPrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeDirectoryPrefix
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setIncludePrefix
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getIncludePrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNamespacedFilepath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getFilepath
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 render
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 setDebugData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 extends
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 extendsWithoutPrefix
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 inLayout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 block
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 endBlock
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 renderBlock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeBlock
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hasBlock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 inBlock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 currentBlock
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 include
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 involveInclude
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 includeWithoutPrefix
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getIncludeContentsWithDebug
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getIncludeContents
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getContents
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 setDebugCollector
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isShowingDebugComments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enableDebugComments
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 disableDebugComments
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework MVC Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\MVC;
11
12use Framework\Helpers\Isolation;
13use Framework\MVC\Debug\ViewCollector;
14use InvalidArgumentException;
15use LogicException;
16
17/**
18 * Class View.
19 *
20 * @package mvc
21 */
22class View
23{
24    protected ?string $baseDir = null;
25    protected string $extension;
26    protected string $layout;
27    protected ?string $openBlock;
28    /**
29     * @var array<int,string>
30     */
31    protected array $openBlocks = [];
32    /**
33     * @var array<int,string>
34     */
35    protected array $layoutsOpen = [];
36    /**
37     * @var array<string,string>
38     */
39    protected array $blocks;
40    protected string $currentView;
41    protected ViewCollector $debugCollector;
42    protected string $layoutPrefix = '';
43    protected string $includePrefix = '';
44    protected bool $inInclude = false;
45    protected bool $showDebugComments = true;
46
47    public function __construct(string $baseDir = null, string $extension = '.php')
48    {
49        if ($baseDir !== null) {
50            $this->setBaseDir($baseDir);
51        }
52        $this->setExtension($extension);
53    }
54
55    public function __destruct()
56    {
57        if ($this->openBlocks) {
58            throw new LogicException(
59                'Trying to destruct a View instance while the following blocks stayed open: '
60                . \implode(', ', \array_map(static function ($name) {
61                    return "'{$name}'";
62                }, $this->openBlocks))
63            );
64        }
65    }
66
67    public function setBaseDir(string $baseDir) : static
68    {
69        $real = \realpath($baseDir);
70        if ( ! $real || ! \is_dir($real)) {
71            throw new InvalidArgumentException("View base dir is not a valid directory: {$baseDir} ");
72        }
73        $this->baseDir = \rtrim($real, '\\/ ') . \DIRECTORY_SEPARATOR;
74        return $this;
75    }
76
77    public function getBaseDir() : ?string
78    {
79        return $this->baseDir;
80    }
81
82    public function setExtension(string $extension) : static
83    {
84        $this->extension = '.' . \ltrim($extension, '.');
85        return $this;
86    }
87
88    public function getExtension() : string
89    {
90        return $this->extension;
91    }
92
93    /**
94     * @param string $prefix
95     *
96     * @return static
97     */
98    public function setLayoutPrefix(string $prefix) : static
99    {
100        $this->layoutPrefix = $this->makeDirectoryPrefix($prefix);
101        return $this;
102    }
103
104    /**
105     * @return string
106     */
107    public function getLayoutPrefix() : string
108    {
109        return $this->layoutPrefix;
110    }
111
112    protected function makeDirectoryPrefix(string $prefix) : string
113    {
114        return $prefix === ''
115            ? ''
116            : \trim($prefix, '\\/') . \DIRECTORY_SEPARATOR;
117    }
118
119    /**
120     * @param string $prefix
121     *
122     * @return static
123     */
124    public function setIncludePrefix(string $prefix) : static
125    {
126        $this->includePrefix = $this->makeDirectoryPrefix($prefix);
127        return $this;
128    }
129
130    /**
131     * @return string
132     */
133    public function getIncludePrefix() : string
134    {
135        return $this->includePrefix;
136    }
137
138    protected function getNamespacedFilepath(string $view) : string
139    {
140        $path = App::locator()->getNamespacedFilepath($view, $this->getExtension());
141        if ($path) {
142            return $path;
143        }
144        throw new InvalidArgumentException("Namespaced view path does not match a file: {$view}");
145    }
146
147    protected function getFilepath(string $view) : string
148    {
149        if (isset($view[0]) && $view[0] === '\\') {
150            return $this->getNamespacedFilepath($view);
151        }
152        $view = $this->getBaseDir() . $view . $this->getExtension();
153        $real = \realpath($view);
154        if ( ! $real || ! \is_file($real)) {
155            throw new InvalidArgumentException("View path does not match a file: {$view}");
156        }
157        if ($this->getBaseDir() && ! \str_starts_with($real, $this->getBaseDir())) {
158            throw new InvalidArgumentException("View path out of base directory: {$real}");
159        }
160        return $real;
161    }
162
163    /**
164     * @param string $view
165     * @param array<string,mixed> $data
166     *
167     * @return string
168     */
169    public function render(string $view, array $data = []) : string
170    {
171        $debug = isset($this->debugCollector);
172        if ($debug) {
173            $start = \microtime(true);
174        }
175        $this->currentView = $view;
176        $contents = $this->getContents($view, $data);
177        if (isset($this->layout)) {
178            $layout = $this->layout;
179            unset($this->layout);
180            $this->layoutsOpen[] = $layout;
181            $contents = $this->render($layout, $data);
182        }
183        if ($debug) {
184            $type = 'render';
185            if ($this->layoutsOpen) {
186                \array_shift($this->layoutsOpen);
187                $type = 'layout';
188            }
189            $this->setDebugData($view, $start, $type);
190            $type = \ucfirst($type);
191            if ($this->isShowingDebugComments()) {
192                $contents = '<!-- ' . $type . ' start: ' . $view . ' -->'
193                    . \PHP_EOL . $contents . \PHP_EOL
194                    . '<!-- ' . $type . ' end: ' . $view . ' -->';
195            }
196        }
197        return $contents;
198    }
199
200    protected function setDebugData(string $file, float $start, string $type) : static
201    {
202        $end = \microtime(true);
203        $this->debugCollector->addData([
204            'start' => $start,
205            'end' => $end,
206            'file' => $file,
207            'filepath' => $this->getFilepath($file),
208            'type' => $type,
209        ]);
210        return $this;
211    }
212
213    public function extends(string $layout, string $openBlock = null) : static
214    {
215        $this->layout = $this->getLayoutPrefix() . $layout;
216        $this->openBlock = $openBlock;
217        if ($openBlock !== null) {
218            $this->block($openBlock);
219        }
220        return $this;
221    }
222
223    public function extendsWithoutPrefix(string $layout) : static
224    {
225        $this->layout = $layout;
226        return $this;
227    }
228
229    public function inLayout(string $layout) : bool
230    {
231        return isset($this->layout) && $this->layout === $layout;
232    }
233
234    public function block(string $name) : static
235    {
236        $this->openBlocks[] = $name;
237        \ob_start();
238        if (isset($this->debugCollector) && $this->isShowingDebugComments()) {
239            if (isset($this->currentView)) {
240                $name = $this->currentView . '::' . $name;
241            }
242            echo \PHP_EOL . '<!-- Block start: ' . $name . ' -->' . \PHP_EOL;
243        }
244        return $this;
245    }
246
247    public function endBlock() : static
248    {
249        if (empty($this->openBlocks)) {
250            throw new LogicException('Trying to end a view block when none is open');
251        }
252        $name = \array_pop($this->openBlocks);
253        if (isset($this->debugCollector) && $this->isShowingDebugComments()) {
254            $block = $name;
255            if (isset($this->currentView)) {
256                $block = $this->currentView . '::' . $name;
257            }
258            echo \PHP_EOL . '<!-- Block end: ' . $block . ' -->' . \PHP_EOL;
259        }
260        $contents = \ob_get_clean();
261        if ( ! isset($this->blocks[$name])) {
262            $this->blocks[$name] = $contents; // @phpstan-ignore-line
263        }
264        return $this;
265    }
266
267    public function renderBlock(string $name) : ?string
268    {
269        return $this->blocks[$name] ?? null;
270    }
271
272    public function removeBlock(string $name) : static
273    {
274        unset($this->blocks[$name]);
275        return $this;
276    }
277
278    public function hasBlock(string $name) : bool
279    {
280        return isset($this->blocks[$name]);
281    }
282
283    public function inBlock(string $name) : bool
284    {
285        return $this->currentBlock() === $name;
286    }
287
288    public function currentBlock() : ?string
289    {
290        if ($this->openBlocks) {
291            return $this->openBlocks[\array_key_last($this->openBlocks)];
292        }
293        return null;
294    }
295
296    /**
297     * @param string $view
298     * @param array<string,mixed> $data
299     *
300     * @return string
301     */
302    public function include(string $view, array $data = []) : string
303    {
304        $view = $this->getIncludePrefix() . $view;
305        if (isset($this->debugCollector)) {
306            return $this->getIncludeContentsWithDebug($view, $data);
307        }
308        return $this->getIncludeContents($view, $data);
309    }
310
311    protected function involveInclude(string $view, string $contents) : string
312    {
313        return \PHP_EOL . '<!-- Include start: ' . $view . ' -->'
314            . \PHP_EOL . $contents . \PHP_EOL
315            . '<!-- Include end: ' . $view . ' -->' . \PHP_EOL;
316    }
317
318    /**
319     * @param string $view
320     * @param array<string,mixed> $data
321     *
322     * @return string
323     */
324    public function includeWithoutPrefix(string $view, array $data = []) : string
325    {
326        if (isset($this->debugCollector)) {
327            return $this->getIncludeContentsWithDebug($view, $data);
328        }
329        return $this->getIncludeContents($view, $data);
330    }
331
332    /**
333     * @param string $view
334     * @param array<string,mixed> $data
335     *
336     * @return string
337     */
338    protected function getIncludeContentsWithDebug(string $view, array $data = []) : string
339    {
340        $start = \microtime(true);
341        $this->inInclude = true;
342        $contents = $this->getContents($view, $data);
343        $this->inInclude = false;
344        $this->setDebugData($view, $start, 'include');
345        if ( ! $this->isShowingDebugComments()) {
346            return $contents;
347        }
348        return $this->involveInclude($view, $contents);
349    }
350
351    /**
352     * @param string $view
353     * @param array<string,mixed> $data
354     *
355     * @return string
356     */
357    protected function getIncludeContents(string $view, array $data = []) : string
358    {
359        $this->inInclude = true;
360        $contents = $this->getContents($view, $data);
361        $this->inInclude = false;
362        return $contents;
363    }
364
365    /**
366     * @param string $view
367     * @param array<string,mixed> $data
368     *
369     * @return string
370     */
371    protected function getContents(string $view, array $data) : string
372    {
373        $data['view'] = $this;
374        \ob_start();
375        Isolation::require($this->getFilepath($view), $data);
376        if (isset($this->openBlock) && ! $this->inInclude) {
377            $this->openBlock = null;
378            $this->endBlock();
379        }
380        return \ob_get_clean(); // @phpstan-ignore-line
381    }
382
383    public function setDebugCollector(ViewCollector $debugCollector) : static
384    {
385        $this->debugCollector = $debugCollector;
386        $this->debugCollector->setView($this);
387        return $this;
388    }
389
390    /**
391     * Tells if it is showing debug comments when in debug mode.
392     *
393     * @since 3.2
394     *
395     * @return bool
396     */
397    public function isShowingDebugComments() : bool
398    {
399        return $this->showDebugComments;
400    }
401
402    /**
403     * Enable debug comments when in debug mode.
404     *
405     * @since 3.2
406     *
407     * @return static
408     */
409    public function enableDebugComments() : static
410    {
411        $this->showDebugComments = true;
412        return $this;
413    }
414
415    /**
416     * Disable debug comments when in debug mode.
417     *
418     * @since 3.2
419     *
420     * @return static
421     */
422    public function disableDebugComments() : static
423    {
424        $this->showDebugComments = false;
425        return $this;
426    }
427}