Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
33 / 34
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Asset
97.06% covered (success)
97.06%
33 / 34
87.50% covered (warning)
87.50%
7 / 8
20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromRow
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 path
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sizePath
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 resizable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 classify
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 metaMap
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 encode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Assets;
6
7use Cosray\Config;
8
9/** A row from the asset catalog. */
10final class Asset
11{
12    private const array RESIZABLE = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
13
14    /**
15     * Coarse classification for pickers and tiles. `list.tpql` filters on
16     * the same mime prefixes, so the two have to move together.
17     */
18    public string $kind {
19        get => self::classify($this->mime);
20    }
21
22    public function __construct(
23        public readonly string $uid,
24        public readonly string $disk,
25        public readonly string $key,
26        public readonly string $filename,
27        public readonly ?string $mime = null,
28        public readonly ?int $bytes = null,
29        public readonly ?int $width = null,
30        public readonly ?int $height = null,
31        public readonly array $meta = [],
32        private readonly string $assetsBase = '/assets',
33        private readonly string $cacheBase = '/cache',
34    ) {}
35
36    public static function fromRow(array $row, Config $config): self
37    {
38        $meta = json_decode((string) ($row['meta'] ?? '{}'), true);
39        $prefix = $config->path->prefix;
40
41        return new self(
42            uid: (string) $row['uid'],
43            disk: (string) $row['disk'],
44            key: (string) $row['key'],
45            filename: (string) $row['filename'],
46            mime: isset($row['mime']) ? (string) $row['mime'] : null,
47            bytes: isset($row['bytes']) ? (int) $row['bytes'] : null,
48            width: isset($row['width']) ? (int) $row['width'] : null,
49            height: isset($row['height']) ? (int) $row['height'] : null,
50            meta: is_array($meta) ? $meta : [],
51            assetsBase: $prefix . '/' . trim($config->path->assets, '/'),
52            cacheBase: $prefix . '/' . trim($config->path->cache, '/'),
53        );
54    }
55
56    /**
57     * Root-relative URL of the original. The URL equals the file's path
58     * below the public directory, so the web server serves it natively.
59     */
60    public function path(): string
61    {
62        return "{$this->assetsBase}/" . $this->encode($this->key);
63    }
64
65    /**
66     * Root-relative URL of a named rendition:
67     * `{cache}/{shard}/{uid}/{stem}-{size}.{ext}`. Served natively once
68     * generated; misses fall through to the cache route.
69     */
70    public function sizePath(string $size): string
71    {
72        $dir = dirname($this->key);
73        $base = basename($this->key);
74        $dot = strrpos($base, '.');
75        $variant =
76            $dot === false || $dot === 0
77                ? "{$base}-{$size}"
78                : substr($base, 0, $dot) . "-{$size}" . substr($base, $dot);
79
80        return "{$this->cacheBase}/" . $this->encode("{$dir}/{$variant}");
81    }
82
83    /** Whether the resize pipeline can process this asset. */
84    public function resizable(): bool
85    {
86        return in_array($this->mime, self::RESIZABLE, true);
87    }
88
89    /**
90     * A mime type reduced to the vocabulary the panel filters on. Audio,
91     * documents and an unreadable type all land on `file`.
92     */
93    public static function classify(?string $mime): string
94    {
95        if ($mime === null) {
96            return 'file';
97        }
98
99        return match (true) {
100            str_starts_with($mime, 'image/') => 'image',
101            str_starts_with($mime, 'video/') => 'video',
102            default => 'file',
103        };
104    }
105
106    /** Locale map for a catalog meta key, or null when absent. */
107    public function metaMap(string $key): ?array
108    {
109        $value = $this->meta[$key] ?? null;
110
111        return is_array($value) ? $value : null;
112    }
113
114    private function encode(string $path): string
115    {
116        return implode('/', array_map(rawurlencode(...), explode('/', $path)));
117    }
118}