Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
118 / 118
92.14% covered (success)
92.14%
129 / 140
12.34% covered (danger)
12.34%
47 / 381
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Router
100.00% covered (success)
100.00%
118 / 118
92.14% covered (success)
92.14%
129 / 140
12.34% covered (danger)
12.34%
47 / 381
100.00% covered (success)
100.00%
16 / 16
2324.32
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
85.71% covered (warning)
85.71%
6 / 7
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
2.50
 addRoute
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
10 / 10
27.78% covered (danger)
27.78%
5 / 18
100.00% covered (success)
100.00%
1 / 1
14.42
 group
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addStatic
100.00% covered (success)
100.00%
15 / 15
90.91% covered (success)
90.91%
10 / 11
62.50% covered (warning)
62.50%
5 / 8
100.00% covered (success)
100.00%
1 / 1
4.84
 asset
100.00% covered (success)
100.00%
10 / 10
91.67% covered (success)
91.67%
11 / 12
55.56% covered (warning)
55.56%
5 / 9
100.00% covered (success)
100.00%
1 / 1
7.19
 url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 match
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
28 / 28
1.42% covered (danger)
1.42%
4 / 282
100.00% covered (success)
100.00%
1 / 1
149.96
 applyGlobalPrefix
100.00% covered (success)
100.00%
3 / 3
83.33% covered (warning)
83.33%
5 / 6
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
3.33
 prependHost
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 queryString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeQuery
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
10 / 10
66.67% covered (warning)
66.67%
4 / 6
100.00% covered (success)
100.00%
1 / 1
5.93
 queryValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getCacheBuster
100.00% covered (success)
100.00%
9 / 9
84.62% covered (warning)
84.62%
11 / 13
30.77% covered (danger)
30.77%
4 / 13
100.00% covered (success)
100.00%
1 / 1
13.30
 splitStaticPath
100.00% covered (success)
100.00%
4 / 4
77.78% covered (warning)
77.78%
7 / 9
33.33% covered (danger)
33.33%
2 / 6
100.00% covered (success)
100.00%
1 / 1
3.19
 assertSafeStaticPath
100.00% covered (success)
100.00%
6 / 6
85.71% covered (warning)
85.71%
12 / 14
15.00% covered (danger)
15.00%
3 / 20
100.00% covered (success)
100.00%
1 / 1
13.83
 isInsideDirectory
100.00% covered (success)
100.00%
1 / 1
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Router;
6
7use Celema\Router\Exception\InvalidArgumentException;
8use Celema\Router\Exception\MethodNotAllowedException;
9use Celema\Router\Exception\NotFoundException;
10use Celema\Router\Exception\RuntimeException;
11use Closure;
12use Override;
13use Psr\Http\Message\ServerRequestInterface as Request;
14use Stringable;
15
16/** @psalm-api */
17class Router implements RouteAdder
18{
19    use AddsRoutes;
20
21    protected readonly string $globalPrefix;
22
23    public function __construct(string $globalPrefix = '')
24    {
25        $globalPrefix = trim($globalPrefix, '/');
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
27    }
28
29    protected const string ANY = 'ANY';
30
31    /** @var array<string, list<Route>> */
32    protected array $routes = [];
33
34    /** @var array<string, StaticRoute> */
35    protected array $staticRoutes = [];
36
37    /** @var array<string, Route> */
38    protected array $names = [];
39
40    #[Override]
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name !== '') {
56            if (array_key_exists($name, $this->names)) {
57                throw new RuntimeException('Duplicate route name: ' . $name);
58            }
59
60            $this->names[$name] = $route;
61        }
62
63        return $route;
64    }
65
66    #[Override]
67    public function group(
68        string $patternPrefix,
69        Closure $createClosure,
70        string $namePrefix = '',
71    ): void {
72        Group::register($this, $patternPrefix, $createClosure, $namePrefix);
73    }
74
75    public function addStatic(
76        string $prefix,
77        string $dir,
78        string $name = '',
79    ): void {
80        if ($name === '') {
81            $name = $prefix;
82        }
83
84        if (array_key_exists($name, $this->staticRoutes)) {
85            throw new RuntimeException(
86                'Duplicate static route: '
87                    . $name
88                    . '. If you want to use the same '
89                    . 'url prefix you have to create static routes with names.',
90            );
91        }
92
93        if (is_dir($dir)) {
94            $this->staticRoutes[$name] = new StaticRoute(
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
96                dir: $dir,
97            );
98        } else {
99            throw new RuntimeException("The static directory does not exist: {$dir}");
100        }
101    }
102
103    public function asset(
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
112            throw new NotFoundException('Static route not found: ' . $name);
113        }
114
115        [$file, $hasQuery] = $this->splitStaticPath($path);
116        $this->assertSafeStaticPath($file);
117
118        if ($bust) {
119            $buster = $this->getCacheBuster($route->dir, $file);
120
121            if ($buster !== '') {
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
123            }
124        }
125
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
127    }
128
129    /**
130     * @param array<string, mixed> $params
131     * @param array<string, mixed> $query
132     */
133    public function url(
134        string $name,
135        array $params = [],
136        array $query = [],
137        ?string $host = null,
138    ): string {
139        $route = $this->names[$name] ?? null;
140
141        if (!$route) {
142            throw new NotFoundException('Route not found: ' . $name);
143        }
144
145        $url = $this->applyGlobalPrefix($route->url($params));
146        $queryString = $this->queryString($query);
147
148        if ($queryString !== '') {
149            $url .= '?' . $queryString;
150        }
151
152        return $this->prependHost($url, $host);
153    }
154
155    public function match(Request $request): RouteMatch
156    {
157        $url = rawurldecode($request->getUri()->getPath());
158        $requestMethod = strtoupper($request->getMethod());
159        // HEAD is GET without a body (RFC 9110), so GET routes answer it
160        // unless a HEAD route matches first.
161        $methods = $requestMethod === 'HEAD' ? ['HEAD', 'GET', self::ANY] : [$requestMethod, self::ANY];
162
163        foreach ($methods as $method) {
164            foreach ($this->routes[$method] ?? [] as $route) {
165                $params = $route->match($url, $this->globalPrefix);
166
167                if ($params !== null) {
168                    return new RouteMatch($route, $params, $requestMethod);
169                }
170            }
171        }
172
173        $allowedMethods = [];
174
175        foreach ($this->routes as $method => $routes) {
176            if ($method === $requestMethod || $method === self::ANY) {
177                continue;
178            }
179
180            foreach ($routes as $route) {
181                if ($route->match($url, $this->globalPrefix) === null) {
182                    continue;
183                }
184
185                $allowedMethods[] = $method;
186
187                if ($method === 'GET') {
188                    $allowedMethods[] = 'HEAD';
189                }
190
191                break;
192            }
193        }
194
195        if (count($allowedMethods) > 0) {
196            throw new MethodNotAllowedException($allowedMethods);
197        }
198
199        throw new NotFoundException();
200    }
201
202    private function applyGlobalPrefix(string $path): string
203    {
204        if ($this->globalPrefix === '') {
205            return $path;
206        }
207
208        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
209    }
210
211    private function prependHost(string $path, ?string $host): string
212    {
213        return $host === null ? $path : rtrim($host, '/') . $path;
214    }
215
216    /** @param array<string, mixed> $query */
217    private function queryString(array $query): string
218    {
219        $normalized = $this->normalizeQuery($query);
220
221        return http_build_query($normalized, '', '&', PHP_QUERY_RFC3986);
222    }
223
224    /**
225     * @param array<string, mixed> $query
226     * @return array<string, bool|int|float|string|list<bool|int|float|string>>
227     */
228    private function normalizeQuery(array $query): array
229    {
230        $normalized = [];
231
232        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
233        foreach ($query as $name => $value) {
234            if ($value === null) {
235                continue;
236            }
237
238            if (is_array($value)) {
239                if (!array_is_list($value)) {
240                    throw new InvalidArgumentException(
241                        'Query parameter must be scalar or a list of scalars: ' . $name,
242                    );
243                }
244
245                $normalized[$name] = array_map(
246                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
247                    $value,
248                );
249
250                continue;
251            }
252
253            $normalized[$name] = $this->queryValue($value, $name);
254        }
255
256        return $normalized;
257    }
258
259    private function queryValue(mixed $value, string $name): bool|int|float|string
260    {
261        if (is_scalar($value)) {
262            return $value;
263        }
264
265        if ($value instanceof Stringable) {
266            return (string) $value;
267        }
268
269        throw new InvalidArgumentException('Query parameter must be scalar or a list of scalars: '
270            . $name);
271    }
272
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
278            return '';
279        }
280
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
285            return '';
286        }
287
288        $mtime = filemtime($file);
289
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
291    }
292
293    /** @return array{string, bool} */
294    private function splitStaticPath(string $path): array
295    {
296        $queryStart = strpos($path, '?');
297
298        if ($queryStart === false) {
299            return [$path, false];
300        }
301
302        return [substr($path, 0, $queryStart), true];
303    }
304
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
310            throw new InvalidArgumentException('Static path must stay inside static root');
311        }
312
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
319
320    private function isInsideDirectory(string $file, string $dir): bool
321    {
322        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
323    }
324}

Paths

Below are the source code lines that represent each code path as identified by Xdebug. Please note a path is not necessarily coterminous with a line, a line may contain multiple paths and therefore show up more than once. Please also be aware that some paths may include implicit rather than explicit branches, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Router->__construct
23    public function __construct(string $globalPrefix = '')
24    {
25        $globalPrefix = trim($globalPrefix, '/');
 
25        $globalPrefix = trim($globalPrefix, '/');
 
25        $globalPrefix = trim($globalPrefix, '/');
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
 
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
 
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
27    }
23    public function __construct(string $globalPrefix = '')
24    {
25        $globalPrefix = trim($globalPrefix, '/');
 
25        $globalPrefix = trim($globalPrefix, '/');
 
25        $globalPrefix = trim($globalPrefix, '/');
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
 
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
 
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
27    }
23    public function __construct(string $globalPrefix = '')
24    {
25        $globalPrefix = trim($globalPrefix, '/');
 
25        $globalPrefix = trim($globalPrefix, '/');
 
25        $globalPrefix = trim($globalPrefix, '/');
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
 
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
 
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
27    }
23    public function __construct(string $globalPrefix = '')
24    {
25        $globalPrefix = trim($globalPrefix, '/');
 
25        $globalPrefix = trim($globalPrefix, '/');
 
25        $globalPrefix = trim($globalPrefix, '/');
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
 
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
 
26        $this->globalPrefix = $globalPrefix === '' ? '' : '/' . $globalPrefix;
27    }
Router->addRoute
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name !== '') {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
57                throw new RuntimeException('Duplicate route name: ' . $name);
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name !== '') {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
60            $this->names[$name] = $route;
61        }
62
63        return $route;
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name !== '') {
 
55        if ($name !== '') {
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
57                throw new RuntimeException('Duplicate route name: ' . $name);
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
60            $this->names[$name] = $route;
61        }
62
63        return $route;
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
55        if ($name !== '') {
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name !== '') {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
57                throw new RuntimeException('Duplicate route name: ' . $name);
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name !== '') {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
60            $this->names[$name] = $route;
61        }
62
63        return $route;
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name !== '') {
 
55        if ($name !== '') {
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
57                throw new RuntimeException('Duplicate route name: ' . $name);
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
60            $this->names[$name] = $route;
61        }
62
63        return $route;
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
55        if ($name !== '') {
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name !== '') {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
57                throw new RuntimeException('Duplicate route name: ' . $name);
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name !== '') {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
60            $this->names[$name] = $route;
61        }
62
63        return $route;
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
52            $this->routes[self::ANY][] = $route;
53        }
54
55        if ($name !== '') {
 
55        if ($name !== '') {
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
57                throw new RuntimeException('Duplicate route name: ' . $name);
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
55        if ($name !== '') {
 
56            if (array_key_exists($name, $this->names)) {
 
60            $this->names[$name] = $route;
61        }
62
63        return $route;
 
63        return $route;
64    }
41    public function addRoute(Route $route): Route
42    {
43        $name = $route->name();
44        $noMethodGiven = true;
45
46        foreach ($route->methods() as $method) {
 
46        foreach ($route->methods() as $method) {
47            $noMethodGiven = false;
48            $this->routes[$method][] = $route;
49        }
50
51        if ($noMethodGiven) {
 
55        if ($name !== '') {
 
63        return $route;
64    }
Router->addStatic
76        string $prefix,
77        string $dir,
78        string $name = '',
79    ): void {
80        if ($name === '') {
 
81            $name = $prefix;
82        }
83
84        if (array_key_exists($name, $this->staticRoutes)) {
 
84        if (array_key_exists($name, $this->staticRoutes)) {
 
85            throw new RuntimeException(
86                'Duplicate static route: '
87                    . $name
88                    . '. If you want to use the same '
89                    . 'url prefix you have to create static routes with names.',
76        string $prefix,
77        string $dir,
78        string $name = '',
79    ): void {
80        if ($name === '') {
 
81            $name = $prefix;
82        }
83
84        if (array_key_exists($name, $this->staticRoutes)) {
 
84        if (array_key_exists($name, $this->staticRoutes)) {
 
93        if (is_dir($dir)) {
 
94            $this->staticRoutes[$name] = new StaticRoute(
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
93        if (is_dir($dir)) {
94            $this->staticRoutes[$name] = new StaticRoute(
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
101    }
76        string $prefix,
77        string $dir,
78        string $name = '',
79    ): void {
80        if ($name === '') {
 
81            $name = $prefix;
82        }
83
84        if (array_key_exists($name, $this->staticRoutes)) {
 
84        if (array_key_exists($name, $this->staticRoutes)) {
 
93        if (is_dir($dir)) {
 
94            $this->staticRoutes[$name] = new StaticRoute(
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
93        if (is_dir($dir)) {
94            $this->staticRoutes[$name] = new StaticRoute(
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
101    }
76        string $prefix,
77        string $dir,
78        string $name = '',
79    ): void {
80        if ($name === '') {
 
81            $name = $prefix;
82        }
83
84        if (array_key_exists($name, $this->staticRoutes)) {
 
84        if (array_key_exists($name, $this->staticRoutes)) {
 
93        if (is_dir($dir)) {
 
99            throw new RuntimeException("The static directory does not exist: {$dir}");
76        string $prefix,
77        string $dir,
78        string $name = '',
79    ): void {
80        if ($name === '') {
 
84        if (array_key_exists($name, $this->staticRoutes)) {
 
85            throw new RuntimeException(
86                'Duplicate static route: '
87                    . $name
88                    . '. If you want to use the same '
89                    . 'url prefix you have to create static routes with names.',
76        string $prefix,
77        string $dir,
78        string $name = '',
79    ): void {
80        if ($name === '') {
 
84        if (array_key_exists($name, $this->staticRoutes)) {
 
93        if (is_dir($dir)) {
 
94            $this->staticRoutes[$name] = new StaticRoute(
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
93        if (is_dir($dir)) {
94            $this->staticRoutes[$name] = new StaticRoute(
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
101    }
76        string $prefix,
77        string $dir,
78        string $name = '',
79    ): void {
80        if ($name === '') {
 
84        if (array_key_exists($name, $this->staticRoutes)) {
 
93        if (is_dir($dir)) {
 
94            $this->staticRoutes[$name] = new StaticRoute(
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
93        if (is_dir($dir)) {
94            $this->staticRoutes[$name] = new StaticRoute(
95                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
101    }
76        string $prefix,
77        string $dir,
78        string $name = '',
79    ): void {
80        if ($name === '') {
 
84        if (array_key_exists($name, $this->staticRoutes)) {
 
93        if (is_dir($dir)) {
 
99            throw new RuntimeException("The static directory does not exist: {$dir}");
Router->applyGlobalPrefix
202    private function applyGlobalPrefix(string $path): string
203    {
204        if ($this->globalPrefix === '') {
 
205            return $path;
202    private function applyGlobalPrefix(string $path): string
203    {
204        if ($this->globalPrefix === '') {
 
208        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
 
208        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
 
208        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
209    }
202    private function applyGlobalPrefix(string $path): string
203    {
204        if ($this->globalPrefix === '') {
 
208        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
 
208        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
 
208        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
209    }
Router->assertSafeStaticPath
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
310            throw new InvalidArgumentException('Static path must stay inside static root');
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
314            if ($segment === '..') {
 
315                throw new InvalidArgumentException('Static path must stay inside static root');
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
314            if ($segment === '..') {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
310            throw new InvalidArgumentException('Static path must stay inside static root');
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
314            if ($segment === '..') {
 
315                throw new InvalidArgumentException('Static path must stay inside static root');
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
314            if ($segment === '..') {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
310            throw new InvalidArgumentException('Static path must stay inside static root');
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
314            if ($segment === '..') {
 
315                throw new InvalidArgumentException('Static path must stay inside static root');
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
314            if ($segment === '..') {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
310            throw new InvalidArgumentException('Static path must stay inside static root');
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
314            if ($segment === '..') {
 
315                throw new InvalidArgumentException('Static path must stay inside static root');
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
314            if ($segment === '..') {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
305    private function assertSafeStaticPath(string $path): void
306    {
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
307        $decodedPath = str_replace('\\', '/', rawurldecode($path));
308
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
309        if (str_contains($decodedPath, "\0")) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
 
313        foreach (explode('/', $decodedPath) as $segment) {
314            if ($segment === '..') {
315                throw new InvalidArgumentException('Static path must stay inside static root');
316            }
317        }
318    }
Router->asset
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
 
112            throw new NotFoundException('Static route not found: ' . $name);
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
 
115        [$file, $hasQuery] = $this->splitStaticPath($path);
116        $this->assertSafeStaticPath($file);
117
118        if ($bust) {
 
119            $buster = $this->getCacheBuster($route->dir, $file);
120
121            if ($buster !== '') {
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
123            }
124        }
125
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
127    }
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
 
115        [$file, $hasQuery] = $this->splitStaticPath($path);
116        $this->assertSafeStaticPath($file);
117
118        if ($bust) {
 
119            $buster = $this->getCacheBuster($route->dir, $file);
120
121            if ($buster !== '') {
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
123            }
124        }
125
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
127    }
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
 
115        [$file, $hasQuery] = $this->splitStaticPath($path);
116        $this->assertSafeStaticPath($file);
117
118        if ($bust) {
 
119            $buster = $this->getCacheBuster($route->dir, $file);
120
121            if ($buster !== '') {
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
123            }
124        }
125
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
127    }
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
 
115        [$file, $hasQuery] = $this->splitStaticPath($path);
116        $this->assertSafeStaticPath($file);
117
118        if ($bust) {
 
119            $buster = $this->getCacheBuster($route->dir, $file);
120
121            if ($buster !== '') {
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
122                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
123            }
124        }
125
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
127    }
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
 
115        [$file, $hasQuery] = $this->splitStaticPath($path);
116        $this->assertSafeStaticPath($file);
117
118        if ($bust) {
 
119            $buster = $this->getCacheBuster($route->dir, $file);
120
121            if ($buster !== '') {
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
127    }
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
 
115        [$file, $hasQuery] = $this->splitStaticPath($path);
116        $this->assertSafeStaticPath($file);
117
118        if ($bust) {
 
119            $buster = $this->getCacheBuster($route->dir, $file);
120
121            if ($buster !== '') {
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
127    }
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
 
115        [$file, $hasQuery] = $this->splitStaticPath($path);
116        $this->assertSafeStaticPath($file);
117
118        if ($bust) {
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
127    }
104        string $name,
105        string $path,
106        bool $bust = false,
107        ?string $host = null,
108    ): string {
109        $route = $this->staticRoutes[$name] ?? null;
110
111        if (!$route) {
 
115        [$file, $hasQuery] = $this->splitStaticPath($path);
116        $this->assertSafeStaticPath($file);
117
118        if ($bust) {
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
126        return $this->prependHost($route->prefix . trim($path, '/'), $host);
127    }
Router->getCacheBuster
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
278            return '';
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
285            return '';
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
288        $mtime = filemtime($file);
289
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
291    }
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
288        $mtime = filemtime($file);
289
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
291    }
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
285            return '';
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
288        $mtime = filemtime($file);
289
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
291    }
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
288        $mtime = filemtime($file);
289
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
291    }
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
285            return '';
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
288        $mtime = filemtime($file);
289
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
291    }
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
288        $mtime = filemtime($file);
289
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
291    }
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
285            return '';
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
288        $mtime = filemtime($file);
289
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
291    }
273    protected function getCacheBuster(string $dir, string $path): string
274    {
275        $root = realpath($dir);
276
277        if ($root === false) {
 
281        $ds = DIRECTORY_SEPARATOR;
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
282        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
283
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
284        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
288        $mtime = filemtime($file);
289
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
290        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
291    }
Router->group
68        string $patternPrefix,
69        Closure $createClosure,
70        string $namePrefix = '',
71    ): void {
72        Group::register($this, $patternPrefix, $createClosure, $namePrefix);
73    }
Router->isInsideDirectory
320    private function isInsideDirectory(string $file, string $dir): bool
321    {
322        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
 
322        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
 
322        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
323    }
320    private function isInsideDirectory(string $file, string $dir): bool
321    {
322        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
 
322        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
 
322        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
323    }
Router->normalizeQuery
228    private function normalizeQuery(array $query): array
229    {
230        $normalized = [];
231
232        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
234            if ($value === null) {
 
235                continue;
 
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
234            if ($value === null) {
235                continue;
236            }
237
238            if (is_array($value)) {
239                if (!array_is_list($value)) {
240                    throw new InvalidArgumentException(
241                        'Query parameter must be scalar or a list of scalars: ' . $name,
242                    );
243                }
244
245                $normalized[$name] = array_map(
246                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
247                    $value,
248                );
249
250                continue;
251            }
252
253            $normalized[$name] = $this->queryValue($value, $name);
254        }
255
256        return $normalized;
257    }
228    private function normalizeQuery(array $query): array
229    {
230        $normalized = [];
231
232        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
234            if ($value === null) {
 
238            if (is_array($value)) {
 
239                if (!array_is_list($value)) {
 
240                    throw new InvalidArgumentException(
241                        'Query parameter must be scalar or a list of scalars: ' . $name,
228    private function normalizeQuery(array $query): array
229    {
230        $normalized = [];
231
232        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
234            if ($value === null) {
 
238            if (is_array($value)) {
 
239                if (!array_is_list($value)) {
 
245                $normalized[$name] = array_map(
246                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
247                    $value,
248                );
249
250                continue;
 
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
234            if ($value === null) {
235                continue;
236            }
237
238            if (is_array($value)) {
239                if (!array_is_list($value)) {
240                    throw new InvalidArgumentException(
241                        'Query parameter must be scalar or a list of scalars: ' . $name,
242                    );
243                }
244
245                $normalized[$name] = array_map(
246                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
247                    $value,
248                );
249
250                continue;
251            }
252
253            $normalized[$name] = $this->queryValue($value, $name);
254        }
255
256        return $normalized;
257    }
228    private function normalizeQuery(array $query): array
229    {
230        $normalized = [];
231
232        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
234            if ($value === null) {
 
238            if (is_array($value)) {
 
233        foreach ($query as $name => $value) {
234            if ($value === null) {
235                continue;
236            }
237
238            if (is_array($value)) {
239                if (!array_is_list($value)) {
240                    throw new InvalidArgumentException(
241                        'Query parameter must be scalar or a list of scalars: ' . $name,
242                    );
243                }
244
245                $normalized[$name] = array_map(
246                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
247                    $value,
248                );
249
250                continue;
251            }
252
253            $normalized[$name] = $this->queryValue($value, $name);
 
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
234            if ($value === null) {
235                continue;
236            }
237
238            if (is_array($value)) {
239                if (!array_is_list($value)) {
240                    throw new InvalidArgumentException(
241                        'Query parameter must be scalar or a list of scalars: ' . $name,
242                    );
243                }
244
245                $normalized[$name] = array_map(
246                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
247                    $value,
248                );
249
250                continue;
251            }
252
253            $normalized[$name] = $this->queryValue($value, $name);
254        }
255
256        return $normalized;
257    }
228    private function normalizeQuery(array $query): array
229    {
230        $normalized = [];
231
232        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
234            if ($value === null) {
235                continue;
236            }
237
238            if (is_array($value)) {
239                if (!array_is_list($value)) {
240                    throw new InvalidArgumentException(
241                        'Query parameter must be scalar or a list of scalars: ' . $name,
242                    );
243                }
244
245                $normalized[$name] = array_map(
246                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
247                    $value,
248                );
249
250                continue;
251            }
252
253            $normalized[$name] = $this->queryValue($value, $name);
254        }
255
256        return $normalized;
257    }
228    private function normalizeQuery(array $query): array
229    {
230        $normalized = [];
231
232        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
233        foreach ($query as $name => $value) {
 
233        foreach ($query as $name => $value) {
234            if ($value === null) {
235                continue;
236            }
237
238            if (is_array($value)) {
239                if (!array_is_list($value)) {
240                    throw new InvalidArgumentException(
241                        'Query parameter must be scalar or a list of scalars: ' . $name,
242                    );
243                }
244
245                $normalized[$name] = array_map(
246                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
247                    $value,
248                );
249
250                continue;
251            }
252
253            $normalized[$name] = $this->queryValue($value, $name);
254        }
255
256        return $normalized;
257    }
Router->prependHost
211    private function prependHost(string $path, ?string $host): string
212    {
213        return $host === null ? $path : rtrim($host, '/') . $path;
 
213        return $host === null ? $path : rtrim($host, '/') . $path;
 
213        return $host === null ? $path : rtrim($host, '/') . $path;
214    }
211    private function prependHost(string $path, ?string $host): string
212    {
213        return $host === null ? $path : rtrim($host, '/') . $path;
 
213        return $host === null ? $path : rtrim($host, '/') . $path;
 
213        return $host === null ? $path : rtrim($host, '/') . $path;
214    }
Router->queryString
217    private function queryString(array $query): string
218    {
219        $normalized = $this->normalizeQuery($query);
220
221        return http_build_query($normalized, '', '&', PHP_QUERY_RFC3986);
222    }
Router->queryValue
259    private function queryValue(mixed $value, string $name): bool|int|float|string
260    {
261        if (is_scalar($value)) {
 
262            return $value;
259    private function queryValue(mixed $value, string $name): bool|int|float|string
260    {
261        if (is_scalar($value)) {
 
265        if ($value instanceof Stringable) {
 
266            return (string) $value;
259    private function queryValue(mixed $value, string $name): bool|int|float|string
260    {
261        if (is_scalar($value)) {
 
265        if ($value instanceof Stringable) {
 
269        throw new InvalidArgumentException('Query parameter must be scalar or a list of scalars: '
270            . $name);
271    }
Router->splitStaticPath
294    private function splitStaticPath(string $path): array
295    {
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
297
298        if ($queryStart === false) {
 
299            return [$path, false];
294    private function splitStaticPath(string $path): array
295    {
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
297
298        if ($queryStart === false) {
 
302        return [substr($path, 0, $queryStart), true];
 
302        return [substr($path, 0, $queryStart), true];
 
302        return [substr($path, 0, $queryStart), true];
303    }
294    private function splitStaticPath(string $path): array
295    {
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
297
298        if ($queryStart === false) {
 
302        return [substr($path, 0, $queryStart), true];
 
302        return [substr($path, 0, $queryStart), true];
 
302        return [substr($path, 0, $queryStart), true];
303    }
294    private function splitStaticPath(string $path): array
295    {
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
297
298        if ($queryStart === false) {
 
299            return [$path, false];
294    private function splitStaticPath(string $path): array
295    {
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
297
298        if ($queryStart === false) {
 
302        return [substr($path, 0, $queryStart), true];
 
302        return [substr($path, 0, $queryStart), true];
 
302        return [substr($path, 0, $queryStart), true];
303    }
294    private function splitStaticPath(string $path): array
295    {
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
 
296        $queryStart = strpos($path, '?');
297
298        if ($queryStart === false) {
 
302        return [substr($path, 0, $queryStart), true];
 
302        return [substr($path, 0, $queryStart), true];
 
302        return [substr($path, 0, $queryStart), true];
303    }
Router->url
134        string $name,
135        array $params = [],
136        array $query = [],
137        ?string $host = null,
138    ): string {
139        $route = $this->names[$name] ?? null;
140
141        if (!$route) {
 
142            throw new NotFoundException('Route not found: ' . $name);
134        string $name,
135        array $params = [],
136        array $query = [],
137        ?string $host = null,
138    ): string {
139        $route = $this->names[$name] ?? null;
140
141        if (!$route) {
 
145        $url = $this->applyGlobalPrefix($route->url($params));
146        $queryString = $this->queryString($query);
147
148        if ($queryString !== '') {
 
149            $url .= '?' . $queryString;
150        }
151
152        return $this->prependHost($url, $host);
 
152        return $this->prependHost($url, $host);
153    }
134        string $name,
135        array $params = [],
136        array $query = [],
137        ?string $host = null,
138    ): string {
139        $route = $this->names[$name] ?? null;
140
141        if (!$route) {
 
145        $url = $this->applyGlobalPrefix($route->url($params));
146        $queryString = $this->queryString($query);
147
148        if ($queryString !== '') {
 
152        return $this->prependHost($url, $host);
153    }
{closure:/workspace/celema/router/src/Router.php:246-246}
246                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),