Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
87 / 87
100.00% covered (success)
100.00%
17 / 17
CRAP
100.00% covered (success)
100.00%
1 / 1
Block
100.00% covered (success)
100.00%
87 / 87
100.00% covered (success)
100.00%
17 / 17
44
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 uid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 handle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 layout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSplit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 blocks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 split
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 meta
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 padding
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 json
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 raw
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 __get
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 render
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 renderWith
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
10
 resolve
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Value;
6
7use Closure;
8use Cosray\Block\Layout;
9use Cosray\Block\RenderContext;
10use Cosray\Contract\Block as BlockType;
11use Cosray\Exception\NoSuchProperty;
12use Cosray\Field;
13use Cosray\Markup;
14use Override;
15
16use function Cosray\escape;
17
18/**
19 * One row of a Blocks field: its type, grid placement, block meta and
20 * the type's fields, reachable as properties like on an entry. A split
21 * has no type and no fields; it holds blocks laid out on its own area.
22 *
23 * @property-read Field\Blocks $field
24 */
25class Block extends Value
26{
27    /** @var array<string, Field\Field> */
28    protected array $fields = [];
29
30    /** @var list<Block> */
31    protected array $blocks = [];
32
33    protected readonly Layout $layout;
34
35    /** The field grid's column count, which a split's blocks share. */
36    protected readonly int $columns;
37
38    /**
39     * @param class-string<BlockType>|null $type null for a split
40     * @param ?Layout $area the split a block sits in, which bounds its layout
41     * @param ?int $columns the grid's column count, the field's default without one
42     */
43    public function __construct(
44        Field\Owner $owner,
45        Field\Blocks $field,
46        ValueContext $context,
47        public readonly ?string $type,
48        ?Layout $area = null,
49        ?int $columns = null,
50    ) {
51        parent::__construct($owner, $field, $context);
52
53        $this->columns = $columns ?? $field->getColumns();
54        $min = $field->minOf($this->columns);
55        $this->layout = $area === null
56            ? Layout::normalize($this->data['layout'] ?? null, $this->columns, $min)
57            : Layout::normalize($this->data['layout'] ?? null, $area->colspan, $min, $area->rowspan);
58
59        if ($type !== null) {
60            $data = $this->data['fields'] ?? [];
61            $this->fields = $field->blockFieldsFor($type, is_array($data) ? $data : []);
62
63            return;
64        }
65
66        foreach (is_array($this->data['blocks'] ?? null) ? $this->data['blocks'] : [] as $row) {
67            $childType = is_array($row) ? $row['type'] ?? null : null;
68
69            if (is_string($childType) && $field->allows($childType)) {
70                $this->blocks[] = new self(
71                    $owner,
72                    $field,
73                    new ValueContext($this->fieldName, $row),
74                    $childType,
75                    $this->layout,
76                    $this->columns,
77                );
78            }
79        }
80    }
81
82    #[Override]
83    public function __toString(): string
84    {
85        return (string) $this->render();
86    }
87
88    public function uid(): ?string
89    {
90        $uid = $this->data['uid'] ?? null;
91
92        return is_string($uid) ? $uid : null;
93    }
94
95    /** The type's `data-type` value; a split has none. */
96    public function handle(): ?string
97    {
98        return $this->type === null ? null : $this->field->blockHandle($this->type);
99    }
100
101    public function layout(): Layout
102    {
103        return $this->layout;
104    }
105
106    public function isSplit(): bool
107    {
108        return $this->type === null;
109    }
110
111    /** @return list<Block> a split's blocks in reading order, empty for a block */
112    public function blocks(): array
113    {
114        return $this->blocks;
115    }
116
117    /**
118     * `columns` when a split's blocks sit side by side, `rows` when they
119     * are stacked, read off the blocks since the direction is not stored.
120     */
121    public function split(): ?string
122    {
123        if ($this->blocks === []) {
124            return null;
125        }
126
127        return $this->blocks[0]->layout->colspan < $this->layout->colspan ? 'columns' : 'rows';
128    }
129
130    /** A block-level meta entry, such as `class` or `id`. */
131    #[Override]
132    public function meta(string $key, mixed $default = null): mixed
133    {
134        return parent::meta($key, $default);
135    }
136
137    /** The block's padding as a spacing token, `null` for the site's default. */
138    public function padding(): ?string
139    {
140        $value = $this->meta('padding');
141
142        return is_string($value) && in_array($value, Field\Blocks::SPACING, true) ? $value : null;
143    }
144
145    /** @return array<string, mixed> with the block type's handle as `type`, null for a split */
146    #[Override]
147    public function json(): array
148    {
149        $json = $this->resolve(static fn(Value $value): mixed => $value->json());
150        $json['type'] = $json['handle'];
151        unset($json['handle']);
152
153        return $json;
154    }
155
156    /** @return array<string, mixed> */
157    #[Override]
158    public function raw(): array
159    {
160        return $this->resolve(static fn(Value $value): mixed => $value->raw());
161    }
162
163    #[Override]
164    public function isset(): bool
165    {
166        return $this->type === null ? $this->blocks !== [] : count($this->fields) > 0;
167    }
168
169    #[Override]
170    public function __get(string $name): mixed
171    {
172        if (isset($this->fields[$name])) {
173            return $this->fields[$name]->value();
174        }
175
176        throw new NoSuchProperty("Block doesn't have field '{$name}'");
177    }
178
179    /**
180     * This block alone, wrapper element included. Supports the same
181     * arguments as `Blocks::render()`.
182     */
183    public function render(mixed ...$args): Markup
184    {
185        $ctx = new RenderContext($this->owner, $this->fieldName, $this->columns, $args);
186
187        return new Markup($this->renderWith($ctx, $this->field->services()->blocks->cached($this->owner)));
188    }
189
190    /**
191     * The rendering contract: `{prefix}-block` plus the meta class, the
192     * meta id, `data-type` â€” or `data-split` for a split, whose blocks
193     * follow inside â€”, the layout as data attributes and custom
194     * properties, then the type's output. An empty output emits no
195     * element, so a block whose asset is gone leaves no grid cell.
196     *
197     * @param Closure(class-string<BlockType>): BlockType $types
198     */
199    public function renderWith(RenderContext $ctx, Closure $types): string
200    {
201        if ($this->type === null) {
202            $inner = '';
203
204            foreach ($this->blocks as $block) {
205                $inner .= $block->renderWith($ctx, $types);
206            }
207        } else {
208            $inner = $types($this->type)->render($this, $ctx);
209        }
210
211        if ($inner === '') {
212            return '';
213        }
214
215        $class = $ctx->prefix() . '-block';
216        $styleClass = $this->styleClass();
217
218        if ($styleClass !== null) {
219            $class .= ' ' . $styleClass;
220        }
221
222        $attributes = ' class="' . escape($class) . '"';
223        $id = $this->elementId();
224
225        if ($id !== null) {
226            $attributes .= ' id="' . escape($id) . '"';
227        }
228
229        $attributes .= $this->type === null
230            ? ' data-split="' . escape((string) $this->split()) . '"'
231            : ' data-type="' . escape((string) $this->handle()) . '"';
232        $layout = $this->layout;
233        // A block on the field's grid carries the lines it starts at; the
234        // blocks of a split follow each other on its area.
235        $position = $layout->placed() ? " data-col=\"{$layout->col}\" data-row=\"{$layout->row}\"" : '';
236        $style =
237            "--colspan: {$layout->colspan}; --rowspan: {$layout->rowspan}"
238            . ($layout->placed() ? "; --col: {$layout->col}; --row: {$layout->row}" : '');
239        $attributes .=
240            " data-colspan=\"{$layout->colspan}\" data-rowspan=\"{$layout->rowspan}\"{$position}"
241            . ($this->padding() !== null ? ' data-padding="' . escape((string) $this->padding()) . '"' : '')
242            . " style=\"{$style}\"";
243
244        return "<div{$attributes}>{$inner}</div>";
245    }
246
247    /**
248     * @param callable(Value): mixed $resolve
249     * @return array{uid: ?string, type: ?string, handle: ?string, layout: array{colspan: int, rowspan: int, col?: int, row?: int}, fields?: array<string, mixed>, blocks?: list<array<string, mixed>>, meta: array<mixed>}
250     */
251    private function resolve(callable $resolve): array
252    {
253        $meta = $this->data['meta'] ?? [];
254        $result = [
255            'uid' => $this->uid(),
256            'type' => $this->type,
257            'handle' => $this->handle(),
258            'layout' => $this->layout->array(),
259        ];
260
261        if ($this->type === null) {
262            $result['blocks'] = array_map(static fn(Block $block): array => $block->resolve($resolve), $this->blocks);
263        } else {
264            $result['fields'] = array_map(static fn(Field\Field $field): mixed => $resolve(
265                $field->value(),
266            ), $this->fields);
267        }
268
269        $result['meta'] = is_array($meta) ? $meta : [];
270
271        return $result;
272    }
273}