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