Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
21 / 21
CRAP
100.00% covered (success)
100.00%
1 / 1
Context
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
21 / 21
40
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 wrapAll
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 unwrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
3 / 3
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
4
 wrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 wrappedContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 templateValue
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 layout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 insert
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 slot
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 hasSlot
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 begin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 append
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepend
100.00% covered (success)
100.00%
1 / 1
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
1
 section
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 location
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Boiler;
6
7use Celemas\Boiler\Contract\Wrapper;
8use Celemas\Boiler\Exception\RuntimeException;
9use Celemas\Boiler\Proxy\ObjectProxy;
10use Celemas\Boiler\Proxy\Proxy;
11use Celemas\Boiler\Proxy\StringProxy;
12use Closure;
13use Stringable;
14
15/** @api */
16abstract class Context
17{
18    /** @var array<array-key, mixed>|null */
19    private ?array $wrappedContext = null;
20    protected readonly Wrapper $wrapper;
21    private readonly bool $hasTrusted;
22
23    /**
24     * @param list<class-string> $trusted
25     */
26    public function __construct(
27        protected readonly BaseTemplate $template,
28        protected array $context,
29        public readonly array $trusted,
30        public readonly bool $autoescape,
31    ) {
32        $this->wrapper = $template->engine->wrapper();
33        $this->hasTrusted = $trusted !== [];
34    }
35
36    public function __call(string $name, array $args): mixed
37    {
38        $method = $this->template->methods()->get($name);
39
40        /** @var array<array-key, mixed> $args */
41        $args = $this->unwrap($args);
42
43        return $this->templateValue(($method->callable)(...$args), safe: $method->safe);
44    }
45
46    public function get(array $values = []): array
47    {
48        if (!$this->autoescape) {
49            return $values === []
50                ? $this->context
51                : array_merge($this->context, $values);
52        }
53
54        if ($values === []) {
55            return $this->wrappedContext();
56        }
57
58        return array_merge($this->wrappedContext(), $this->wrapAll($values));
59    }
60
61    /**
62     * @param array<array-key, mixed> $values
63     * @return array<array-key, mixed>
64     */
65    protected function wrapAll(array $values): array
66    {
67        $wrapped = [];
68
69        /** @var mixed $value */
70        foreach ($values as $key => $value) {
71            if ($value instanceof Proxy) {
72                $wrapped[$key] = $value;
73
74                continue;
75            }
76
77            if ($this->hasTrusted && is_object($value)) {
78                foreach ($this->trusted as $trustedClass) {
79                    if (!$value instanceof $trustedClass) {
80                        continue;
81                    }
82
83                    $wrapped[$key] = $value;
84
85                    continue 2;
86                }
87            }
88
89            /** @psalm-suppress MixedAssignment wrapper returns mixed by design */
90            $wrapped[$key] = $this->wrapper->wrap($value);
91        }
92
93        return $wrapped;
94    }
95
96    public function unwrap(mixed $value): mixed
97    {
98        return $this->wrapper->unwrap($value);
99    }
100
101    public function add(string $key, mixed $value): mixed
102    {
103        $this->context[$key] = $value;
104        $this->wrappedContext = null;
105
106        return $this->templateValue($value);
107    }
108
109    public function escape(
110        StringProxy|ObjectProxy|string|Stringable $value,
111        ?string $escaper = null,
112    ): string {
113        if ($value instanceof StringProxy) {
114            return $this->wrapper->escape($value->unwrap(), $escaper);
115        }
116
117        if ($value instanceof ObjectProxy) {
118            $value = $value->unwrap();
119
120            if (!$value instanceof Stringable) {
121                throw new RuntimeException('Value cannot be escaped as string');
122            }
123        }
124
125        return $this->wrapper->escape((string) $value, $escaper);
126    }
127
128    public function wrap(mixed $value): mixed
129    {
130        return $this->wrapper->wrap($value);
131    }
132
133    /** @return array<array-key, mixed> */
134    private function wrappedContext(): array
135    {
136        return $this->wrappedContext ??= $this->wrapAll($this->context);
137    }
138
139    private function templateValue(mixed $value, bool $safe = false): mixed
140    {
141        if (!$this->autoescape) {
142            return $this->wrapper->unwrap($value);
143        }
144
145        if (!$safe) {
146            return $this->wrapper->wrap($value);
147        }
148
149        if ($value instanceof StringProxy) {
150            return StringProxy::safe($value->unwrap(), $this->wrapper);
151        }
152
153        if (is_string($value) || $value instanceof Stringable) {
154            return StringProxy::safe((string) $value, $this->wrapper);
155        }
156
157        throw new RuntimeException('Safe template methods must return string or Stringable values');
158    }
159
160    /**
161     * @param non-empty-string $path
162     */
163    public function layout(string $path, array $context = []): void
164    {
165        $this->template->setLayout(new LayoutSpec($path, $this->location(), $context));
166    }
167
168    /**
169     * Includes another template into the current template.
170     *
171     * If no context is passed it shares the context of the calling template.
172     *
173     * The optional slot is a block of markup the inserted template can place,
174     * and repeat, by calling `$this->slot([...])`. A closure receives the per-call
175     * data as its argument and either echoes or returns markup. A `Slot::template()`
176     * slot renders another template with the per-call data merged into its context.
177     * Slot values are raw, so escape them like any other template data.
178     *
179     * @param non-empty-string $path
180     */
181    public function insert(string $path, array $context = [], Closure|Slot|null $slot = null): void
182    {
183        $path = $this->template->engine->resolve($path);
184        $template = new Template(
185            $path,
186            sections: $this->template->sections,
187            engine: $this->template->engine,
188        );
189
190        $template->setMethods($this->template->methods());
191        $template->setSlot($slot, $this, $this->location());
192
193        echo
194            $this->autoescape
195                ? $template->renderEscaped($this->get($context), $this->trusted)
196                : $template->renderUnescaped($this->get($context), $this->trusted)
197        ;
198    }
199
200    /**
201     * Renders the slot passed to this template via `insert(..., slot: ...)`.
202     *
203     * Call it once for a simple slot, or once per row to repeat the block with
204     * different data. Throws when the template was inserted without a slot;
205     * guard with `hasSlot()` when a slot is optional.
206     *
207     * @param array<array-key, mixed> $data
208     */
209    public function slot(array $data = []): void
210    {
211        echo
212            ($this->template->slot() ?? throw new RuntimeException(
213                'No slot was provided for this template',
214                location: $this->location(),
215            ))->render($data)
216        ;
217    }
218
219    public function hasSlot(): bool
220    {
221        return $this->template->slot() !== null;
222    }
223
224    public function begin(string $name): void
225    {
226        $this->template->sections->begin($name, $this->location());
227    }
228
229    public function append(string $name): void
230    {
231        $this->template->sections->append($name, $this->location());
232    }
233
234    public function prepend(string $name): void
235    {
236        $this->template->sections->prepend($name, $this->location());
237    }
238
239    public function end(): void
240    {
241        $this->template->sections->end();
242    }
243
244    public function section(string $name, string $default = ''): string
245    {
246        if (func_num_args() > 1) {
247            return $this->template->sections->getOr($name, $default);
248        }
249
250        return $this->template->sections->get($name);
251    }
252
253    public function has(string $name): bool
254    {
255        return $this->template->sections->has($name);
256    }
257
258    private function location(): Location
259    {
260        return Location::fromBacktrace($this->template->path);
261    }
262}