Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
116 / 116
91.85% covered (success)
91.85%
124 / 135
20.91% covered (danger)
20.91%
46 / 220
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Router
100.00% covered (success)
100.00%
116 / 116
91.85% covered (success)
91.85%
124 / 135
20.91% covered (danger)
20.91%
46 / 220
100.00% covered (success)
100.00%
16 / 16
1607.51
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%
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
 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%
19 / 19
100.00% covered (success)
100.00%
23 / 23
2.48% covered (danger)
2.48%
3 / 121
100.00% covered (success)
100.00%
1 / 1
102.74
 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 Celemas\Router;
6
7use Celemas\Router\Exception\InvalidArgumentException;
8use Celemas\Router\Exception\MethodNotAllowedException;
9use Celemas\Router\Exception\NotFoundException;
10use Celemas\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 = Group::make($patternPrefix, $createClosure, $namePrefix);
73        $group->register($this);
74    }
75
76    public function addStatic(
77        string $prefix,
78        string $dir,
79        string $name = '',
80    ): void {
81        if ($name === '') {
82            $name = $prefix;
83        }
84
85        if (array_key_exists($name, $this->staticRoutes)) {
86            throw new RuntimeException(
87                'Duplicate static route: '
88                . $name
89                . '. If you want to use the same '
90                . 'url prefix you have to create static routes with names.',
91            );
92        }
93
94        if (is_dir($dir)) {
95            $this->staticRoutes[$name] = new StaticRoute(
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
97                dir: $dir,
98            );
99        } else {
100            throw new RuntimeException("The static directory does not exist: {$dir}");
101        }
102    }
103
104    public function asset(
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
113            throw new NotFoundException('Static route not found: ' . $name);
114        }
115
116        [$file, $hasQuery] = $this->splitStaticPath($path);
117        $this->assertSafeStaticPath($file);
118
119        if ($bust) {
120            $buster = $this->getCacheBuster($route->dir, $file);
121
122            if ($buster !== '') {
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
124            }
125        }
126
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
128    }
129
130    /**
131     * @param array<string, mixed> $params
132     * @param array<string, mixed> $query
133     */
134    public function url(
135        string $name,
136        array $params = [],
137        array $query = [],
138        ?string $host = null,
139    ): string {
140        $route = $this->names[$name] ?? null;
141
142        if (!$route) {
143            throw new NotFoundException('Route not found: ' . $name);
144        }
145
146        $url = $this->applyGlobalPrefix($route->url($params));
147        $queryString = $this->queryString($query);
148
149        if ($queryString !== '') {
150            $url .= '?' . $queryString;
151        }
152
153        return $this->prependHost($url, $host);
154    }
155
156    public function match(Request $request): RouteMatch
157    {
158        $url = rawurldecode($request->getUri()->getPath());
159        $requestMethod = strtoupper($request->getMethod());
160
161        foreach ([$requestMethod, self::ANY] as $method) {
162            foreach ($this->routes[$method] ?? [] as $route) {
163                $params = $route->match($url, $this->globalPrefix);
164
165                if ($params !== null) {
166                    return new RouteMatch($route, $params, $requestMethod);
167                }
168            }
169        }
170
171        /** @var list<string> $allowedMethods */
172        $allowedMethods = [];
173
174        foreach ($this->routes as $method => $routes) {
175            if ($method === $requestMethod || $method === self::ANY) {
176                continue;
177            }
178
179            foreach ($routes as $route) {
180                if ($route->match($url, $this->globalPrefix) === null) {
181                    continue;
182                }
183
184                $allowedMethods[] = $method;
185
186                break;
187            }
188        }
189
190        if (count($allowedMethods) > 0) {
191            throw new MethodNotAllowedException($allowedMethods);
192        }
193
194        throw new NotFoundException();
195    }
196
197    private function applyGlobalPrefix(string $path): string
198    {
199        if ($this->globalPrefix === '') {
200            return $path;
201        }
202
203        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
204    }
205
206    private function prependHost(string $path, ?string $host): string
207    {
208        return $host === null ? $path : rtrim($host, '/') . $path;
209    }
210
211    /** @param array<string, mixed> $query */
212    private function queryString(array $query): string
213    {
214        $normalized = $this->normalizeQuery($query);
215
216        return http_build_query($normalized, '', '&', PHP_QUERY_RFC3986);
217    }
218
219    /**
220     * @param array<string, mixed> $query
221     * @return array<string, bool|int|float|string|list<bool|int|float|string>>
222     */
223    private function normalizeQuery(array $query): array
224    {
225        $normalized = [];
226
227        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
228        foreach ($query as $name => $value) {
229            if ($value === null) {
230                continue;
231            }
232
233            if (is_array($value)) {
234                if (!array_is_list($value)) {
235                    throw new InvalidArgumentException(
236                        'Query parameter must be scalar or a list of scalars: ' . $name,
237                    );
238                }
239
240                $normalized[$name] = array_map(
241                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
242                    $value,
243                );
244
245                continue;
246            }
247
248            $normalized[$name] = $this->queryValue($value, $name);
249        }
250
251        return $normalized;
252    }
253
254    private function queryValue(mixed $value, string $name): bool|int|float|string
255    {
256        if (is_scalar($value)) {
257            return $value;
258        }
259
260        if ($value instanceof Stringable) {
261            return (string) $value;
262        }
263
264        throw new InvalidArgumentException('Query parameter must be scalar or a list of scalars: '
265        . $name);
266    }
267
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
273            return '';
274        }
275
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
280            return '';
281        }
282
283        $mtime = filemtime($file);
284
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
286    }
287
288    /** @return array{string, bool} */
289    private function splitStaticPath(string $path): array
290    {
291        $queryStart = strpos($path, '?');
292
293        if ($queryStart === false) {
294            return [$path, false];
295        }
296
297        return [substr($path, 0, $queryStart), true];
298    }
299
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
305            throw new InvalidArgumentException('Static path must stay inside static root');
306        }
307
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
314
315    private function isInsideDirectory(string $file, string $dir): bool
316    {
317        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
318    }
319}

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
77        string $prefix,
78        string $dir,
79        string $name = '',
80    ): void {
81        if ($name === '') {
 
82            $name = $prefix;
83        }
84
85        if (array_key_exists($name, $this->staticRoutes)) {
 
85        if (array_key_exists($name, $this->staticRoutes)) {
 
86            throw new RuntimeException(
87                'Duplicate static route: '
88                . $name
89                . '. If you want to use the same '
90                . 'url prefix you have to create static routes with names.',
77        string $prefix,
78        string $dir,
79        string $name = '',
80    ): void {
81        if ($name === '') {
 
82            $name = $prefix;
83        }
84
85        if (array_key_exists($name, $this->staticRoutes)) {
 
85        if (array_key_exists($name, $this->staticRoutes)) {
 
94        if (is_dir($dir)) {
 
95            $this->staticRoutes[$name] = new StaticRoute(
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
94        if (is_dir($dir)) {
95            $this->staticRoutes[$name] = new StaticRoute(
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
102    }
77        string $prefix,
78        string $dir,
79        string $name = '',
80    ): void {
81        if ($name === '') {
 
82            $name = $prefix;
83        }
84
85        if (array_key_exists($name, $this->staticRoutes)) {
 
85        if (array_key_exists($name, $this->staticRoutes)) {
 
94        if (is_dir($dir)) {
 
95            $this->staticRoutes[$name] = new StaticRoute(
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
94        if (is_dir($dir)) {
95            $this->staticRoutes[$name] = new StaticRoute(
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
102    }
77        string $prefix,
78        string $dir,
79        string $name = '',
80    ): void {
81        if ($name === '') {
 
82            $name = $prefix;
83        }
84
85        if (array_key_exists($name, $this->staticRoutes)) {
 
85        if (array_key_exists($name, $this->staticRoutes)) {
 
94        if (is_dir($dir)) {
 
100            throw new RuntimeException("The static directory does not exist: {$dir}");
77        string $prefix,
78        string $dir,
79        string $name = '',
80    ): void {
81        if ($name === '') {
 
85        if (array_key_exists($name, $this->staticRoutes)) {
 
86            throw new RuntimeException(
87                'Duplicate static route: '
88                . $name
89                . '. If you want to use the same '
90                . 'url prefix you have to create static routes with names.',
77        string $prefix,
78        string $dir,
79        string $name = '',
80    ): void {
81        if ($name === '') {
 
85        if (array_key_exists($name, $this->staticRoutes)) {
 
94        if (is_dir($dir)) {
 
95            $this->staticRoutes[$name] = new StaticRoute(
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
94        if (is_dir($dir)) {
95            $this->staticRoutes[$name] = new StaticRoute(
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
102    }
77        string $prefix,
78        string $dir,
79        string $name = '',
80    ): void {
81        if ($name === '') {
 
85        if (array_key_exists($name, $this->staticRoutes)) {
 
94        if (is_dir($dir)) {
 
95            $this->staticRoutes[$name] = new StaticRoute(
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
94        if (is_dir($dir)) {
95            $this->staticRoutes[$name] = new StaticRoute(
96                prefix: $this->globalPrefix . '/' . trim($prefix, '/') . '/',
 
102    }
77        string $prefix,
78        string $dir,
79        string $name = '',
80    ): void {
81        if ($name === '') {
 
85        if (array_key_exists($name, $this->staticRoutes)) {
 
94        if (is_dir($dir)) {
 
100            throw new RuntimeException("The static directory does not exist: {$dir}");
Router->applyGlobalPrefix
197    private function applyGlobalPrefix(string $path): string
198    {
199        if ($this->globalPrefix === '') {
 
200            return $path;
197    private function applyGlobalPrefix(string $path): string
198    {
199        if ($this->globalPrefix === '') {
 
203        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
 
203        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
 
203        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
204    }
197    private function applyGlobalPrefix(string $path): string
198    {
199        if ($this->globalPrefix === '') {
 
203        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
 
203        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
 
203        return $path === '/' ? $this->globalPrefix : $this->globalPrefix . $path;
204    }
Router->assertSafeStaticPath
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
305            throw new InvalidArgumentException('Static path must stay inside static root');
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
309            if ($segment === '..') {
 
310                throw new InvalidArgumentException('Static path must stay inside static root');
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
309            if ($segment === '..') {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
305            throw new InvalidArgumentException('Static path must stay inside static root');
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
309            if ($segment === '..') {
 
310                throw new InvalidArgumentException('Static path must stay inside static root');
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
309            if ($segment === '..') {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
305            throw new InvalidArgumentException('Static path must stay inside static root');
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
309            if ($segment === '..') {
 
310                throw new InvalidArgumentException('Static path must stay inside static root');
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
309            if ($segment === '..') {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
305            throw new InvalidArgumentException('Static path must stay inside static root');
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
309            if ($segment === '..') {
 
310                throw new InvalidArgumentException('Static path must stay inside static root');
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
309            if ($segment === '..') {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
300    private function assertSafeStaticPath(string $path): void
301    {
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
 
302        $decodedPath = str_replace('\\', '/', rawurldecode($path));
303
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
304        if (str_contains($decodedPath, "\0")) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
 
308        foreach (explode('/', $decodedPath) as $segment) {
309            if ($segment === '..') {
310                throw new InvalidArgumentException('Static path must stay inside static root');
311            }
312        }
313    }
Router->asset
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
 
113            throw new NotFoundException('Static route not found: ' . $name);
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
 
116        [$file, $hasQuery] = $this->splitStaticPath($path);
117        $this->assertSafeStaticPath($file);
118
119        if ($bust) {
 
120            $buster = $this->getCacheBuster($route->dir, $file);
121
122            if ($buster !== '') {
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
124            }
125        }
126
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
128    }
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
 
116        [$file, $hasQuery] = $this->splitStaticPath($path);
117        $this->assertSafeStaticPath($file);
118
119        if ($bust) {
 
120            $buster = $this->getCacheBuster($route->dir, $file);
121
122            if ($buster !== '') {
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
124            }
125        }
126
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
128    }
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
 
116        [$file, $hasQuery] = $this->splitStaticPath($path);
117        $this->assertSafeStaticPath($file);
118
119        if ($bust) {
 
120            $buster = $this->getCacheBuster($route->dir, $file);
121
122            if ($buster !== '') {
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
124            }
125        }
126
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
128    }
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
 
116        [$file, $hasQuery] = $this->splitStaticPath($path);
117        $this->assertSafeStaticPath($file);
118
119        if ($bust) {
 
120            $buster = $this->getCacheBuster($route->dir, $file);
121
122            if ($buster !== '') {
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
 
123                $path .= ($hasQuery ? '&' : '?') . 'v=' . $buster;
124            }
125        }
126
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
128    }
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
 
116        [$file, $hasQuery] = $this->splitStaticPath($path);
117        $this->assertSafeStaticPath($file);
118
119        if ($bust) {
 
120            $buster = $this->getCacheBuster($route->dir, $file);
121
122            if ($buster !== '') {
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
128    }
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
 
116        [$file, $hasQuery] = $this->splitStaticPath($path);
117        $this->assertSafeStaticPath($file);
118
119        if ($bust) {
 
120            $buster = $this->getCacheBuster($route->dir, $file);
121
122            if ($buster !== '') {
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
128    }
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
 
116        [$file, $hasQuery] = $this->splitStaticPath($path);
117        $this->assertSafeStaticPath($file);
118
119        if ($bust) {
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
128    }
105        string $name,
106        string $path,
107        bool $bust = false,
108        ?string $host = null,
109    ): string {
110        $route = $this->staticRoutes[$name] ?? null;
111
112        if (!$route) {
 
116        [$file, $hasQuery] = $this->splitStaticPath($path);
117        $this->assertSafeStaticPath($file);
118
119        if ($bust) {
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
 
127        return $this->prependHost($route->prefix . trim($path, '/'), $host);
128    }
Router->getCacheBuster
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
273            return '';
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
280            return '';
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
283        $mtime = filemtime($file);
284
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
286    }
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
283        $mtime = filemtime($file);
284
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
286    }
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
280            return '';
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
283        $mtime = filemtime($file);
284
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
286    }
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
283        $mtime = filemtime($file);
284
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
286    }
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
280            return '';
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
283        $mtime = filemtime($file);
284
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
286    }
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
283        $mtime = filemtime($file);
284
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
286    }
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
280            return '';
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
283        $mtime = filemtime($file);
284
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
286    }
268    protected function getCacheBuster(string $dir, string $path): string
269    {
270        $root = realpath($dir);
271
272        if ($root === false) {
 
276        $ds = DIRECTORY_SEPARATOR;
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
 
277        $file = realpath($root . $ds . ltrim(str_replace('/', $ds, $path), $ds));
278
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
279        if ($file === false || !$this->isInsideDirectory($file, $root)) {
 
283        $mtime = filemtime($file);
284
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
 
285        return $mtime === false ? '' : hash('xxh32', (string) $mtime);
286    }
Router->group
68        string $patternPrefix,
69        Closure $createClosure,
70        string $namePrefix = '',
71    ): void {
72        $group = Group::make($patternPrefix, $createClosure, $namePrefix);
73        $group->register($this);
74    }
Router->isInsideDirectory
315    private function isInsideDirectory(string $file, string $dir): bool
316    {
317        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
 
317        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
 
317        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
318    }
315    private function isInsideDirectory(string $file, string $dir): bool
316    {
317        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
 
317        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
 
317        return str_starts_with($file, rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
318    }
Router->normalizeQuery
223    private function normalizeQuery(array $query): array
224    {
225        $normalized = [];
226
227        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
229            if ($value === null) {
 
230                continue;
 
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
229            if ($value === null) {
230                continue;
231            }
232
233            if (is_array($value)) {
234                if (!array_is_list($value)) {
235                    throw new InvalidArgumentException(
236                        'Query parameter must be scalar or a list of scalars: ' . $name,
237                    );
238                }
239
240                $normalized[$name] = array_map(
241                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
242                    $value,
243                );
244
245                continue;
246            }
247
248            $normalized[$name] = $this->queryValue($value, $name);
249        }
250
251        return $normalized;
252    }
223    private function normalizeQuery(array $query): array
224    {
225        $normalized = [];
226
227        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
229            if ($value === null) {
 
233            if (is_array($value)) {
 
234                if (!array_is_list($value)) {
 
235                    throw new InvalidArgumentException(
236                        'Query parameter must be scalar or a list of scalars: ' . $name,
223    private function normalizeQuery(array $query): array
224    {
225        $normalized = [];
226
227        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
229            if ($value === null) {
 
233            if (is_array($value)) {
 
234                if (!array_is_list($value)) {
 
240                $normalized[$name] = array_map(
241                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
242                    $value,
243                );
244
245                continue;
 
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
229            if ($value === null) {
230                continue;
231            }
232
233            if (is_array($value)) {
234                if (!array_is_list($value)) {
235                    throw new InvalidArgumentException(
236                        'Query parameter must be scalar or a list of scalars: ' . $name,
237                    );
238                }
239
240                $normalized[$name] = array_map(
241                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
242                    $value,
243                );
244
245                continue;
246            }
247
248            $normalized[$name] = $this->queryValue($value, $name);
249        }
250
251        return $normalized;
252    }
223    private function normalizeQuery(array $query): array
224    {
225        $normalized = [];
226
227        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
229            if ($value === null) {
 
233            if (is_array($value)) {
 
228        foreach ($query as $name => $value) {
229            if ($value === null) {
230                continue;
231            }
232
233            if (is_array($value)) {
234                if (!array_is_list($value)) {
235                    throw new InvalidArgumentException(
236                        'Query parameter must be scalar or a list of scalars: ' . $name,
237                    );
238                }
239
240                $normalized[$name] = array_map(
241                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
242                    $value,
243                );
244
245                continue;
246            }
247
248            $normalized[$name] = $this->queryValue($value, $name);
 
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
229            if ($value === null) {
230                continue;
231            }
232
233            if (is_array($value)) {
234                if (!array_is_list($value)) {
235                    throw new InvalidArgumentException(
236                        'Query parameter must be scalar or a list of scalars: ' . $name,
237                    );
238                }
239
240                $normalized[$name] = array_map(
241                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
242                    $value,
243                );
244
245                continue;
246            }
247
248            $normalized[$name] = $this->queryValue($value, $name);
249        }
250
251        return $normalized;
252    }
223    private function normalizeQuery(array $query): array
224    {
225        $normalized = [];
226
227        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
229            if ($value === null) {
230                continue;
231            }
232
233            if (is_array($value)) {
234                if (!array_is_list($value)) {
235                    throw new InvalidArgumentException(
236                        'Query parameter must be scalar or a list of scalars: ' . $name,
237                    );
238                }
239
240                $normalized[$name] = array_map(
241                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
242                    $value,
243                );
244
245                continue;
246            }
247
248            $normalized[$name] = $this->queryValue($value, $name);
249        }
250
251        return $normalized;
252    }
223    private function normalizeQuery(array $query): array
224    {
225        $normalized = [];
226
227        /** @psalm-suppress MixedAssignment -- query values are intentionally mixed and validated below */
228        foreach ($query as $name => $value) {
 
228        foreach ($query as $name => $value) {
229            if ($value === null) {
230                continue;
231            }
232
233            if (is_array($value)) {
234                if (!array_is_list($value)) {
235                    throw new InvalidArgumentException(
236                        'Query parameter must be scalar or a list of scalars: ' . $name,
237                    );
238                }
239
240                $normalized[$name] = array_map(
241                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),
242                    $value,
243                );
244
245                continue;
246            }
247
248            $normalized[$name] = $this->queryValue($value, $name);
249        }
250
251        return $normalized;
252    }
Router->prependHost
206    private function prependHost(string $path, ?string $host): string
207    {
208        return $host === null ? $path : rtrim($host, '/') . $path;
 
208        return $host === null ? $path : rtrim($host, '/') . $path;
 
208        return $host === null ? $path : rtrim($host, '/') . $path;
209    }
206    private function prependHost(string $path, ?string $host): string
207    {
208        return $host === null ? $path : rtrim($host, '/') . $path;
 
208        return $host === null ? $path : rtrim($host, '/') . $path;
 
208        return $host === null ? $path : rtrim($host, '/') . $path;
209    }
Router->queryString
212    private function queryString(array $query): string
213    {
214        $normalized = $this->normalizeQuery($query);
215
216        return http_build_query($normalized, '', '&', PHP_QUERY_RFC3986);
217    }
Router->queryValue
254    private function queryValue(mixed $value, string $name): bool|int|float|string
255    {
256        if (is_scalar($value)) {
 
257            return $value;
254    private function queryValue(mixed $value, string $name): bool|int|float|string
255    {
256        if (is_scalar($value)) {
 
260        if ($value instanceof Stringable) {
 
261            return (string) $value;
254    private function queryValue(mixed $value, string $name): bool|int|float|string
255    {
256        if (is_scalar($value)) {
 
260        if ($value instanceof Stringable) {
 
264        throw new InvalidArgumentException('Query parameter must be scalar or a list of scalars: '
265        . $name);
266    }
Router->splitStaticPath
289    private function splitStaticPath(string $path): array
290    {
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
292
293        if ($queryStart === false) {
 
294            return [$path, false];
289    private function splitStaticPath(string $path): array
290    {
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
292
293        if ($queryStart === false) {
 
297        return [substr($path, 0, $queryStart), true];
 
297        return [substr($path, 0, $queryStart), true];
 
297        return [substr($path, 0, $queryStart), true];
298    }
289    private function splitStaticPath(string $path): array
290    {
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
292
293        if ($queryStart === false) {
 
297        return [substr($path, 0, $queryStart), true];
 
297        return [substr($path, 0, $queryStart), true];
 
297        return [substr($path, 0, $queryStart), true];
298    }
289    private function splitStaticPath(string $path): array
290    {
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
292
293        if ($queryStart === false) {
 
294            return [$path, false];
289    private function splitStaticPath(string $path): array
290    {
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
292
293        if ($queryStart === false) {
 
297        return [substr($path, 0, $queryStart), true];
 
297        return [substr($path, 0, $queryStart), true];
 
297        return [substr($path, 0, $queryStart), true];
298    }
289    private function splitStaticPath(string $path): array
290    {
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
 
291        $queryStart = strpos($path, '?');
292
293        if ($queryStart === false) {
 
297        return [substr($path, 0, $queryStart), true];
 
297        return [substr($path, 0, $queryStart), true];
 
297        return [substr($path, 0, $queryStart), true];
298    }
Router->url
135        string $name,
136        array $params = [],
137        array $query = [],
138        ?string $host = null,
139    ): string {
140        $route = $this->names[$name] ?? null;
141
142        if (!$route) {
 
143            throw new NotFoundException('Route not found: ' . $name);
135        string $name,
136        array $params = [],
137        array $query = [],
138        ?string $host = null,
139    ): string {
140        $route = $this->names[$name] ?? null;
141
142        if (!$route) {
 
146        $url = $this->applyGlobalPrefix($route->url($params));
147        $queryString = $this->queryString($query);
148
149        if ($queryString !== '') {
 
150            $url .= '?' . $queryString;
151        }
152
153        return $this->prependHost($url, $host);
 
153        return $this->prependHost($url, $host);
154    }
135        string $name,
136        array $params = [],
137        array $query = [],
138        ?string $host = null,
139    ): string {
140        $route = $this->names[$name] ?? null;
141
142        if (!$route) {
 
146        $url = $this->applyGlobalPrefix($route->url($params));
147        $queryString = $this->queryString($query);
148
149        if ($queryString !== '') {
 
153        return $this->prependHost($url, $host);
154    }
{closure:/workspace/celemas/router/src/Router.php:241-241}
241                    fn(mixed $item): bool|int|float|string => $this->queryValue($item, $name),