Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
Image
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
4 / 4
14
100.00% covered (success)
100.00%
1 / 1
 render
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 img
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 ladder
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 sizes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Block;
6
7use Cosray\Assets\Asset;
8use Cosray\Assets\ResizeMode;
9use Cosray\Assets\SizeSpec;
10use Cosray\Contract\Block;
11use Cosray\Exception\RuntimeException;
12use Cosray\Field;
13use Cosray\Schema\Label;
14use Cosray\Schema\Limit;
15use Cosray\Schema\Required;
16use Cosray\Schema\Translate;
17use Cosray\Value\Block as BlockValue;
18use Cosray\Value\Image as ImageValue;
19use Override;
20
21use function Cosray\escape;
22
23#[Label('block:image')]
24final class Image implements Block
25{
26    private const array LADDER = ['block-sm', 'block', 'block-lg'];
27    private const string SIZES = '(min-width: 48rem) {pct}vw, 100vw';
28
29    #[Label('block:image'), Required, Limit(1), Translate]
30    protected Field\Image $image;
31
32    #[Override]
33    public function render(BlockValue $block, RenderContext $ctx): string
34    {
35        /** @var ImageValue $image */
36        $image = $block->image;
37        $asset = $ctx->asset((string) ($image->raw()['uid'] ?? ''));
38
39        if ($asset === null) {
40            return '';
41        }
42
43        $caption = $image->caption();
44        $figcaption = $caption === '' ? '' : '<figcaption>' . escape($caption) . '</figcaption>';
45
46        return (
47            "<figure class=\"{$ctx->prefix()}-figure\">"
48                . $this->img($image, $asset, $block, $ctx)
49                . $figcaption
50                . '</figure>'
51        );
52    }
53
54    private function img(ImageValue $image, Asset $asset, BlockValue $block, RenderContext $ctx): string
55    {
56        $alt = escape($image->alt() ?: strip_tags($image->title()));
57        $path = escape($asset->path());
58
59        if (!$asset->resizable()) {
60            return "<img src=\"{$path}\" alt=\"{$alt}\" data-path-original=\"{$path}\">";
61        }
62
63        $specs = $this->ladder($ctx);
64        $src = escape($asset->sizePath($specs[intdiv(count($specs), 2)]->name));
65
66        if (count($specs) === 1) {
67            return "<img src=\"{$src}\" alt=\"{$alt}\" data-path-original=\"{$path}\">";
68        }
69
70        $srcset = escape(implode(', ', array_map(
71            static fn(SizeSpec $spec) => $asset->sizePath($spec->name) . " {$spec->first}w",
72            $specs,
73        )));
74        $sizes = escape($this->sizes($ctx, $block->layout()->colspan));
75
76        return (
77            "<img src=\"{$src}\" srcset=\"{$srcset}\" sizes=\"{$sizes}\""
78                . " alt=\"{$alt}\" data-path-original=\"{$path}\">"
79        );
80    }
81
82    /**
83     * The named sizes forming the srcset ladder, ascending by width.
84     * Multi-rung ladders need `w` descriptors, so their entries must use
85     * the `width` mode; a single rung emits a plain `src` and may use
86     * any mode.
87     *
88     * @return list<SizeSpec>
89     */
90    private function ladder(RenderContext $ctx): array
91    {
92        $names = $ctx->args['imageSizes'] ?? self::LADDER;
93
94        if (!is_array($names) || $names === []) {
95            throw new RuntimeException('Blocks error: `imageSizes` must be a non-empty list of size names');
96        }
97
98        $registry = $ctx->owner->config()->media->sizes;
99        $specs = array_map(static fn($name) => $registry->get((string) $name), array_values($names));
100
101        if (count($specs) > 1) {
102            foreach ($specs as $spec) {
103                if ($spec->mode !== ResizeMode::Width) {
104                    throw new RuntimeException(
105                        "Blocks error: srcset entry '{$spec->name}' must use the `width` mode",
106                    );
107                }
108            }
109
110            usort($specs, static fn(SizeSpec $a, SizeSpec $b) => $a->first <=> $b->first);
111        }
112
113        return $specs;
114    }
115
116    private function sizes(RenderContext $ctx, int $span): string
117    {
118        $template = (string) ($ctx->args['sizes'] ?? self::SIZES);
119        $pct = (int) round(($span / max($ctx->columns, 1)) * 100);
120
121        return str_replace('{pct}', (string) $pct, $template);
122    }
123}