Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
84 / 84
100.00% covered (success)
100.00%
15 / 15
CRAP
100.00% covered (success)
100.00%
1 / 1
BaseTemplate
100.00% covered (success)
100.00%
84 / 84
100.00% covered (success)
100.00%
15 / 15
30
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 render
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderEscaped
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderUnescaped
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLayout
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setMethods
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 methods
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSlot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 slot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderIsolated
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 resetRenderState
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 context
n/a
0 / 0
n/a
0 / 0
0
 renderTemplate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getContent
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
5
 renderLayouts
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 throwLayoutException
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Boiler;
6
7use Celemas\Boiler\Exception\LookupException;
8use Celemas\Boiler\Exception\RenderException;
9use Celemas\Boiler\Exception\RuntimeException;
10use Celemas\Boiler\Exception\UnexpectedValueException;
11use Closure;
12use Throwable;
13
14abstract class BaseTemplate
15{
16    private ?LayoutSpec $layout = null;
17    private Methods $methods;
18    private ?SlotRenderer $slot = null;
19    private readonly bool $ownsSections;
20
21    public private(set) Engine $engine {
22        get => $this->engine;
23        set(Engine $value) => $this->engine = $value;
24    }
25    /** @internal */
26    public private(set) Sections $sections {
27        get => $this->sections;
28        set(Sections $value) => $this->sections = $value;
29    }
30
31    public function __construct(
32        public readonly string $path,
33        ?Sections $sections = null,
34        ?Engine $engine = null,
35    ) {
36        $this->ownsSections = $sections === null;
37        $this->sections = $sections ?? new Sections();
38        $this->methods = new Methods();
39
40        if ($engine === null) {
41            $dir = dirname($path);
42
43            if ($dir === '' || $path === '') {
44                throw new LookupException('No directory given or empty path');
45            }
46
47            $this->engine = new Engine(new Resolver($dir), new Environment(), true);
48
49            if (!is_file($path)) {
50                throw new LookupException('Template not found: ' . $path);
51            }
52
53            return;
54        }
55
56        $this->engine = $engine;
57    }
58
59    /**
60     * @param list<class-string> $trusted
61     * @psalm-suppress PossiblyUnusedMethod Called through inherited API on concrete templates
62     */
63    public function render(array $context = [], array $trusted = []): string
64    {
65        return $this->renderIsolated($context, $trusted, autoescape: $this->engine->autoescape);
66    }
67
68    /** @param list<class-string> $trusted */
69    public function renderEscaped(array $context = [], array $trusted = []): string
70    {
71        return $this->renderIsolated($context, $trusted, autoescape: true);
72    }
73
74    /** @param list<class-string> $trusted */
75    public function renderUnescaped(array $context = [], array $trusted = []): string
76    {
77        return $this->renderIsolated($context, $trusted, autoescape: false);
78    }
79
80    /**
81     * Defines a layout template that will be wrapped around this instance.
82     *
83     * Typically it’s placed at the top of the file.
84     *
85     * @internal
86     */
87    public function setLayout(LayoutSpec $layout): void
88    {
89        if ($this->layout === null) {
90            $this->layout = $layout;
91
92            return;
93        }
94
95        throw new RuntimeException('Template error: layout already set');
96    }
97
98    /** @internal */
99    public function setMethods(Methods $methods): void
100    {
101        $this->methods = $methods;
102    }
103
104    /** @internal */
105    public function methods(): Methods
106    {
107        return $this->methods;
108    }
109
110    /** @internal */
111    public function setSlot(
112        Closure|Slot|null $slot,
113        Context $context,
114        Location $location,
115    ): void {
116        $this->slot = $slot === null ? null : new SlotRenderer($slot, $context, $location);
117    }
118
119    /** @internal */
120    public function slot(): ?SlotRenderer
121    {
122        return $this->slot;
123    }
124
125    /** @param list<class-string> $trusted */
126    private function renderIsolated(array $context, array $trusted, bool $autoescape): string
127    {
128        $this->resetRenderState();
129
130        try {
131            return $this->renderTemplate($context, $trusted, $autoescape);
132        } finally {
133            $this->resetRenderState();
134        }
135    }
136
137    private function resetRenderState(): void
138    {
139        $this->layout = null;
140
141        if ($this->ownsSections) {
142            $this->sections = new Sections();
143        }
144    }
145
146    /** @param list<class-string> $trusted */
147    abstract protected function context(
148        array $context,
149        array $trusted,
150        bool $autoescape,
151    ): Context;
152
153    /** @param list<class-string> $trusted */
154    final protected function renderTemplate(array $context, array $trusted, bool $autoescape): string
155    {
156        $content = $this->getContent($context, $trusted, $autoescape);
157
158        if ($this instanceof Layout) {
159            return $content->content;
160        }
161
162        return $this->renderLayouts(
163            $this,
164            $content->templateContext,
165            $trusted,
166            $content->content,
167            $autoescape,
168        );
169    }
170
171    /** @param list<class-string> $trusted */
172    private function getContent(array $context, array $trusted, bool $autoescape): Content
173    {
174        $templateContext = $this->context($context, $trusted, $autoescape);
175
176        /** @mago-expect lint:prefer-static-closure Closure::call() binds $this to the template context at runtime. */
177        $load = function (string $templatePath, array $context = []): void {
178            // Must stay non-static so Closure::call() can bind $this to the template context.
179            // Hide $templatePath. Could be overwritten if $context['templatePath'] exists.
180            $____template_path____ = $templatePath;
181
182            extract($context, EXTR_SKIP);
183
184            /** @psalm-suppress UnresolvableInclude */
185            include $____template_path____;
186        };
187
188        $level = ob_get_level();
189        $sections = $this->sections->checkpoint();
190
191        try {
192            ob_start();
193
194            $load->call(
195                $templateContext,
196                $this->path,
197                $autoescape
198                    ? $templateContext->get()
199                    : $context,
200            );
201            $this->sections->assertClosed($sections);
202
203            $content = (string) ob_get_clean();
204
205            return new Content($content, $templateContext);
206        } catch (RenderException $e) {
207            throw $e;
208        } catch (Throwable $e) {
209            throw RenderException::fromThrowable($this->path, $e);
210        } finally {
211            while (ob_get_level() > $level) {
212                ob_end_clean();
213            }
214        }
215    }
216
217    /** @param list<class-string> $trusted */
218    private function renderLayouts(
219        BaseTemplate $template,
220        Context $context,
221        array $trusted,
222        string $content,
223        bool $autoescape,
224    ): string {
225        while (($layout = $template->layout) !== null) {
226            try {
227                $file = $template->engine->resolve($layout->path);
228            } catch (LookupException|UnexpectedValueException $e) {
229                self::throwLayoutException($layout, $e);
230            }
231
232            $methods = $template->methods();
233            $template = new Layout(
234                $file,
235                $content,
236                $this->sections,
237                $template->engine,
238            );
239            $template->setMethods($methods);
240
241            $layoutContext = $context->get($layout->context);
242
243            $content = $template->renderTemplate($layoutContext, $trusted, $autoescape);
244        }
245
246        return $content;
247    }
248
249    private static function throwLayoutException(
250        LayoutSpec $layout,
251        LookupException|UnexpectedValueException $exception,
252    ): never {
253        $location = $layout->location;
254        $message = $exception->getMessage() . " (referenced at {$location})";
255
256        if ($exception instanceof LookupException) {
257            throw new LookupException($message, $exception->getCode(), $exception, $location);
258        }
259
260        throw new UnexpectedValueException($message, $exception->getCode(), $exception, $location);
261    }
262}