Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
Rendering
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
11 / 11
23
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 include
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
 location
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 capture
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 close
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 output
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 with
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 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 Celema\Boiler;
6
7use Celema\Boiler\Exception\LogicException;
8use Celema\Boiler\Exception\LookupException;
9use Celema\Boiler\Exception\RenderException;
10use Celema\Boiler\Exception\RuntimeException;
11use Celema\Boiler\Exception\UnexpectedValueException;
12use Closure;
13use Stringable;
14use Throwable;
15
16/**
17 * One run of a template file during a render: the rendered template, one of
18 * its layouts, or an included template. It holds the state that the
19 * template's helpers act on and shares the rest with the other runs of the
20 * render. A new render starts new runs, so a Template can render again, even
21 * from within its own render.
22 *
23 * @internal
24 */
25final class Rendering
26{
27    /** the blocks open in this run, once it opened one */
28    private ?Blocks $blocks = null;
29    private ?LayoutSpec $layout = null;
30
31    /**
32     * @mago-expect lint:excessive-parameter-list The runs of a render share all but the path and slot, which with() copies.
33     *
34     * @param list<class-string> $trusted
35     * @param Contract\Wrapper $wrapper the engine's wrapper, set to trust those classes
36     * @param ?string $slot what the template prints with `$this->slot()`: a layout's page or a component's block
37     */
38    public function __construct(
39        public readonly string $path,
40        public readonly Engine $engine,
41        public readonly Methods $methods,
42        public readonly Sections $sections,
43        public readonly array $trusted,
44        public readonly Contract\Wrapper $wrapper,
45        public readonly bool $autoescape,
46        public readonly ?string $slot = null,
47    ) {}
48
49    /**
50     * Runs the template and wraps its output in its layouts, innermost first.
51     * A template can appear only once in the chain, as a layout that wraps a
52     * template of its own chain would wrap forever.
53     *
54     * @param array<array-key, mixed> $context
55     * @param bool $wrapped whether the context comes from a template of this render, as that of an include does
56     */
57    public function render(array $context, bool $wrapped): string
58    {
59        $rendering = $this;
60        $content = $this->run($context, $wrapped);
61        $chain = [$this->path];
62
63        while (($layout = $rendering->layout) !== null) {
64            try {
65                $file = $this->engine->resolve($layout->path);
66            } catch (LookupException|UnexpectedValueException $e) {
67                self::throwLayoutException($layout, $e);
68            }
69
70            if (in_array($file, $chain, true)) {
71                throw new LogicException(
72                    "Layout cycle: `{$layout->path}` is already in the layout chain (referenced at {$layout->location})",
73                    location: $layout->location,
74                );
75            }
76
77            $chain[] = $file;
78            $rendering = $this->with($file, $content->content);
79            $this->sections->enterLayout();
80            // A layout builds on the context of the template it wraps, like an
81            // included template builds on the context of the template that calls it.
82            $content = $rendering->run($content->templateContext->get($layout->context), wrapped: true);
83        }
84
85        return $content->content;
86    }
87
88    /**
89     * Renders another template file within this render, for an include or a
90     * component.
91     *
92     * @param non-empty-string $file
93     * @param array<array-key, mixed> $context
94     */
95    public function include(string $file, array $context, ?string $slot = null): string
96    {
97        return $this->sections->nest(fn(): string => $this->with($file, $slot)->render($context, wrapped: true));
98    }
99
100    /**
101     * Defines the layout that wraps this run's output.
102     */
103    public function setLayout(LayoutSpec $layout): void
104    {
105        if ($this->layout === null) {
106            $this->layout = $layout;
107
108            return;
109        }
110
111        throw new RuntimeException('Template error: layout already set');
112    }
113
114    /** Where the template's code currently runs, for errors raised by helpers. */
115    public function location(): Location
116    {
117        return Location::fromBacktrace($this->path);
118    }
119
120    /**
121     * Captures the template's output up to the matching `end()`.
122     *
123     * @param Closure(string, Location): void $close receives the captured output and where the block opened
124     */
125    public function capture(string $kind, string $name, Closure $close): void
126    {
127        ($this->blocks ??= new Blocks())->open($kind, $name, $this->location(), $close);
128    }
129
130    /** Closes the innermost block that this run has open, which must have that name if one is given. */
131    public function close(?string $name): void
132    {
133        ($this->blocks ?? new Blocks())->close($name);
134    }
135
136    /**
137     * Returns what a closure of the template prints. It must print rather
138     * than return, like a block, so a returned string fails instead of
139     * vanishing.
140     *
141     * @param Closure(): mixed $print
142     */
143    public function output(Closure $print): string
144    {
145        $level = ob_get_level();
146
147        try {
148            ob_start();
149            /** @var mixed $result */
150            $result = $print();
151            $output = (string) ob_get_clean();
152        } finally {
153            while (ob_get_level() > $level) {
154                ob_end_clean();
155            }
156        }
157
158        if (is_string($result) || $result instanceof Stringable) {
159            throw new UnexpectedValueException(
160                'A closure passed to yield() must print its content, not return it',
161                location: $this->location(),
162            );
163        }
164
165        return $output;
166    }
167
168    private function with(string $path, ?string $slot): self
169    {
170        return new self(
171            $path,
172            $this->engine,
173            $this->methods,
174            $this->sections,
175            $this->trusted,
176            $this->wrapper,
177            $this->autoescape,
178            $slot,
179        );
180    }
181
182    /** @param array<array-key, mixed> $context */
183    private function run(array $context, bool $wrapped): Content
184    {
185        $templateContext = new Context($this, $context, $wrapped);
186
187        /** @mago-expect lint:prefer-static-closure Closure::call() binds $this to the template context at runtime. */
188        $load = function (string $____template_path____, array $____template_context____): void {
189            // Must stay non-static so Closure::call() can bind $this to the template context.
190            // extract() skips names that already exist, so the parameter names
191            // are obscure to leave common names such as `$context` to the caller.
192            extract($____template_context____, EXTR_SKIP);
193
194            /** @psalm-suppress UnresolvableInclude the template file is known only at render time */
195            include $____template_path____;
196        };
197
198        $level = ob_get_level();
199
200        try {
201            ob_start();
202
203            $load->call(
204                $templateContext,
205                $this->path,
206                $this->autoescape
207                    ? $templateContext->get()
208                    : $context,
209            );
210            $this->blocks?->assertClosed();
211
212            $content = (string) ob_get_clean();
213
214            return new Content($content, $templateContext);
215        } catch (RenderException $e) {
216            throw $e;
217        } catch (Throwable $e) {
218            throw RenderException::fromThrowable($this->path, $e);
219        } finally {
220            while (ob_get_level() > $level) {
221                ob_end_clean();
222            }
223        }
224    }
225
226    private static function throwLayoutException(
227        LayoutSpec $layout,
228        LookupException|UnexpectedValueException $exception,
229    ): never {
230        $location = $layout->location;
231        $message = $exception->getMessage() . " (referenced at {$location})";
232
233        if ($exception instanceof LookupException) {
234            throw new LookupException($message, $exception->getCode(), $exception, $location);
235        }
236
237        throw new UnexpectedValueException($message, $exception->getCode(), $exception, $location);
238    }
239}