Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.95% covered (warning)
84.95%
79 / 93
27.27% covered (danger)
27.27%
3 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Local
84.95% covered (warning)
84.95%
79 / 93
27.27% covered (danger)
27.27%
3 / 11
50.60
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
 icon
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 split
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
5.09
 normalizePaths
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 file
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 injectAttributes
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 appendAttribute
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
5.93
 mergeStyle
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 joinStyles
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 clean
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isSvg
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\Icons;
6
7use Cosray\Contract;
8
9use function Cosray\escape;
10
11final class Local implements Contract\Icons
12{
13    /** @var list<string> */
14    private array $paths;
15
16    /** @param array<array-key, mixed> $paths */
17    public function __construct(array $paths)
18    {
19        $this->paths = $this->normalizePaths($paths);
20    }
21
22    /** @param array<array-key, mixed> $args */
23    public function icon(string $id, array $args = []): string
24    {
25        $parts = $this->split($id);
26
27        if ($parts === null) {
28            return '';
29        }
30
31        foreach ($this->paths as $path) {
32            $file = $this->file($path, $parts['prefix'], $parts['name']);
33
34            if ($file === null) {
35                continue;
36            }
37
38            $svg = file_get_contents($file);
39
40            if (is_string($svg) && $this->isSvg($svg)) {
41                return $this->injectAttributes($svg, $args);
42            }
43        }
44
45        return '';
46    }
47
48    /**
49     * @return array{prefix: ?string, name: string}|null
50     */
51    private function split(string $id): ?array
52    {
53        $id = trim($id);
54
55        if (!preg_match(
56            '/^(?:(?<prefix>[a-z0-9]+(?:[-_][a-z0-9]+)*):)?(?<name>[a-z0-9]+(?:[-_][a-z0-9]+)*)$/i',
57            $id,
58            $matches,
59        )) {
60            return null;
61        }
62
63        $name = strtolower((string) $matches['name']);
64
65        if ($name === '') {
66            return null;
67        }
68
69        $prefix = $matches['prefix'] ?? null;
70        $prefix = is_string($prefix) && $prefix !== '' ? strtolower($prefix) : null;
71
72        return ['prefix' => $prefix, 'name' => $name];
73    }
74
75    /**
76     * @param array<array-key, mixed> $paths
77     * @return list<string>
78     */
79    private function normalizePaths(array $paths): array
80    {
81        $result = [];
82
83        foreach ($paths as $path) {
84            if (!is_string($path)) {
85                continue;
86            }
87
88            $path = trim($path);
89
90            if ($path === '') {
91                continue;
92            }
93
94            $real = realpath($path);
95
96            if ($real !== false && is_dir($real)) {
97                $result[] = $real;
98            }
99        }
100
101        return array_values(array_unique($result));
102    }
103
104    private function file(string $path, ?string $prefix, string $name): ?string
105    {
106        $file = $prefix === null
107            ? $path . DIRECTORY_SEPARATOR . $name . '.svg'
108            : $path . DIRECTORY_SEPARATOR . $prefix . DIRECTORY_SEPARATOR . $name . '.svg';
109        $resolved = realpath($file);
110
111        if ($resolved === false || !is_file($resolved)) {
112            return null;
113        }
114
115        $root = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
116
117        if (!str_starts_with($resolved, $root)) {
118            return null;
119        }
120
121        return $resolved;
122    }
123
124    /** @param array<array-key, mixed> $args */
125    private function injectAttributes(string $svg, array $args): string
126    {
127        $class = $this->clean($args['class'] ?? null);
128        $style = $this->mergeStyle(
129            $this->clean($args['style'] ?? null),
130            $this->clean($args['color'] ?? null),
131        );
132
133        if ($class === null && $style === null) {
134            return $svg;
135        }
136
137        if (!preg_match('/<svg\\b[^>]*>/i', $svg, $matches, PREG_OFFSET_CAPTURE)) {
138            return $svg;
139        }
140
141        $tag = $matches[0][0];
142        $offset = $matches[0][1];
143        $length = strlen($tag);
144
145        if ($class !== null) {
146            $tag = $this->appendAttribute($tag, 'class', $class);
147        }
148
149        if ($style !== null) {
150            $tag = $this->appendAttribute($tag, 'style', $style);
151        }
152
153        return substr_replace($svg, $tag, $offset, $length);
154    }
155
156    private function appendAttribute(string $tag, string $name, string $value): string
157    {
158        $pattern = sprintf('/\\s%s\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\')/i', preg_quote($name, '/'));
159
160        if (preg_match($pattern, $tag, $matches) === 1) {
161            $current = ($matches[1] ?? '') !== '' ? $matches[1] : $matches[2] ?? '';
162            $merged = $name === 'style'
163                ? $this->joinStyles($current, $value)
164                : trim($current . ' ' . $value);
165            $replacement = sprintf(' %s="%s"', $name, escape($merged));
166
167            return (string) preg_replace($pattern, $replacement, $tag, 1);
168        }
169
170        $injection = sprintf(' %s="%s"', $name, escape($value));
171        $closer = str_ends_with($tag, '/>') ? '/>' : '>';
172        $base = substr($tag, 0, -strlen($closer));
173
174        return $base . $injection . $closer;
175    }
176
177    private function mergeStyle(?string $style, ?string $color): ?string
178    {
179        if ($color === null) {
180            return $style;
181        }
182
183        $colorStyle = 'color: ' . $color;
184
185        if ($style === null) {
186            return $colorStyle;
187        }
188
189        return rtrim($style, '; ') . '; ' . $colorStyle;
190    }
191
192    private function joinStyles(string $base, string $append): string
193    {
194        $base = trim($base);
195        $append = trim($append);
196
197        if ($base === '') {
198            return $append;
199        }
200
201        if ($append === '') {
202            return $base;
203        }
204
205        return rtrim($base, '; ') . '; ' . ltrim($append, '; ');
206    }
207
208    private function clean(mixed $value): ?string
209    {
210        if (!is_scalar($value)) {
211            return null;
212        }
213
214        $value = trim((string) $value);
215
216        return $value === '' ? null : $value;
217    }
218
219    private function isSvg(string $svg): bool
220    {
221        return str_starts_with(ltrim($svg), '<svg');
222    }
223}