Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
114 / 114
100.00% covered (success)
100.00%
26 / 26
CRAP
100.00% covered (success)
100.00%
1 / 1
Stream
100.00% covered (success)
100.00%
114 / 114
100.00% covered (success)
100.00%
26 / 26
52
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 setTemplate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 setMaster
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getBlocks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBlock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMasterBlocks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMasterBlock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setBlocks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setBlock
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setMasterBlocks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setMasterBlock
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getParent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMaster
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCacheDir
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCacheDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCacheDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContributingFiles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 parseParent
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 parseIncludes
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 assertSafeTemplatePath
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 parseBlocks
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
7
 renderTemplate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 renderCompiled
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\View\Template;
16
17/**
18 * View stream template class
19 *
20 * @category   Pop
21 * @package    Pop\View
22 * @author     Nick Sagona, III <nick@popphp.org>
23 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
24 * @license    https://www.popphp.org/license     New BSD License
25 * @version    5.0.0
26 */
27class Stream extends AbstractTemplate
28{
29
30    /**
31     * View template file
32     * @var ?string
33     */
34    protected ?string $file = null;
35
36    /**
37     * View parent template
38     * @var ?Stream
39     */
40    protected ?Stream $parent = null;
41
42    /**
43     * Block templates
44     * @var array
45     */
46    protected array $blocks = [];
47
48    /**
49     * Master template
50     * @var ?string
51     */
52    protected ?string $master = null;
53
54    /**
55     * Master block templates
56     * @var array
57     */
58    protected array $masterBlocks = [];
59
60    /**
61     * Cache directory for compiled templates
62     * @var ?string
63     */
64    protected ?string $cacheDir = null;
65
66    /**
67     * Files that contributed to the resolved template (path => mtime),
68     * across the full @extends/@include chain
69     * @var array
70     */
71    protected array $contributingFiles = [];
72
73    /**
74     * Constructor
75     *
76     * Instantiate the view stream template object
77     *
78     * @param string  $template
79     * @param ?string $cacheDir
80     */
81    public function __construct(string $template, ?string $cacheDir = null)
82    {
83        if ($cacheDir !== null) {
84            $this->setCacheDir($cacheDir);
85        }
86
87        $this->setTemplate($template);
88
89        // Parse parent template
90        $this->parseParent();
91
92        // Parse includes
93        $this->parseIncludes();
94
95        // Parse blocks
96        $this->parseBlocks();
97    }
98
99    /**
100     * Set view template with auto-detect
101     *
102     * @param  string $template
103     * @return static
104     */
105    public function setTemplate(string $template): static
106    {
107        if ((strlen($template) <= 255) && file_exists($template)) {
108            $this->template = file_get_contents($template);
109            $this->file     = $template;
110            $this->contributingFiles[$template] = filemtime($template);
111        } else {
112            $this->template = $template;
113        }
114        return $this;
115    }
116
117    /**
118     * Set master
119     *
120     * @param  string $master
121     * @return static
122     */
123    public function setMaster(string $master): static
124    {
125        $this->master = $master;
126        return $this;
127    }
128
129    /**
130     * Get blocks
131     *
132     * @return array
133     */
134    public function getBlocks(): array
135    {
136        return $this->blocks;
137    }
138
139    /**
140     * Get block by name
141     *
142     * @param  string $name
143     * @return string|null
144     */
145    public function getBlock(string $name): string|null
146    {
147        return $this->blocks[$name] ?? null;
148    }
149
150    /**
151     * Get master blocks
152     *
153     * @return array
154     */
155    public function getMasterBlocks(): array
156    {
157        return $this->masterBlocks;
158    }
159
160    /**
161     * Get master block by name
162     *
163     * @param  string $name
164     * @return string|null
165     */
166    public function getMasterBlock(string $name): string|null
167    {
168        return $this->masterBlocks[$name] ?? null;
169    }
170
171    /**
172     * Set blocks
173     *
174     * @param  array $blocks
175     * @return static
176     */
177    public function setBlocks(array $blocks): static
178    {
179        $this->blocks = $blocks;
180        return $this;
181    }
182
183    /**
184     * Set block
185     *
186     * @param  string $name
187     * @param  string $value
188     * @return static
189     */
190    public function setBlock($name, $value): static
191    {
192        $this->blocks[$name] = $value;
193        return $this;
194    }
195
196    /**
197     * Set master blocks
198     *
199     * @param  array $blocks
200     * @return static
201     */
202    public function setMasterBlocks(array $blocks): static
203    {
204        $this->masterBlocks = $blocks;
205        return $this;
206    }
207
208    /**
209     * Set master block
210     *
211     * @param  string $name
212     * @param  string $value
213     * @return static
214     */
215    public function setMasterBlock(string $name, string $value): static
216    {
217        $this->masterBlocks[$name] = $value;
218        return $this;
219    }
220
221    /**
222     * Get parent
223     *
224     * @return self|null
225     */
226    public function getParent(): self|null
227    {
228        return $this->parent;
229    }
230
231    /**
232     * Get master
233     *
234     * @return string
235     */
236    public function getMaster(): string
237    {
238        return $this->master;
239    }
240
241    /**
242     * Determine if the template stream is from a file
243     *
244     * @return bool
245     */
246    public function isFile(): bool
247    {
248        return ($this->file !== null);
249    }
250
251    /**
252     * Determine if the template stream is from a string
253     *
254     * @return bool
255     */
256    public function isString(): bool
257    {
258        return ($this->file === null);
259    }
260
261    /**
262     * Set cache directory for compiled templates
263     *
264     * @param  string $dir
265     * @return static
266     */
267    public function setCacheDir(string $dir): static
268    {
269        $this->cacheDir = $dir;
270        return $this;
271    }
272
273    /**
274     * Get cache directory
275     *
276     * @return ?string
277     */
278    public function getCacheDir(): ?string
279    {
280        return $this->cacheDir;
281    }
282
283    /**
284     * Has a cache directory been configured
285     *
286     * @return bool
287     */
288    public function hasCacheDir(): bool
289    {
290        return ($this->cacheDir !== null);
291    }
292
293    /**
294     * Get files (path => mtime) that contributed to the resolved template
295     *
296     * @return array
297     */
298    public function getContributingFiles(): array
299    {
300        return $this->contributingFiles;
301    }
302
303    /**
304     * Render the view and return the output
305     *
306     * @param  ?array $data
307     * @return string
308     */
309    public function render(?array $data = null): string
310    {
311        if ($data !== null) {
312            $this->data = $data;
313        }
314
315        if ($this->hasCacheDir()) {
316            $this->renderCompiled();
317        } else {
318            $this->renderTemplate();
319        }
320
321        return $this->output;
322    }
323
324    /**
325     * Parse template parent/child blocks
326     *
327     * @return void
328     */
329    protected function parseParent(): void
330    {
331        $matches = [];
332        preg_match_all('/\{\{\@extends(.*?)\}\}/s', $this->template, $matches);
333
334        if (isset($matches[0][0])) {
335            foreach ($matches[0] as $key => $match) {
336                $tmpl = trim($matches[1][$key]);
337                self::assertSafeTemplatePath($tmpl);
338                if ($tmpl != $this->file) {
339                    $dir            = ($this->isFile()) ? dirname($this->file) . DIRECTORY_SEPARATOR : null;
340                    $this->template = str_replace($match, '', $this->template);
341                    $this->parent   = new Stream($dir . $tmpl);
342                    $this->contributingFiles = array_merge($this->contributingFiles, $this->parent->getContributingFiles());
343                }
344            }
345        }
346    }
347
348    /**
349     * Parse template includes
350     *
351     * @return void
352     */
353    protected function parseIncludes(): void
354    {
355        $matches = [];
356        preg_match_all('/\{\{\@include(.*?)\}\}/s', $this->template, $matches);
357
358        if (isset($matches[0][0])) {
359            foreach ($matches[0] as $key => $match) {
360                $tmpl = trim($matches[1][$key]);
361                self::assertSafeTemplatePath($tmpl);
362                if ($tmpl != $this->file) {
363                    $dir  = ($this->isFile()) ? dirname($this->file) . DIRECTORY_SEPARATOR : null;
364                    $view = new Stream($dir . $tmpl);
365                    $this->template = str_replace($match, $view->getTemplate(), $this->template);
366                    $this->contributingFiles = array_merge($this->contributingFiles, $view->getContributingFiles());
367                }
368            }
369        }
370    }
371
372    /**
373     * Guard against path traversal / absolute-path escapes in @extends/@include targets
374     *
375     * @param  string $tmpl
376     * @throws Stream\Exception
377     * @return void
378     */
379    protected static function assertSafeTemplatePath(string $tmpl): void
380    {
381        if ((str_starts_with($tmpl, '/')) || (str_starts_with($tmpl, '\\')) ||
382            (preg_match('/^[a-zA-Z]:[\/\\\\]/', $tmpl)) ||
383            (preg_match('/(^|[\/\\\\])\.\.($|[\/\\\\])/', $tmpl))) {
384            throw new Stream\Exception(
385                "Error: The @extends/@include template path '" . $tmpl . "' is not allowed " .
386                "(absolute paths and '..' segments are not permitted)."
387            );
388        }
389    }
390
391    /**
392     * Parse template parent/child blocks
393     *
394     * @return void
395     */
396    protected function parseBlocks(): void
397    {
398        $matches = [];
399        preg_match_all('/\{\{(.*?)\{\{\/(.*?)\}\}/s', $this->template, $matches);
400
401        if (isset($matches[0][0])) {
402            foreach ($matches[0] as $match) {
403                $name    = substr($match, 2);
404                $name    = substr($name, 0, strpos($name, '}}'));
405                $content = substr($match, (strpos($match, '}}') + 2));
406                $content = substr($content, 0, strpos($content, '{{/'));
407                $this->blocks[$name] = $content;
408            }
409        }
410
411        $parent = $this->parent;
412
413        if ($parent === null) {
414            $this->setMaster($this->template);
415            $this->setMasterBlocks($this->blocks);
416        }
417
418        while ($parent !== null) {
419            $this->setMaster($parent->getMaster());
420            $this->setMasterBlocks($parent->getMasterBlocks());
421
422            foreach ($this->blocks as $block => $tmpl) {
423                $this->setBlock($block, str_replace('{{parent}}', $parent->getBlock($block), $tmpl));
424            }
425
426            $parent = $parent->getParent();
427        }
428
429        $this->template = $this->master;
430        foreach ($this->blocks as $block => $tmpl) {
431            $this->template = str_replace(
432                '{{' . $block . '}}' . $this->getMasterBlock($block) . '{{/' . $block . '}}',
433                $tmpl,
434                $this->template
435            );
436        }
437    }
438
439    /**
440     * Render view template string
441     *
442     * @return void
443     */
444    protected function renderTemplate(): void
445    {
446        $this->output = $this->template;
447
448        // Parse array values
449        $this->output = Stream\Parser::parseArrays($this->template, $this->data, $this->output);
450
451        // Parse conditionals
452        $this->output = Stream\Parser::parseConditionals($this->template, $this->data, $this->output);
453
454        // Parse scalar values
455        $this->output = Stream\Parser::parseScalars($this->data, $this->output);
456    }
457
458    /**
459     * Render the view template string via the compiled/cached path
460     *
461     * @return void
462     */
463    protected function renderCompiled(): void
464    {
465        $cache = new Stream\Cache($this->cacheDir);
466        $key   = Stream\Cache::key($this->template);
467
468        $newestMtime = empty($this->contributingFiles) ? 0 : max($this->contributingFiles);
469
470        $source = $cache->get($key, $newestMtime);
471        if ($source === null) {
472            $source = Stream\Compiler::compile($this->template);
473            $cache->put($key, $source);
474        }
475
476        $data = $this->data;
477
478        try {
479            ob_start();
480            include $cache->path($key);
481            $this->output = ob_get_clean();
482        } catch (\Throwable $e) {
483            // ob_end_clean() (not ob_clean()) is required here: ob_clean() only empties the buffer's
484            // contents but leaves it on PHP's output-buffer stack, so a caller that catches this
485            // exception and continues execution is left with a dangling output buffer that silently
486            // swallows subsequent unrelated output (ob_get_level() grows unboundedly across repeated
487            // catches). Compiled loop bodies (Phase 2) are the first thing on this path that can throw
488            // at runtime (the ArrayAccess/ArrayObject guards), making this reachable for the first time.
489            ob_end_clean();
490            throw $e;
491        }
492    }
493
494}