Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
85 / 85
100.00% covered (success)
100.00%
21 / 21
CRAP
100.00% covered (success)
100.00%
1 / 1
Blocks
100.00% covered (success)
100.00%
85 / 85
100.00% covered (success)
100.00%
21 / 21
50
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
 unwrap
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
 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
 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%
17 / 17
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%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Value;
6
7use Cosray\Block as Builtin;
8use Cosray\Block\RenderContext;
9use Cosray\Field;
10use Cosray\Field\Owner;
11use Cosray\Schema\Responsive;
12use Cosray\Schema\TranslateMode;
13use Generator;
14use IteratorAggregate;
15
16use function Cosray\escape;
17
18/**
19 * @property-read Field\Blocks $field
20 */
21class Blocks extends Value implements IteratorAggregate
22{
23    /** @var list<Block> */
24    protected array $blocks = [];
25
26    public function __construct(Owner $owner, Field\Blocks $field, ValueContext $context)
27    {
28        parent::__construct($owner, $field, $context);
29
30        $this->blocks = $this->rows($this->list());
31    }
32
33    public function __toString(): string
34    {
35        return $this->render();
36    }
37
38    public function json(): array
39    {
40        return [
41            'columns' => $this->columns(),
42            'blocks' => array_map(static fn(Block $block): array => $block->json(), $this->blocks),
43        ];
44    }
45
46    public function unwrap(): array
47    {
48        return [
49            'columns' => $this->columns(),
50            'blocks' => array_map(static fn(Block $block): array => $block->unwrap(), $this->blocks),
51        ];
52    }
53
54    public function getIterator(): Generator
55    {
56        foreach ($this->blocks as $block) {
57            yield $block;
58        }
59    }
60
61    public function count(): int
62    {
63        return count($this->blocks);
64    }
65
66    public function first(): ?Block
67    {
68        return $this->blocks[0] ?? null;
69    }
70
71    public function last(): ?Block
72    {
73        return $this->blocks[count($this->blocks) - 1] ?? null;
74    }
75
76    public function get(int $index): ?Block
77    {
78        return $this->blocks[$index] ?? null;
79    }
80
81    public function isset(): bool
82    {
83        return count($this->blocks) > 0;
84    }
85
86    public function columns(): int
87    {
88        return $this->field->getColumns();
89    }
90
91    public function responsive(): Responsive
92    {
93        return $this->field->getResponsive();
94    }
95
96    /** The n-th image block's image. */
97    public function image(int $index = 1): ?Image
98    {
99        $i = 0;
100
101        foreach ($this->blocks as $block) {
102            if ($block->type !== Builtin\Image::class) {
103                continue;
104            }
105
106            if (++$i === $index) {
107                $image = $block->image;
108
109                return $image instanceof Image ? $image : null;
110            }
111        }
112
113        return null;
114    }
115
116    /**
117     * Every image of the image and images blocks. With `$all` a field
118     * translated per locale yields the images of every locale's list.
119     */
120    public function images(bool $all = false): Generator
121    {
122        $lists = $all && $this->perLocale() ? $this->lists() : [$this->blocks];
123
124        foreach ($lists as $blocks) {
125            foreach ($blocks as $block) {
126                if ($block->type === Builtin\Image::class) {
127                    $image = $block->image;
128
129                    if ($image instanceof Image) {
130                        yield $image;
131                    }
132                } elseif ($block->type === Builtin\Images::class) {
133                    foreach ($block->images as $image) {
134                        yield $image;
135                    }
136                }
137            }
138        }
139    }
140
141    public function hasImage(int $index = 1): bool
142    {
143        return $this->image($index) !== null;
144    }
145
146    /** The n-th richtext block's excerpt. */
147    public function excerpt(
148        int $words = 30,
149        string $allowedTags = '',
150        int $index = 1,
151    ): string {
152        $i = 0;
153
154        foreach ($this->blocks as $block) {
155            if ($block->type !== Builtin\RichText::class) {
156                continue;
157            }
158
159            if (++$i === $index) {
160                $text = $block->text;
161
162                return $text instanceof RichText ? $text->excerpt($words, $allowedTags) : '';
163            }
164        }
165
166        return '';
167    }
168
169    // Supported args:
170    //
171    // - prefix: All generated css classes are prefixed with this value.
172    //   Default 'cms'
173    // - tag: The tag of the container. Default 'div'
174    // - class: An additional class added to the container
175    // - imageSizes: `media.sizes` names forming the image block's srcset
176    //   ladder. Default ['block-sm', 'block', 'block-lg']
177    // - sizes: Template for the image block's `sizes` attribute; `{pct}`
178    //   is replaced with the block's grid share in percent.
179    //   Default '(min-width: 48rem) {pct}vw, 100vw'
180    // - thumbSize: `media.sizes` name for gallery thumbs. Default 'block-thumb'
181    public function render(mixed ...$args): string
182    {
183        $columns = $this->columns();
184        $ctx = new RenderContext($this->owner, $this->fieldName, $columns, $args);
185        $class = $ctx->prefix() . '-blocks' . ($ctx->class() !== '' ? ' ' . $ctx->class() : '');
186        $out =
187            '<'
188            . $ctx->tag()
189            . ' class="'
190            . escape($class)
191            . "\" data-columns=\"{$columns}\" data-responsive=\""
192            . escape($this->responsive()->value)
193            . "\" style=\"--columns: {$columns}\">";
194        $registry = $this->field->services()->blocks;
195        $types = [];
196
197        foreach ($this->blocks as $block) {
198            $types[$block->type] ??= $registry->create($block->type, $this->owner);
199            $out .= $block->renderWith($ctx, $types[$block->type]);
200        }
201
202        return $out . '</' . $ctx->tag() . '>';
203    }
204
205    private function perLocale(): bool
206    {
207        return $this->field->translateMode() === TranslateMode::Asymmetric;
208    }
209
210    /**
211     * The stored list for the effective locale (per-locale lists, along
212     * the fallback chain) or the shared list.
213     */
214    private function list(): array
215    {
216        $value = $this->data['value'] ?? null;
217
218        if (!is_array($value)) {
219            return [];
220        }
221
222        $list = $this->perLocale() ? $this->effective($value) : $value[Field\Field::NEUTRAL_LOCALE] ?? [];
223
224        return is_array($list) ? $list : [];
225    }
226
227    /** @return list<list<Block>> every stored list, one per locale */
228    private function lists(): array
229    {
230        $value = $this->data['value'] ?? null;
231        $lists = [];
232
233        foreach (is_array($value) ? $value : [] as $list) {
234            if (is_array($list)) {
235                $lists[] = $this->rows($list);
236            }
237        }
238
239        return $lists;
240    }
241
242    /** @return list<Block> */
243    private function rows(array $list): array
244    {
245        $rows = [];
246
247        foreach ($list as $row) {
248            if (!is_array($row)) {
249                continue;
250            }
251
252            $type = $row['type'] ?? null;
253
254            if (!is_string($type) || !$this->field->allows($type)) {
255                continue;
256            }
257
258            $rows[] = new Block($this->owner, $this->field, new ValueContext($this->fieldName, $row), $type);
259        }
260
261        return $rows;
262    }
263}