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    /** @param array<string, mixed> $meta */
23    public function __construct(
24        public readonly string $uid,
25        public readonly string $disk,
26        public readonly string $key,
27        public readonly string $filename,
28        public readonly ?string $mime = null,
29        public readonly ?int $bytes = null,
30        public readonly ?int $width = null,
31        public readonly ?int $height = null,
32        public readonly array $meta = [],
33        private readonly string $assetsBase = '/assets',
34        private readonly string $cacheBase = '/cache',
35    ) {}
36
37    /** @param array<mixed> $row */
38    public static function fromRow(array $row, Config $config): self
39    {
40        $meta = json_decode((string) ($row['meta'] ?? '{}'), true);
41        $prefix = $config->app->urlPrefix;
42
43        return new self(
44            uid: (string) $row['uid'],
45            disk: (string) $row['disk'],
46            key: (string) $row['key'],
47            filename: (string) $row['filename'],
48            mime: isset($row['mime']) ? (string) $row['mime'] : null,
49            bytes: isset($row['bytes']) ? (int) $row['bytes'] : null,
50            width: isset($row['width']) ? (int) $row['width'] : null,
51            height: isset($row['height']) ? (int) $row['height'] : null,
52            meta: is_array($meta) ? $meta : [],
53            assetsBase: $prefix . '/' . trim($config->path->assets, '/'),
54            cacheBase: $prefix . '/' . trim($config->path->cache, '/'),
55        );
56    }
57
58    /**
59     * Root-relative URL of the original. The URL equals the file's path
60     * below the public directory, so the web server serves it natively.
61     */
62    public function path(): string
63    {
64        return "{$this->assetsBase}/" . $this->encode($this->key);
65    }
66
67    /**
68     * Root-relative URL of a named rendition:
69     * `{cache}/{shard}/{uid}/{stem}-{size}.{ext}`. Served natively once
70     * generated; misses fall through to the cache route.
71     */
72    public function sizePath(string $size): string
73    {
74        $dir = dirname($this->key);
75        $base = basename($this->key);
76        $dot = strrpos($base, '.');
77        $variant =
78            $dot === false || $dot === 0
79                ? "{$base}-{$size}"
80                : substr($base, 0, $dot) . "-{$size}" . substr($base, $dot);
81
82        return "{$this->cacheBase}/" . $this->encode("{$dir}/{$variant}");
83    }
84
85    /** Whether the resize pipeline can process this asset. */
86    public function resizable(): bool
87    {
88        return in_array($this->mime, self::RESIZABLE, true);
89    }
90
91    /**
92     * A mime type reduced to the vocabulary the panel filters on. Audio,
93     * documents and an unreadable type all land on `file`.
94     */
95    public static function classify(?string $mime): string
96    {
97        if ($mime === null) {
98            return 'file';
99        }
100
101        return match (true) {
102            str_starts_with($mime, 'image/') => 'image',
103            str_starts_with($mime, 'video/') => 'video',
104            default => 'file',
105        };
106    }
107
108    /**
109     * Locale map for a catalog meta key, or null when absent.
110     *
111     * @return ?array<string, mixed>
112     */
113    public function metaMap(string $key): ?array
114    {
115        $value = $this->meta[$key] ?? null;
116
117        return is_array($value) ? $value : null;
118    }
119
120    private function encode(string $path): string
121    {
122        return implode('/', array_map(rawurlencode(...), explode('/', $path)));
123    }
124}