Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
105 / 105
100.00% covered (success)
100.00%
27 / 27
CRAP
100.00% covered (success)
100.00%
1 / 1
Blocks
100.00% covered (success)
100.00%
105 / 105
100.00% covered (success)
100.00%
27 / 27
65
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
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getIterator
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 first
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 last
100.00% covered (success)
100.00%
1 / 1
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
 isset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 leaves
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 columns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 responsive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 gap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rowGap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 columnGap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 image
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 images
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 hasImage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 excerpt
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 render
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 spacing
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 perLocale
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 list
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 lists
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 rows
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 flatten
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Value;
6
7use Cosray\Block as Builtin;
8use Cosray\Block\Placement;
9use Cosray\Block\RenderContext;
10use Cosray\Field;
11use Cosray\Field\Owner;
12use Cosray\Markup;
13use Cosray\Schema\Responsive;
14use Cosray\Schema\TranslateMode;
15use Generator;
16use IteratorAggregate;
17use Override;
18
19use function Cosray\escape;
20
21/**
22 * @property-read Field\Blocks $field
23 * @implements IteratorAggregate<int, Block>
24 */
25class Blocks extends Value implements IteratorAggregate
26{
27    /** @var list<Block> */
28    protected array $blocks = [];
29
30    public function __construct(Owner $owner, Field\Blocks $field, ValueContext $context)
31    {
32        parent::__construct($owner, $field, $context);
33
34        $this->blocks = $this->rows($this->list());
35    }
36
37    #[Override]
38    public function __toString(): string
39    {
40        return (string) $this->render();
41    }
42
43    /** @return array{columns: int, blocks: list<array<string, mixed>>} */
44    #[Override]
45    public function json(): array
46    {
47        return [
48            'columns' => $this->columns(),
49            'blocks' => array_map(static fn(Block $block): array => $block->json(), $this->blocks),
50        ];
51    }
52
53    /** @return array{columns: int, blocks: list<array<string, mixed>>} */
54    #[Override]
55    public function raw(): array
56    {
57        return [
58            'columns' => $this->columns(),
59            'blocks' => array_map(static fn(Block $block): array => $block->raw(), $this->blocks),
60        ];
61    }
62
63    /** @return Generator<int, Block> */
64    #[Override]
65    public function getIterator(): Generator
66    {
67        foreach ($this->blocks as $block) {
68            yield $block;
69        }
70    }
71
72    public function count(): int
73    {
74        return count($this->blocks);
75    }
76
77    public function first(): ?Block
78    {
79        return $this->blocks[0] ?? null;
80    }
81
82    public function last(): ?Block
83    {
84        return $this->blocks[count($this->blocks) - 1] ?? null;
85    }
86
87    public function get(int $index): ?Block
88    {
89        return $this->blocks[$index] ?? null;
90    }
91
92    #[Override]
93    public function isset(): bool
94    {
95        return count($this->blocks) > 0;
96    }
97
98    /** Every block in reading order, the blocks of a split in its place. */
99    public function leaves(): Generator
100    {
101        yield from self::flatten($this->blocks);
102    }
103
104    /** The column count stored with the value, the field's default without one. */
105    public function columns(): int
106    {
107        return $this->field->columnsOf($this->data['columns'] ?? null);
108    }
109
110    public function responsive(): Responsive
111    {
112        return $this->field->getResponsive();
113    }
114
115    /** The gap between blocks as a spacing token, `null` for the site's default. */
116    public function gap(): ?string
117    {
118        return $this->spacing('gap');
119    }
120
121    public function rowGap(): ?string
122    {
123        return $this->spacing('rowGap');
124    }
125
126    public function columnGap(): ?string
127    {
128        return $this->spacing('columnGap');
129    }
130
131    /** The n-th image block's image. */
132    public function image(int $index = 1): ?Image
133    {
134        $i = 0;
135
136        foreach ($this->leaves() as $block) {
137            if ($block->type !== Builtin\Image::class) {
138                continue;
139            }
140
141            if (++$i === $index) {
142                $image = $block->image;
143
144                return $image instanceof Image ? $image : null;
145            }
146        }
147
148        return null;
149    }
150
151    /**
152     * Every image of the image and images blocks. With `$all` a field
153     * translated per locale yields the images of every locale's list.
154     */
155    public function images(bool $all = false): Generator
156    {
157        $lists = $all && $this->perLocale() ? $this->lists() : [$this->blocks];
158
159        foreach ($lists as $blocks) {
160            foreach (self::flatten($blocks) as $block) {
161                if ($block->type === Builtin\Image::class) {
162                    $image = $block->image;
163
164                    if ($image instanceof Image) {
165                        yield $image;
166                    }
167                } elseif ($block->type === Builtin\Images::class) {
168                    foreach ($block->images as $image) {
169                        yield $image;
170                    }
171                }
172            }
173        }
174    }
175
176    public function hasImage(int $index = 1): bool
177    {
178        return $this->image($index) !== null;
179    }
180
181    /** The n-th richtext block's excerpt. */
182    public function excerpt(
183        int $words = 30,
184        string $allowedTags = '',
185        int $index = 1,
186    ): Markup {
187        $i = 0;
188
189        foreach ($this->leaves() as $block) {
190            if ($block->type !== Builtin\RichText::class) {
191                continue;
192            }
193
194            if (++$i === $index) {
195                $text = $block->text;
196
197                return $text instanceof RichText ? $text->excerpt($words, $allowedTags) : new Markup('');
198            }
199        }
200
201        return new Markup('');
202    }
203
204    // Supported args:
205    //
206    // - prefix: All generated css classes are prefixed with this value.
207    //   Default 'cms'
208    // - tag: The tag of the container. Default 'div'
209    // - class: An additional class added to the container
210    // - imageSizes: `media.sizes` names forming the image block's srcset
211    //   ladder. Default ['block-sm', 'block', 'block-lg']
212    // - sizes: Template for the image block's `sizes` attribute; `{pct}`
213    //   is replaced with the block's grid share in percent.
214    //   Default '(min-width: 48rem) {pct}vw, 100vw'
215    // - thumbSize: `media.sizes` name for gallery thumbs. Default 'block-thumb'
216    public function render(mixed ...$args): Markup
217    {
218        $columns = $this->columns();
219        $ctx = new RenderContext($this->owner, $this->fieldName, $columns, $args);
220        $class = $ctx->prefix() . '-blocks' . ($ctx->class() !== '' ? ' ' . $ctx->class() : '');
221        $attributes =
222            ' class="'
223            . escape($class)
224            . "\" data-columns=\"{$columns}\" data-responsive=\""
225            . escape($this->responsive()->value)
226            . '"';
227
228        // The spacing chosen in the editor, as tokens the site's sheet maps
229        // to lengths; nothing is emitted for the site's default.
230        foreach ([
231            'gap' => 'data-gap',
232            'rowGap' => 'data-row-gap',
233            'columnGap' => 'data-column-gap',
234        ] as $key => $attribute) {
235            $size = $this->spacing($key);
236
237            if ($size !== null) {
238                $attributes .= " {$attribute}=\"" . escape($size) . '"';
239            }
240        }
241
242        $out = '<' . $ctx->tag() . $attributes . " style=\"--columns: {$columns}\">";
243        $types = $this->field->services()->blocks->cached($this->owner);
244
245        foreach ($this->blocks as $block) {
246            $out .= $block->renderWith($ctx, $types);
247        }
248
249        return new Markup($out . '</' . $ctx->tag() . '>');
250    }
251
252    private function spacing(string $key): ?string
253    {
254        $value = $this->meta($key);
255
256        return is_string($value) && in_array($value, Field\Blocks::SPACING, true) ? $value : null;
257    }
258
259    private function perLocale(): bool
260    {
261        return $this->field->translateMode() === TranslateMode::Asymmetric;
262    }
263
264    /**
265     * The stored list for the effective locale (per-locale lists, along
266     * the fallback chain) or the shared list.
267     *
268     * @return array<mixed>
269     */
270    private function list(): array
271    {
272        $value = $this->data['value'] ?? null;
273
274        if (!is_array($value)) {
275            return [];
276        }
277
278        $list = $this->perLocale() ? $this->effective($value) : $value[Field\Field::NEUTRAL_LOCALE] ?? [];
279
280        return is_array($list) ? $list : [];
281    }
282
283    /** @return list<list<Block>> every stored list, one per locale */
284    private function lists(): array
285    {
286        $value = $this->data['value'] ?? null;
287        $lists = [];
288
289        foreach (is_array($value) ? $value : [] as $list) {
290            if (is_array($list)) {
291                $lists[] = $this->rows($list);
292            }
293        }
294
295        return $lists;
296    }
297
298    /**
299     * A row without a type holding `blocks` is a split; one left without
300     * a block of an allowed type is skipped like a row of such a type.
301     * Every row has its position; one stored without gets a place below
302     * the others.
303     *
304     * @param array<mixed> $list
305     * @return list<Block>
306     */
307    private function rows(array $list): array
308    {
309        $rows = [];
310        $columns = $this->columns();
311
312        foreach (Placement::rows($list, $columns, $this->field->minOf($columns)) as $row) {
313            if (!is_array($row)) {
314                continue;
315            }
316
317            $type = $row['type'] ?? null;
318            $context = new ValueContext($this->fieldName, $row);
319
320            if ($type === null && is_array($row['blocks'] ?? null)) {
321                $split = new Block($this->owner, $this->field, $context, null, columns: $columns);
322
323                if ($split->blocks() !== []) {
324                    $rows[] = $split;
325                }
326            } elseif (is_string($type) && $this->field->allows($type)) {
327                $rows[] = new Block($this->owner, $this->field, $context, $type, columns: $columns);
328            }
329        }
330
331        return $rows;
332    }
333
334    /** @param list<Block> $blocks */
335    private static function flatten(array $blocks): Generator
336    {
337        foreach ($blocks as $block) {
338            if ($block->isSplit()) {
339                yield from $block->blocks();
340            } else {
341                yield $block;
342            }
343        }
344    }
345}