Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
16 / 16
CRAP
100.00% covered (success)
100.00%
1 / 1
Context
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
16 / 16
28
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
1
 __call
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 escape
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 layout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 include
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 component
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 slot
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 hasSlot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 section
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 append
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 prepend
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rewrite
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 end
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 yield
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
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\RuntimeException;
10use Celema\Boiler\Proxy\ArrayProxy;
11use Celema\Boiler\Proxy\Proxy;
12use Celema\Boiler\Proxy\StringProxy;
13use Closure;
14use Stringable;
15
16/**
17 * The `$this` of template code: the template helpers and the registered
18 * template methods.
19 *
20 * Templates run in the scope of this object, so a method of any visibility
21 * here would shadow a registered template method of the same name. Keep
22 * helper code in Rendering and Values instead of private methods.
23 *
24 * @api
25 */
26final class Context
27{
28    /** @var list<class-string> */
29    public readonly array $trusted;
30    public readonly bool $autoescape;
31    private readonly Values $values;
32
33    /**
34     * @internal Templates receive their context; they never create one.
35     *
36     * @param array<array-key, mixed> $context
37     * @param bool $wrapped whether the context comes from a template of the render, with the values as that template got them
38     */
39    public function __construct(
40        private readonly Rendering $rendering,
41        array $context,
42        bool $wrapped,
43    ) {
44        $this->trusted = $rendering->trusted;
45        $this->autoescape = $rendering->autoescape;
46        $this->values = new Values($context, $rendering->wrapper, $rendering->autoescape, $wrapped);
47    }
48
49    /** @param array<array-key, mixed> $args */
50    public function __call(string $name, array $args): mixed
51    {
52        $method = $this->rendering->methods->get($name);
53
54        $args = $this->values->wrapper->unwrap($args);
55
56        return $this->values->output(($method->callable)(...$args), safe: $method->safe);
57    }
58
59    /**
60     * @param ArrayProxy|array<array-key, mixed> $values
61     * @return array<array-key, mixed>
62     */
63    public function get(ArrayProxy|array $values = []): array
64    {
65        return $this->values->get($values);
66    }
67
68    public function add(StringProxy|string $key, mixed $value): mixed
69    {
70        return $this->values->add($key, $value);
71    }
72
73    public function escape(Stringable|string $value, StringProxy|string|null $escaper = null): string
74    {
75        // A proxy's string conversion escapes already, so escape its original.
76        $original = $value instanceof Proxy ? $value->unwrap() : $value;
77
78        if (!is_string($original) && !$original instanceof Stringable) {
79            throw new RuntimeException('Value cannot be escaped as string');
80        }
81
82        return $this->values->wrapper->escape(
83            (string) $original,
84            $escaper === null ? null : Values::plain($escaper),
85        );
86    }
87
88    /**
89     * @param non-empty-string|StringProxy $path
90     * @param ArrayProxy|array<array-key, mixed> $context
91     */
92    public function layout(StringProxy|string $path, ArrayProxy|array $context = []): void
93    {
94        $this->rendering->setLayout(new LayoutSpec(Values::plain($path), $this->rendering->location(), $context));
95    }
96
97    /**
98     * Includes another template into the current template.
99     *
100     * If no context is passed it shares the context of the calling template.
101     *
102     * @param non-empty-string|StringProxy $path
103     * @param ArrayProxy|array<array-key, mixed> $context
104     */
105    public function include(StringProxy|string $path, ArrayProxy|array $context = []): void
106    {
107        $file = $this->rendering->engine->resolve(Values::plain($path));
108
109        echo $this->rendering->include($file, $this->get($context));
110    }
111
112    /**
113     * Includes another template with the output up to the matching `end()`
114     * as its slot, which it prints with `$this->slot()`.
115     *
116     * The block runs at the call site, before the included template renders.
117     * If no context is passed it shares the context of the calling template.
118     *
119     * @param non-empty-string|StringProxy $path
120     * @param ArrayProxy|array<array-key, mixed> $context
121     */
122    public function component(StringProxy|string $path, ArrayProxy|array $context = []): void
123    {
124        $path = Values::plain($path);
125        // Resolved before the block runs, so a missing template fails at this call.
126        $file = $this->rendering->engine->resolve($path);
127        $context = $this->get($context);
128
129        $this->rendering->capture('component', $path, function (string $content) use ($file, $context): void {
130            echo $this->rendering->include($file, $context, slot: $content);
131        });
132    }
133
134    /**
135     * Returns what this template wraps: the page in a layout, or the block
136     * passed with `component()`.
137     *
138     * Throws when the template has no slot, such as one included with `include()`.
139     */
140    public function slot(): string
141    {
142        $slot = $this->rendering->slot ?? throw new RuntimeException(
143            'No slot was provided for this template',
144            location: $this->rendering->location(),
145        );
146
147        return $this->hasSlot() ? $slot : '';
148    }
149
150    /**
151     * Whether there is a slot to print. Content of only whitespace counts as
152     * none, and `slot()` returns `''` for it.
153     */
154    public function hasSlot(): bool
155    {
156        return trim($this->rendering->slot ?? '') !== '';
157    }
158
159    /**
160     * Captures the output up to the matching `end()` as the content of a
161     * section, which `yield()` prints.
162     *
163     * In a layout, the capture is a default for the templates it wraps. Any
164     * other second capture fails the render at `end()`.
165     */
166    public function section(StringProxy|string $name): void
167    {
168        $name = Values::plain($name);
169        $this->rendering->capture('section', $name, $this->rendering->sections->capture($name));
170    }
171
172    public function append(StringProxy|string $name): void
173    {
174        $name = Values::plain($name);
175        $this->rendering->capture('section', $name, $this->rendering->sections->append($name));
176    }
177
178    public function prepend(StringProxy|string $name): void
179    {
180        $name = Values::plain($name);
181        $this->rendering->capture('section', $name, $this->rendering->sections->prepend($name));
182    }
183
184    /**
185     * Captures the output up to the matching `end()` as the new content of a
186     * section, replacing what it held so far, including appended and prepended
187     * content. Inside, `yield()` returns that content, so a layout can build
188     * on what the page wrote: `<?= $this->yield('title') ?> â€“ Blog`.
189     */
190    public function rewrite(StringProxy|string $name): void
191    {
192        $name = Values::plain($name);
193        $this->rendering->capture('section', $name, $this->rendering->sections->rewrite($name));
194    }
195
196    /**
197     * Closes the innermost open section or component.
198     *
199     * With a name, the render fails unless that is the section or component
200     * being closed, as passed to `section()` or `component()`.
201     */
202    public function end(StringProxy|string|null $name = null): void
203    {
204        $this->rendering->close($name === null ? null : Values::plain($name));
205    }
206
207    /**
208     * Returns the content of a section: what was prepended, the main content
209     * or else the default, and what was appended.
210     *
211     * Without a default the section is required and a missing section throws;
212     * pass a default, even `''`, when it is optional. A closure default prints
213     * its content, such as an include, and runs only when no main content was
214     * captured.
215     *
216     * @param Stringable|string|Closure(): mixed|null $default
217     */
218    public function yield(StringProxy|string $name, Stringable|string|Closure|null $default = null): string
219    {
220        $name = Values::plain($name);
221        $sections = $this->rendering->sections;
222
223        if ($default instanceof Stringable) {
224            $default = (string) $default;
225        }
226
227        if ($sections->writing($name)) {
228            throw new LogicException(
229                "Section `{$name}` cannot be printed inside a block that writes it; build on its content with rewrite()",
230                location: $this->rendering->location(),
231            );
232        }
233
234        if ($default === null && !$sections->has($name)) {
235            throw new LookupException(
236                "Section `{$name}` is not defined; pass a default, even '', when it is optional",
237                location: $this->rendering->location(),
238            );
239        }
240
241        $content = $sections->getOr(
242            $name,
243            $default instanceof Closure ? fn(): string => $this->rendering->output($default) : $default ?? '',
244        );
245
246        // Like slot(), content of only whitespace counts as none, so the result
247        // tells whether there is anything to print.
248        return trim($content) === '' ? '' : $content;
249    }
250}