Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
289 / 289
100.00% covered (success)
100.00%
35 / 35
CRAP
100.00% covered (success)
100.00%
1 / 1
Blocks
100.00% covered (success)
100.00%
289 / 289
100.00% covered (success)
100.00%
35 / 35
121
100.00% covered (success)
100.00%
1 / 1
 control
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 metaControl
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 blockMetaControl
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 spacingControl
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 metaShape
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 spacingShape
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 allow
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 common
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 commonSelection
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 allows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allowedBlockTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 blockFields
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 blockFieldsFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 blockHandle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportedTranslateModes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 value
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 columnsOf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 storedColumns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
 minOf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 structure
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 shape
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
6
 reviewGrid
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
15
 rowKind
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertRowField
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 configureRowField
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 blockTypeProperties
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 inBlock
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 structureMap
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 rowStructures
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 blockStructure
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 splitStructure
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
 rowsShape
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 reviewRows
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
9
 reviewChildren
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 reviewSplit
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Field;
6
7use Celema\Sire\Extra;
8use Celema\Sire\Review;
9use Celema\Sire\Shape;
10use Cosray\Block\Layout;
11use Cosray\Block\Placement;
12use Cosray\Contract\Block;
13use Cosray\Exception\RuntimeException;
14use Cosray\Schema\Tool;
15use Cosray\Schema\Tools;
16use Cosray\Schema\TranslateMode;
17use Cosray\Validation\Prepare;
18use Cosray\Validation\Shapes;
19use Cosray\Value\Blocks as BlocksValue;
20use Override;
21
22/**
23 * A list of typed rows with a grid layout. Block types are classes
24 * implementing Cosray\Contract\Block; without `#[Allows]` the field
25 * offers the registry's default list, without `#[Columns]` it is a
26 * stacked one-column list.
27 *
28 * The column count is stored with the value (`columns` next to `value`
29 * and `meta`): the blocks' positions only mean something on the grid
30 * they were placed on. `#[Columns]` is the default a new value starts
31 * with, so changing it leaves stored content alone.
32 */
33class Blocks extends Field implements
34    Capability\Translatable,
35    Capability\Blocks\Resizable,
36    Capability\ToolsAware,
37    Capability\Searchable
38{
39    use Capability\IsSearchable;
40    use Capability\IsTranslatable;
41    use Capability\Blocks\IsResizable;
42    use Capability\IsToolsAware;
43    use RowTypes;
44
45    public const int COMMON_LIMIT = 5;
46    public const int MAX_COLUMNS = 25;
47
48    /** The spacing tokens the gap and padding settings choose from; unset means the site's default. */
49    public const array SPACING = ['none', 's', 'm', 'l', 'xl'];
50
51    /** @var list<class-string<Block>> */
52    protected array $allowedBlockTypes = [];
53
54    /** @var list<class-string<Block>> */
55    protected array $commonTypes = [];
56
57    #[Override]
58    public function control(): Control
59    {
60        return Control::blocks()
61            ->prop('commonTypes', self::commonSelection($this->commonTypes, $this->allowedBlockTypes(), $this->name))
62            ->prop('blockTypes', array_map($this->blockTypeProperties(...), $this->allowedBlockTypes()))
63            ->prop('columns', $this->columns)
64            ->prop('min', $this->min)
65            ->prop('responsive', $this->responsive->value)
66            ->prop('meta', $this->blockMetaControl());
67    }
68
69    /**
70     * The field's own settings: the gap between blocks, as one value or
71     * split by axis. The panel renders the split as a toggle.
72     */
73    #[Override]
74    public function metaControl(): ?Control
75    {
76        return Control::group([
77            ['key' => 'gap', 'label' => __('field:gap'), 'control' => self::spacingControl()],
78            ['key' => 'rowGap', 'label' => __('field:row-gap'), 'control' => self::spacingControl()],
79            ['key' => 'columnGap', 'label' => __('field:column-gap'), 'control' => self::spacingControl()],
80        ]);
81    }
82
83    /**
84     * The block meta dialog: the same group for every block type, patched
85     * on save like a field's meta.
86     */
87    protected function blockMetaControl(): Control
88    {
89        return Control::group([
90            ['key' => 'class', 'label' => __('block:class'), 'control' => Control::text()],
91            ['key' => 'id', 'label' => __('block:id'), 'control' => Control::text()],
92            ['key' => 'padding', 'label' => __('field:padding'), 'control' => self::spacingControl()],
93        ]);
94    }
95
96    /** An option control over the spacing tokens, the site's default first. */
97    public static function spacingControl(): Control
98    {
99        $labels = [
100            'none' => __('field:spacing-none'),
101            's' => __('field:spacing-s'),
102            'm' => __('field:spacing-m'),
103            'l' => __('field:spacing-l'),
104            'xl' => __('field:spacing-xl'),
105        ];
106
107        return Control::option()->prop('options', [
108            ['value' => '', 'label' => __('field:spacing-default')],
109            ...array_map(
110                static fn(string $size): array => ['value' => $size, 'label' => $labels[$size]],
111                self::SPACING,
112            ),
113        ]);
114    }
115
116    /** Gap, row gap and column gap as spacing tokens; other meta keys pass through. */
117    #[Override]
118    protected function metaShape(): Shape
119    {
120        $shape = parent::metaShape();
121
122        foreach (['gap', 'rowGap', 'columnGap'] as $key) {
123            $shape->add($key, $this->spacingShape())->optional()->nullable();
124        }
125
126        return $shape;
127    }
128
129    /** A neutral-locale map holding a spacing token, or nothing for the site's default. */
130    private function spacingShape(): Shape
131    {
132        $shape = Shapes::create();
133        $shape
134            ->add(self::NEUTRAL_LOCALE, 'string')
135            ->rules('in:' . implode(',', ['', ...self::SPACING]))
136            ->optional()
137            ->nullable();
138
139        return $shape;
140    }
141
142    public function allow(string ...$types): static
143    {
144        $allowed = [];
145
146        foreach ($types as $type) {
147            if (!class_exists($type)) {
148                throw new RuntimeException("Blocks field '{$this->name}' allows unknown block type '{$type}'");
149            }
150
151            if (!is_a($type, Block::class, true)) {
152                throw new RuntimeException(
153                    "Blocks field '{$this->name}' block type '{$type}' must implement " . Block::class,
154                );
155            }
156
157            $allowed[] = $type;
158        }
159
160        $this->allowedBlockTypes = array_values(array_unique([
161            ...$this->allowedBlockTypes,
162            ...$allowed,
163        ]));
164
165        return $this;
166    }
167
168    public function common(string ...$types): static
169    {
170        foreach ($types as $type) {
171            if (!class_exists($type)) {
172                throw new RuntimeException("Blocks field '{$this->name}' has unknown common type '{$type}'");
173            }
174
175            if (!is_a($type, Block::class, true)) {
176                throw new RuntimeException(
177                    "Blocks field '{$this->name}' common type '{$type}' must implement " . Block::class,
178                );
179            }
180        }
181
182        $this->commonTypes = array_values($types);
183
184        return $this;
185    }
186
187    /**
188     * The short menu's types in order: an explicit list checked against
189     * the allowed types, or the first five allowed ones.
190     *
191     * @param list<string> $common
192     * @param list<string> $allowed
193     * @return list<string>
194     */
195    public static function commonSelection(array $common, array $allowed, string $field): array
196    {
197        $common = array_values(array_unique($common));
198
199        if ($common === []) {
200            return array_slice($allowed, 0, self::COMMON_LIMIT);
201        }
202
203        if (count($common) > self::COMMON_LIMIT) {
204            throw new RuntimeException(
205                "Blocks field '{$field}' may have at most " . self::COMMON_LIMIT . ' distinct common types',
206            );
207        }
208
209        foreach ($common as $type) {
210            if (!in_array($type, $allowed, true)) {
211                throw new RuntimeException("Blocks field '{$field}' common type '{$type}' is not allowed");
212            }
213        }
214
215        return $common;
216    }
217
218    /** @phpstan-assert-if-true class-string<Block> $type */
219    #[Override]
220    public function allows(string $type): bool
221    {
222        return in_array($type, $this->allowedBlockTypes(), true);
223    }
224
225    /**
226     * The types this field offers: the `#[Allows]` list, otherwise the
227     * registry's default list.
228     *
229     * @return list<class-string<Block>>
230     */
231    public function allowedBlockTypes(): array
232    {
233        return $this->allowedBlockTypes === [] ? $this->services()->blocks->all() : $this->allowedBlockTypes;
234    }
235
236    /** @return array<string, Field> */
237    public function blockFields(?string $type = null): array
238    {
239        $type ??= $this->allowedBlockTypes()[0]
240            ?? throw new RuntimeException("Blocks field '{$this->name}' offers no block types");
241
242        return $this->rowFieldsFor($type);
243    }
244
245    /**
246     * @param class-string<Block> $type
247     * @param array<string, mixed> $data
248     * @return array<string, Field>
249     */
250    public function blockFieldsFor(string $type, array $data = []): array
251    {
252        return $this->rowFieldsFor($type, $data);
253    }
254
255    /**
256     * The `data-type` value of a block type: its `#[Handle]`, otherwise
257     * derived from the class name.
258     *
259     * @param class-string<Block> $type
260     */
261    public function blockHandle(string $type): string
262    {
263        return $this->rowHandle($type);
264    }
265
266    /** @return list<TranslateMode> */
267    protected function supportedTranslateModes(): array
268    {
269        return [TranslateMode::Symmetric, TranslateMode::Asymmetric];
270    }
271
272    #[Override]
273    public function value(): BlocksValue
274    {
275        return new BlocksValue($this->owner, $this, $this->valueContext);
276    }
277
278    /** The stored column count, the field's default for a value without one. */
279    public function columnsOf(mixed $stored): int
280    {
281        return self::storedColumns($stored, $this->columns);
282    }
283
284    /** A stored column count if it is one, `$default` otherwise. */
285    public static function storedColumns(mixed $stored, int $default): int
286    {
287        return is_int($stored) && $stored >= 1 && $stored <= self::MAX_COLUMNS ? $stored : $default;
288    }
289
290    /** The narrowest block on a grid of that many columns. */
291    public function minOf(int $columns): int
292    {
293        return min($this->min, $columns);
294    }
295
296    #[Override]
297    public function structure(mixed $value = null): array
298    {
299        $value ??= $this->valueContext->data['value'] ?? $this->default ?? [];
300        $columns = $this->columnsOf($this->valueContext->data['columns'] ?? null);
301
302        return [
303            'type' => $this::class,
304            'columns' => $columns,
305            'value' => $this->structureMap(is_array($value) ? $value : [], $columns),
306        ];
307    }
308
309    #[Override]
310    public function shape(): Shape
311    {
312        $shape = Shapes::create();
313        $this->addType($shape);
314        $rows = $this->rowsShape();
315
316        if ($this->isAsymmetricallyTranslated()) {
317            $locales = $this->owner->locales();
318            $defaultLocale = $locales->getDefault()->id;
319            $i18nShape = Shapes::create();
320
321            foreach ($locales as $locale) {
322                $localeField = $i18nShape
323                    ->add($locale->id, $rows)
324                    ->label($this->valueLabel($locale))
325                    ->prepare(Prepare::nullAsEmpty(...));
326
327                if ($this->isRequired() && $locale->id === $defaultLocale) {
328                    $localeField->rules('required');
329                } else {
330                    $localeField->optional()->nullable();
331                }
332            }
333
334            $value = $shape
335                ->add('value', $i18nShape)
336                ->rules(...$this->validators)
337                ->prepare(Prepare::nullAsEmpty(...));
338        } else {
339            $value = $shape
340                ->add('value', $this->zxxShape($rows, $this->validators))
341                ->prepare(Prepare::nullAsEmpty(...));
342        }
343
344        if (!$this->isRequired()) {
345            $value->optional()->nullable();
346        }
347
348        $shape->add('columns', 'int')->optional()->rules('min:1', 'max:' . self::MAX_COLUMNS);
349        $this->addMeta($shape);
350        $shape->review($this->reviewGrid(...));
351
352        return $shape;
353    }
354
355    /**
356     * The layouts against the value's own grid: every block on the field's
357     * grid inside its columns and clear of the others, every split's
358     * blocks within its area.
359     */
360    private function reviewGrid(Review $review): void
361    {
362        $data = $review->values();
363        $columns = $this->columnsOf($data['columns'] ?? null);
364        $min = $this->minOf($columns);
365        $lists = is_array($data['value'] ?? null) ? $data['value'] : [];
366
367        foreach ($lists as $locale => $rows) {
368            $layouts = [];
369
370            foreach (is_array($rows) ? array_values($rows) : [] as $index => $row) {
371                $layout = is_array($row) && is_array($row['layout'] ?? null) ? $row['layout'] : [];
372                $area = new Layout(
373                    (int) ($layout['colspan'] ?? 0),
374                    (int) ($layout['rowspan'] ?? 1),
375                    (int) ($layout['col'] ?? 0),
376                    (int) ($layout['row'] ?? 0),
377                );
378                $path = ['value', $locale, $index, 'layout'];
379                $layouts[] = $area;
380
381                if ($area->colspan < $min || $area->colspan > $columns) {
382                    $review->addError([...$path, 'colspan'], __('block:invalid-width'));
383                } elseif (
384                    isset($layout['col']) !== isset($layout['row'])
385                    || $area->placed()
386                    && ($area->col + $area->colspan - 1) > $columns
387                ) {
388                    $review->addError([...$path, 'col'], __('block:invalid-position'));
389                }
390
391                if (is_array($row) && is_array($row['blocks'] ?? null)) {
392                    $this->reviewSplit(
393                        $review,
394                        ['value', $locale, $index, 'blocks'],
395                        $area,
396                        array_values($row['blocks']),
397                        $min,
398                    );
399                }
400            }
401
402            $overlap = Placement::overlap($layouts);
403
404            if ($overlap !== null) {
405                $review->addError(['value', $locale, $overlap, 'layout', 'col'], __('block:overlap'));
406            }
407        }
408    }
409
410    #[Override]
411    protected function rowKind(): string
412    {
413        return 'block';
414    }
415
416    /** Nested typed repeaters are rejected, as on entry types. */
417    #[Override]
418    protected function assertRowField(string $type, Definition $definition): void
419    {
420        foreach ([Entries::class => 'entries', self::class => 'blocks'] as $class => $kind) {
421            if (is_a($definition->type, $class, true)) {
422                throw new RuntimeException(
423                    "Blocks field '{$this->name}' cannot contain nested {$kind} field"
424                        . " '{$definition->name}' in block type '{$type}'",
425                );
426            }
427        }
428    }
429
430    /**
431     * The translation mode rule: only a symmetric list translates its
432     * sub-fields — a per-locale list already translates the whole block,
433     * an untranslated list nothing. The field's `#[Tools]` feeds every
434     * richtext sub-field that declares none of its own.
435     */
436    protected function configureRowField(Field $field, Definition $definition): void
437    {
438        if ($field instanceof Capability\Translatable && !$this->isSymmetricallyTranslated()) {
439            $field->translate(null);
440        }
441
442        // The project's richtext.tools describes the full toolbar and does
443        // not reach into blocks: without a list from the property or the
444        // field, the inline preset applies.
445        if (
446            $field instanceof Capability\ToolsAware
447            && $definition->property->getAttributes(Tools::class) === []
448        ) {
449            $field->tools(...$this->tools !== [] ? $this->tools : Tool::INLINE);
450        }
451    }
452
453    /**
454     * @param class-string<Block> $type
455     * @return array{type: class-string, handle: string, label: string, icon: ?array<string, mixed>, labels: bool, fields: list<array<string, mixed>>, fieldsets: list<array<string, mixed>>}
456     */
457    protected function blockTypeProperties(string $type): array
458    {
459        $properties = $this->rowTypeProperties($type);
460        $visible = array_filter(
461            $properties['fields'],
462            static fn(array $field): bool => !($field['hidden'] ?? false),
463        );
464
465        return [
466            'type' => $type,
467            'handle' => $this->blockHandle($type),
468            'label' => $properties['label'],
469            'icon' => $this->nodeTypes()->get($type, 'icon'),
470            // A block with one field says what that field is; the label
471            // below the block's own would only repeat it. #[Labels] keeps it.
472            'labels' =>
473                count($visible) !== 1
474                    || (bool) $this->nodeTypes()->get($type, 'labels', false),
475            'fields' => array_map(self::inBlock(...), $properties['fields']),
476            'fieldsets' => $properties['fieldsets'],
477        ];
478    }
479
480    /**
481     * A block marks none of its fields as required and tells each of them
482     * that it renders inside a block. Reaching for a block is what makes its
483     * content mandatory, so the mark tells the editor nothing it does not
484     * already see; only the panel's view of the field loses it — the shape
485     * still validates, built from the field itself. The presentation lets an
486     * element control render as content instead of as a form control.
487     *
488     * @param array<string, mixed> $field
489     * @return array<string, mixed>
490     */
491    private static function inBlock(array $field): array
492    {
493        unset($field['required']);
494        $field['presentation'] = 'block';
495
496        return $field;
497    }
498
499    /**
500     * A plain list lands in the shared or default-locale list; a locale
501     * map is taken per locale.
502     *
503     * @param array<mixed> $value
504     * @return array<string, list<array<string, mixed>>>
505     */
506    private function structureMap(array $value, int $columns): array
507    {
508        if ($this->isAsymmetricallyTranslated()) {
509            $map = array_is_list($value) ? [$this->owner->defaultLocale()->id => $value] : $value;
510            $result = [];
511
512            foreach ($this->owner->locales() as $locale) {
513                $result[$locale->id] = $this->rowStructures($map[$locale->id] ?? [], $columns);
514            }
515
516            return $result;
517        }
518
519        $rows = array_is_list($value) ? $value : $value[self::NEUTRAL_LOCALE] ?? [];
520
521        return [self::NEUTRAL_LOCALE => $this->rowStructures($rows, $columns)];
522    }
523
524    /**
525     * Every row with its position on the grid; one without gets a place
526     * below the others.
527     *
528     * @return list<array<string, mixed>>
529     */
530    private function rowStructures(mixed $rows, int $columns): array
531    {
532        $result = [];
533        $min = $this->minOf($columns);
534
535        foreach (is_array($rows) ? $rows : [] as $row) {
536            if (!is_array($row)) {
537                continue;
538            }
539
540            $layout = Layout::normalize($row['layout'] ?? null, $columns, $min);
541            $structure = !isset($row['type']) && is_array($row['blocks'] ?? null)
542                ? $this->splitStructure($row, $layout, $min)
543                : $this->blockStructure($row, $layout);
544
545            if ($structure !== null) {
546                $result[] = $structure;
547            }
548        }
549
550        return Placement::rows($result, $columns, $min);
551    }
552
553    /**
554     * @param array<mixed> $row
555     * @return ?array<string, mixed>
556     */
557    private function blockStructure(array $row, Layout $layout): ?array
558    {
559        $type = $row['type'] ?? null;
560
561        if (!is_string($type) || !$this->allows($type)) {
562            return null;
563        }
564
565        $fields = is_array($row['fields'] ?? null) ? $row['fields'] : [];
566        $structure = [
567            'uid' => is_string($row['uid'] ?? null) ? $row['uid'] : null,
568            'type' => $type,
569            'layout' => $layout->array(),
570            'fields' => $this->rowStructure($type, $fields),
571        ];
572
573        if (is_array($row['meta'] ?? null) && $row['meta'] !== []) {
574            $structure['meta'] = $row['meta'];
575        }
576
577        return $structure;
578    }
579
580    /**
581     * A split's blocks are laid out on its area and never split again.
582     * One left over after dropping disallowed types is no split: it takes
583     * the split's place, as in the editor.
584     *
585     * @param array<mixed> $row
586     * @return ?array<string, mixed>
587     */
588    private function splitStructure(array $row, Layout $layout, int $min): ?array
589    {
590        $blocks = [];
591
592        foreach ($row['blocks'] as $child) {
593            if (!is_array($child)) {
594                continue;
595            }
596
597            $childLayout = Layout::normalize($child['layout'] ?? null, $layout->colspan, $min, $layout->rowspan);
598            $structure = $this->blockStructure($child, $childLayout);
599
600            if ($structure !== null) {
601                $blocks[] = $structure;
602            }
603        }
604
605        if (count($blocks) < 2) {
606            return $blocks === [] ? null : [...$blocks[0], 'layout' => $layout->array()];
607        }
608
609        $structure = [
610            'uid' => is_string($row['uid'] ?? null) ? $row['uid'] : null,
611            'layout' => $layout->array(),
612            'blocks' => $blocks,
613        ];
614
615        if (is_array($row['meta'] ?? null) && $row['meta'] !== []) {
616            $structure['meta'] = $row['meta'];
617        }
618
619        return $structure;
620    }
621
622    /**
623     * One row: uid, a layout (out-of-range values are rejected, not
624     * clamped, so a programmatic write fails loudly; the bounds that
625     * depend on the value's column count are checked against it; a
626     * position is optional, but both of its lines or neither), the
627     * block meta, and either an allowed type with its fields or — at the
628     * top level only — the `blocks` of a split, whose blocks have no
629     * position of their own.
630     */
631    private function rowsShape(bool $children = false): Shape
632    {
633        $layout = Shapes::create();
634        $layout->add('colspan', 'int')->rules('required', 'min:1', 'max:' . self::MAX_COLUMNS);
635        $layout->add('rowspan', 'int')->rules('required', 'min:1', 'max:' . Layout::MAX_ROWSPAN);
636
637        // Without a position a block is placed below the others on read.
638        if (!$children) {
639            $layout->add('col', 'int')->optional()->rules('min:1', 'max:' . self::MAX_COLUMNS);
640            $layout->add('row', 'int')->optional()->rules('min:1');
641        }
642
643        $rows = Shapes::list();
644        $rows->add('uid', 'string')->rules('required');
645        $type = $rows->add('type', 'string')->rules('in:' . implode(',', $this->allowedBlockTypes()));
646        $rows->add('layout', $layout)->rules('required');
647        $fields = $rows->add('fields', Shapes::create())->finalize($this->finalizeRowFields(...));
648        $meta = Shapes::create()->extra(Extra::Allow);
649        $meta->add('padding', $this->spacingShape())->optional()->nullable();
650        $rows->add('meta', $meta)->optional()->nullable();
651
652        if ($children) {
653            $type->rules('required');
654            $fields->rules('required');
655            $rows->review($this->reviewChildren(...));
656
657            return $rows;
658        }
659
660        $type->optional();
661        $fields->optional();
662        $rows->add('blocks', $this->rowsShape(children: true))->optional();
663        $rows->review($this->reviewRows(...));
664
665        return $rows;
666    }
667
668    private function reviewRows(Review $review): void
669    {
670        $this->reviewRowFields($review);
671
672        foreach ($review->values() as $index => $row) {
673            // An optional type may arrive empty; a non-empty one is allowed.
674            $blocks = $row['blocks'] ?? null;
675            $isBlock = ($row['type'] ?? '') !== '' && isset($row['fields']) && $blocks === null;
676            $isSplit =
677                !array_key_exists('type', $row)
678                && !array_key_exists('fields', $row)
679                && is_array($blocks)
680                && count($blocks) >= 2;
681
682            if (!$isSplit && !$isBlock) {
683                $review->addError([$index], __('block:row-kind'));
684            }
685        }
686    }
687
688    private function reviewChildren(Review $review): void
689    {
690        $this->reviewRowFields($review);
691
692        foreach ($review->values() as $index => $row) {
693            if (array_key_exists('blocks', $row)) {
694                $review->addError([$index, 'blocks'], __('block:row-kind'));
695            }
696        }
697    }
698
699    /**
700     * A split's area has no rows beyond its own — a subgrid never grows —
701     * so a block the flow would put outside it lands on top of another.
702     * Its blocks are placed as the browser's sparse row flow places them.
703     *
704     * @param list<int|string> $path
705     * @param list<mixed> $blocks
706     */
707    private function reviewSplit(Review $review, array $path, Layout $area, array $blocks, int $min): void
708    {
709        $spans = [];
710
711        foreach ($blocks as $position => $block) {
712            $layout = is_array($block) && is_array($block['layout'] ?? null) ? $block['layout'] : [];
713            $colspan = (int) ($layout['colspan'] ?? 0);
714
715            if ($colspan < $min || $colspan > $area->colspan) {
716                $review->addError([...$path, $position, 'layout', 'colspan'], __('block:invalid-width'));
717
718                return;
719            }
720
721            $spans[] = ['colspan' => $colspan, 'rowspan' => (int) ($layout['rowspan'] ?? 1)];
722        }
723
724        foreach (Placement::flow($spans, $area->colspan) as $position => $spot) {
725            if (($spot['row'] + $spans[$position]['rowspan'] - 1) > $area->rowspan) {
726                $review->addError([...$path, $position, 'layout'], __('block:children-overflow'));
727
728                return;
729            }
730        }
731    }
732}