├── composer.json ├── license.md ├── readme.md └── src └── Routing ├── Route.php ├── RouteList.php ├── Router.php └── SimpleRouter.php /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nette/routing", 3 | "description": "Nette Routing: two-ways URL conversion", 4 | "keywords": ["nette"], 5 | "homepage": "https://nette.org", 6 | "license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"], 7 | "authors": [ 8 | { 9 | "name": "David Grudl", 10 | "homepage": "https://davidgrudl.com" 11 | }, 12 | { 13 | "name": "Nette Community", 14 | "homepage": "https://nette.org/contributors" 15 | } 16 | ], 17 | "require": { 18 | "php": "8.1 - 8.4", 19 | "nette/http": "^3.2 || ~4.0.0", 20 | "nette/utils": "^4.0" 21 | }, 22 | "require-dev": { 23 | "nette/tester": "^2.5", 24 | "tracy/tracy": "^2.9", 25 | "phpstan/phpstan-nette": "^2.0@stable" 26 | }, 27 | "autoload": { 28 | "classmap": ["src/"], 29 | "psr-4": { 30 | "Nette\\": "src" 31 | } 32 | }, 33 | "minimum-stability": "dev", 34 | "scripts": { 35 | "phpstan": "phpstan analyse", 36 | "tester": "tester tests -s" 37 | }, 38 | "extra": { 39 | "branch-alias": { 40 | "dev-master": "4.0-dev" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Licenses 2 | ======== 3 | 4 | Good news! You may use Nette Framework under the terms of either 5 | the New BSD License or the GNU General Public License (GPL) version 2 or 3. 6 | 7 | The BSD License is recommended for most projects. It is easy to understand and it 8 | places almost no restrictions on what you can do with the framework. If the GPL 9 | fits better to your project, you can use the framework under this license. 10 | 11 | You don't have to notify anyone which license you are using. You can freely 12 | use Nette Framework in commercial projects as long as the copyright header 13 | remains intact. 14 | 15 | Please be advised that the name "Nette Framework" is a protected trademark and its 16 | usage has some limitations. So please do not use word "Nette" in the name of your 17 | project or top-level domain, and choose a name that stands on its own merits. 18 | If your stuff is good, it will not take long to establish a reputation for yourselves. 19 | 20 | 21 | New BSD License 22 | --------------- 23 | 24 | Copyright (c) 2004, 2014 David Grudl (https://davidgrudl.com) 25 | All rights reserved. 26 | 27 | Redistribution and use in source and binary forms, with or without modification, 28 | are permitted provided that the following conditions are met: 29 | 30 | * Redistributions of source code must retain the above copyright notice, 31 | this list of conditions and the following disclaimer. 32 | 33 | * Redistributions in binary form must reproduce the above copyright notice, 34 | this list of conditions and the following disclaimer in the documentation 35 | and/or other materials provided with the distribution. 36 | 37 | * Neither the name of "Nette Framework" nor the names of its contributors 38 | may be used to endorse or promote products derived from this software 39 | without specific prior written permission. 40 | 41 | This software is provided by the copyright holders and contributors "as is" and 42 | any express or implied warranties, including, but not limited to, the implied 43 | warranties of merchantability and fitness for a particular purpose are 44 | disclaimed. In no event shall the copyright owner or contributors be liable for 45 | any direct, indirect, incidental, special, exemplary, or consequential damages 46 | (including, but not limited to, procurement of substitute goods or services; 47 | loss of use, data, or profits; or business interruption) however caused and on 48 | any theory of liability, whether in contract, strict liability, or tort 49 | (including negligence or otherwise) arising in any way out of the use of this 50 | software, even if advised of the possibility of such damage. 51 | 52 | 53 | GNU General Public License 54 | -------------------------- 55 | 56 | GPL licenses are very very long, so instead of including them here we offer 57 | you URLs with full text: 58 | 59 | - [GPL version 2](http://www.gnu.org/licenses/gpl-2.0.html) 60 | - [GPL version 3](http://www.gnu.org/licenses/gpl-3.0.html) 61 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Nette Routing: two-ways URL conversion 2 | 3 | [![Downloads this Month](https://img.shields.io/packagist/dm/nette/routing.svg)](https://packagist.org/packages/nette/routing) 4 | [![Tests](https://github.com/nette/routing/workflows/Tests/badge.svg?branch=master)](https://github.com/nette/routing/actions) 5 | [![Coverage Status](https://coveralls.io/repos/github/nette/routing/badge.svg?branch=master)](https://coveralls.io/github/nette/routing?branch=master) 6 | 7 | Introduction 8 | ============ 9 | 10 | The router is responsible for everything about URLs so that you no longer have to think about them. We will show: 11 | 12 | - how to set up the router so that the URLs look like you want 13 | - a few notes about SEO redirection 14 | - and we'll show you how to write your own router 15 | 16 | It requires PHP version 8.1 and supports PHP up to 8.4. 17 | 18 | Documentation can be found on the [website](https://doc.nette.org/routing). 19 | 20 | 21 | [Support Me](https://github.com/sponsors/dg) 22 | -------------------------------------------- 23 | 24 | Do you like Nette Routing? Are you looking forward to the new features? 25 | 26 | [![Buy me a coffee](https://files.nette.org/icons/donation-3.svg)](https://github.com/sponsors/dg) 27 | 28 | Thank you! 29 | 30 | 31 | Basics 32 | ====== 33 | 34 | More human URLs (or cool or pretty URLs) are more usable, more memorable, and contribute positively to SEO. Nette Framework keeps this in mind and fully meets developers' desires. 35 | 36 | Let's start a little technically. A router is an object that implements the [Nette\Routing\Router](https://api.nette.org/routing/Nette/Routing/Router.html) interface, which can decompose a URL into an array of parameters (method `match`) and, conversely, build a URL from an array of parameters (method `constructUrl`). Therefore, it is also said that the router is bidirectional. 37 | Nette brings a very elegant way to define how the URLs of your application look like. 38 | 39 | You can use it in the same way in completely different cases, for the REST API, for applications where controllers are not used at all, etc. 40 | 41 | Thus, routing is a separate and sophisticated layer of the application, thanks to which the look of URL addresses can be easily designed or changed when the entire application is ready, because it can be done without modification of the code or templates. Which gives developers huge freedom. 42 | 43 | 44 | Route Collection 45 | ================ 46 | 47 | The most pleasant way to define the URL addresses in the application is via the class [Nette\Routing\RouteList](https://api.nette.org/routing/Nette/Routing/RouteList.html). The big advantage is that the whole router 48 | is defined in one place and is not so scattered in the form of annotations in all controllers. 49 | 50 | The definition consists of a list of so-called routes, ie masks of URL addresses and their associated controllers and actions using a simple API. We do not have to name the routes. 51 | 52 | ```php 53 | $router = new Nette\Routing\RouteList; 54 | $router->addRoute('rss.xml', [ 55 | 'controller' => 'RssFeedController', 56 | ]); 57 | $router->addRoute('article/', [ 58 | 'controller' => 'ArticleController', 59 | ]); 60 | ... 61 | ``` 62 | 63 | Order of routes is important because they are tried sequentially from the first one to the last one. Basic rule is to **declare routes from the most specific to the most general**. 64 | 65 | Now we have to let the router to work: 66 | 67 | ```php 68 | $params = $router->match($httpRequest); 69 | if ($params === null) { 70 | // no matching route found, we will send a 404 error 71 | exit; 72 | } 73 | 74 | // we process the received parameters 75 | $controller = $params['controller']; 76 | ... 77 | ``` 78 | 79 | And vice versa, we will use the router to create the link: 80 | 81 | ```php 82 | $params = ['controller' => 'ArticleController', 'id' => 123]; 83 | $url = $router->constructUrl($params, $httpRequest->getUrl()); 84 | ``` 85 | 86 | 87 | Mask and Parameters 88 | ------------------- 89 | 90 | The mask describes the relative path based on the site root. The simplest mask is a static URL: 91 | 92 | ```php 93 | $router->addRoute('products', ...); 94 | ``` 95 | 96 | Often masks contain so-called **parameters**. They are enclosed in angle brackets (e.g. ``). 97 | 98 | ```php 99 | $router->addRoute('chronicle/', ...); 100 | ``` 101 | 102 | We can specify a default value for the parameters directly in the mask and thus it becomes optional: 103 | 104 | ```php 105 | $router->addRoute('chronicle/', ...); 106 | ``` 107 | 108 | The route will now accept the URL `https://any-domain.com/chronicle/`, which will again display with parameter `year: 2020`. 109 | 110 | The mask can describe not only the relative path based on the site root, but also the absolute path when it begins with a slash, or even the entire absolute URL when it begins with two slashes: 111 | 112 | ```php 113 | // relative path to application document root 114 | $router->addRoute('/', ...); 115 | 116 | // absolute path, relative to server hostname 117 | $router->addRoute('//', ...); 118 | 119 | // absolute URL including hostname (but scheme-relative) 120 | $router->addRoute('//.example.com//', ...); 121 | 122 | // absolute URL including schema 123 | $router->addRoute('https://.example.com//', ...); 124 | ``` 125 | 126 | 127 | Validation Expressions 128 | ---------------------- 129 | 130 | A validation condition can be specified for each parameter using [regular expression ](https://www.php.net/manual/en/reference.pcre.pattern.syntax.php). For example, let's set `id` to be only numerical, using `\d+` regexp: 131 | 132 | ```php 133 | $router->addRoute('/[/]', ...); 134 | ``` 135 | 136 | The default regular expression for all parameters is `[^/]+`, ie everything except the slash. If a parameter is supposed to match a slash as well, we set the regular expression to `.+`. 137 | 138 | ```php 139 | // accepts https://example.com/a/b/c, path is 'a/b/c' 140 | $router->addRoute('', ...); 141 | ``` 142 | 143 | 144 | Optional Sequences 145 | ------------------ 146 | 147 | Square brackets denote optional parts of mask. Any part of mask may be set as optional, including those containing parameters: 148 | 149 | ```php 150 | $router->addRoute('[/]', ...); 151 | 152 | // Accepted URLs: Parameters: 153 | // /en/download lang => en, name => download 154 | // /download lang => null, name => download 155 | ``` 156 | 157 | Of course, when a parameter is part of an optional sequence, it also becomes optional. If it does not have a default value, it will be null. 158 | 159 | Optional sections can also be in the domain: 160 | 161 | ```php 162 | $router->addRoute('//[.]example.com//', ...); 163 | ``` 164 | 165 | Sequences may be freely nested and combined: 166 | 167 | ```php 168 | $router->addRoute( 169 | '[[-]/][/page-]', 170 | ... 171 | ); 172 | 173 | // Accepted URLs: 174 | // /cs/hello 175 | // /en-us/hello 176 | // /hello 177 | // /hello/page-12 178 | ``` 179 | 180 | URL generator tries to keep the URL as short as possible, so what can be omitted is omitted. Therefore, for example, a route `index[.html]` generates a path `/index`. You can reverse this behavior by writing an exclamation mark after the left square bracket: 181 | 182 | ```php 183 | // accepts both /hello and /hello.html, generates /hello 184 | $router->addRoute('[.html]', ...); 185 | 186 | // accepts both /hello and /hello.html, generates /hello.html 187 | $router->addRoute('[!.html]', ...); 188 | ``` 189 | 190 | Optional parameters (ie. parameters having default value) without square brackets do behave as if wrapped like this: 191 | 192 | ```php 193 | $router->addRoute('//', ...); 194 | 195 | // equals to: 196 | $router->addRoute('[/[/[]]]', ...); 197 | ``` 198 | 199 | To change how the rightmost slash is generated, i.e. instead of `/homepage/` get a `/homepage`, adjust the route this way: 200 | 201 | ```php 202 | $router->addRoute('[[/[/]]]', ...); 203 | ``` 204 | 205 | 206 | Wildcards 207 | --------- 208 | 209 | In the absolute path mask, we can use the following wildcards to avoid, for example, the need to write a domain to the mask, which may differ in the development and production environment: 210 | 211 | - `%tld%` = top level domain, e.g. `com` or `org` 212 | - `%sld%` = second level domain, e.g. `example` 213 | - `%domain%` = domain without subdomains, e.g. `example.com` 214 | - `%host%` = whole host, e.g. `www.example.com` 215 | - `%basePath%` = path to the root directory 216 | 217 | ```php 218 | $router->addRoute('//www.%domain%/%basePath%//', ...); 219 | $router->addRoute('//www.%sld%.%tld%/%basePath%//addRoute('/[/]', [ 230 | 'controller' => 'Homepage', 231 | 'action' => 'default', 232 | ]); 233 | ``` 234 | 235 | Or we can use this form, notice the rewriting of the validation regular expression: 236 | 237 | ```php 238 | use Nette\Routing\Route; 239 | 240 | $router->addRoute('/[/]', [ 241 | 'controller' => [ 242 | Route::Value => 'Homepage', 243 | ], 244 | 'action' => [ 245 | Route::Value => 'default', 246 | ], 247 | 'id' => [ 248 | Route::Pattern => '\d+', 249 | ], 250 | ]); 251 | ``` 252 | 253 | These more talkative formats are useful for adding other metadata. 254 | 255 | 256 | Filters and Translations 257 | ------------------------ 258 | 259 | It's a good practice to write source code in English, but what if you need your website to have translated URL to different language? Simple routes such as: 260 | 261 | ```php 262 | $router->addRoute('/', [...]); 263 | ``` 264 | 265 | will generate English URLs, such as `/product/123` or `/cart`. If we want to have controllers and actions in the URL translated to Deutsch (e.g. `/produkt/123` or `/einkaufswagen`), we can use a translation dictionary. To add it, we already need a "more talkative" variant of the second parameter: 266 | 267 | ```php 268 | use Nette\Routing\Route; 269 | 270 | $router->addRoute('/', [ 271 | 'controller' => [ 272 | Route::Value => 'Homepage', 273 | Route::FilterTable => [ 274 | // string in URL => controller 275 | 'produkt' => 'Product', 276 | 'einkaufswagen' => 'Cart', 277 | 'katalog' => 'Catalog', 278 | ], 279 | ], 280 | 'action' => [ 281 | Route::Value => 'default', 282 | Route::FilterTable => [ 283 | 'liste' => 'list', 284 | ], 285 | ], 286 | ]); 287 | ``` 288 | 289 | Multiple dictionary keys can by used for the same controller. They will create various aliases for it. The last key is considered to be the canonical variant (i.e. the one that will be in the generated URL). 290 | 291 | The translation table can be applied to any parameter in this way. However, if the translation does not exist, the original value is taken. We can change this behavior by adding `Router::FILTER_STRICT => true` and the route will then reject the URL if the value is not in the dictionary. 292 | 293 | In addition to the translation dictionary in the form of an array, it is possible to set own translation functions: 294 | 295 | ```php 296 | use Nette\Routing\Route; 297 | 298 | $router->addRoute('//', [ 299 | 'controller' => [ 300 | Route::Value => 'Homepage', 301 | Route::FilterIn => function (string $s): string { ... }, 302 | Route::FilterOut => function (string $s): string { ... }, 303 | ], 304 | 'action' => 'default', 305 | 'id' => null, 306 | ]); 307 | ``` 308 | 309 | The function `Route::FILTER_IN` converts between the parameter in the URL and the string, which is then passed to the controller, the function `FILTER_OUT` ensures the conversion in the opposite direction. 310 | 311 | 312 | Global Filters 313 | -------------- 314 | 315 | Besides filters for specific parameters, you can also define global filters that receive an associative array of all parameters that they can modify in any way and then return. Global filters are defined under `null` key. 316 | 317 | ```php 318 | use Nette\Routing\Route; 319 | 320 | $router->addRoute('/', [ 321 | 'controller' => 'Homepage', 322 | 'action' => 'default', 323 | null => [ 324 | Route::FilterIn => function (array $params): array { ... }, 325 | Route::FilterOut => function (array $params): array { ... }, 326 | ], 327 | ]); 328 | ``` 329 | 330 | Global filters give you the ability to adjust the behavior of the route in absolutely any way. We can use them, for example, to modify parameters based on other parameters. For example, translation `` and `` based on the current value of parameter ``. 331 | 332 | If a parameter has a custom filter defined and a global filter exists at the same time, custom `FILTER_IN` is executed before the global and vice versa global `FILTER_OUT` is executed before the custom. Thus, inside the global filter are the values of the parameters `controller` resp. `action` written in PascalCase resp. camelCase style. 333 | 334 | 335 | OneWay flag 336 | ------------ 337 | 338 | One-way routes are used to preserve the functionality of old URLs that the application no longer generates but still accepts. We flag them with `oneWay`: 339 | 340 | ```php 341 | // old URL /product-info?id=123 342 | $router->addRoute('product-info', [...], oneWay: true); 343 | // new URL /product/123 344 | $router->addRoute('product/', [...]); 345 | ``` 346 | 347 | When accessing the old URL, the controller automatically redirects to the new URL so that search engines do not index these pages twice (see [#SEO and canonization]). 348 | 349 | 350 | Subdomains 351 | ---------- 352 | 353 | Route collections can be grouped by subdomains: 354 | 355 | ```php 356 | $router = new RouteList; 357 | $router->withDomain('example.com') 358 | ->addRoute('rss', [...]) 359 | ->addRoute('/'); 360 | ``` 361 | 362 | You can also use [#wildcards] in your domain name: 363 | 364 | ```php 365 | $router = new RouteList; 366 | $router->withDomain('example.%tld%') 367 | ... 368 | ``` 369 | 370 | 371 | Path Prefix 372 | ----------- 373 | 374 | Route collections can be grouped by path in URL: 375 | 376 | ```php 377 | $router = new RouteList; 378 | $router->withPath('/eshop') 379 | ->addRoute('rss', [...]) // matches URL /eshop/rss 380 | ->addRoute('/'); // matches URL /eshop// 381 | ``` 382 | 383 | 384 | Combinations 385 | ------------ 386 | 387 | The above usage can be combined: 388 | 389 | ```php 390 | $router = (new RouteList) 391 | ->withDomain('admin.example.com') 392 | ->addRoute(...) 393 | ->addRoute(...) 394 | ->end() 395 | ->withDomain('example.com') 396 | ->withPath('export') 397 | ->addRoute(...) 398 | ... 399 | ``` 400 | 401 | 402 | Query Parameters 403 | ---------------- 404 | 405 | Masks can also contain query parameters (parameters after the question mark in the URL). They cannot define a validation expression, but they can change the name under which they are passed to the controller: 406 | 407 | ```php 408 | // use query parameter 'cat' as a 'categoryId' in application 409 | $router->addRoute('product ? id= & cat=', ...); 410 | ``` 411 | 412 | 413 | Foo Parameters 414 | -------------- 415 | 416 | We're going deeper now. Foo parameters are basically unnamed parameters which allow to match a regular expression. The following route matches `/index`, `/index.html`, `/index.htm` and `/index.php`: 417 | 418 | ```php 419 | $router->addRoute('index', ...); 420 | ``` 421 | 422 | It's also possible to explicitly define a string which will be used for URL generation. The string must be placed directly after the question mark. The following route is similar to the previous one, but generates `/index.html` instead of `/index` because the string `.html` is set as a "generated value". 423 | 424 | ```php 425 | $router->addRoute('index', ...); 426 | ``` 427 | 428 | 429 | SimpleRouter 430 | ============ 431 | 432 | A much simpler router than the Route Collection is [SimpleRouter](https://api.nette.org/routing/Nette/Routing/SimpleRouter.html). It can be used when there's no need for a specific URL format, when `mod_rewrite` (or alternatives) is not available or when we simply do not want to bother with user-friendly URLs yet. 433 | 434 | Generates addresses in roughly this form: 435 | 436 | ``` 437 | http://example.com/?controller=Product&action=detail&id=123 438 | ``` 439 | 440 | The parameter of the `SimpleRouter` constructor is a default controller & action, ie. action to be executed if we open e.g. `http://example.com/` without additional parameters. 441 | 442 | ```php 443 | $router = new Nette\Application\Routers\SimpleRouter(); 444 | ``` 445 | 446 | 447 | SEO and Canonization 448 | ==================== 449 | 450 | The framework increases SEO (search engine optimization) by preventing duplication of content at different URLs. If multiple addresses link to a same destination, eg `/index` and `/index.html`, the framework determines the first one as primary (canonical) and redirects the others to it using HTTP code 301. Thanks to this, search engines will not index pages twice and do not break their page rank. . 451 | 452 | This process is called canonization. The canonical URL is the one generated by the router, i.e. by the first matching route in the [collection ](#route-collection) without the OneWay flag. Therefore, in the collection, we list **primary routes first**. 453 | 454 | Canonization is performed by the controller, more in the chapter [canonization ](controllers#Canonization). 455 | 456 | 457 | HTTPS 458 | ===== 459 | 460 | In order to use the HTTPS protocol, it is necessary to activate it on hosting and to configure the server. 461 | 462 | Redirection of the entire site to HTTPS must be performed at the server level, for example using the .htaccess file in the root directory of our application, with HTTP code 301. The settings may differ depending on the hosting and looks something like this: 463 | 464 | ```php 465 | 466 | RewriteEngine On 467 | ... 468 | RewriteCond %{HTTPS} off 469 | RewriteRule .* https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] 470 | ... 471 | 472 | ``` 473 | 474 | The router generates a URL with the same protocol as the page was loaded, so there is no need to set anything else. 475 | 476 | However, if we exceptionally need different routes to run under different protocols, we will put it in the route mask: 477 | 478 | ```php 479 | // Will generate an HTTP address 480 | $router->addRoute('http://%host%//', ...); 481 | 482 | // Will generate an HTTPS address 483 | $router->addRoute('https://%host%//', ...); 484 | ``` 485 | 486 | 487 | Routing Debugger 488 | ================ 489 | 490 | We will not hide from you that routing may seem a bit magical at first, and before you get into it, Routing Debugger will be a good helper. This is a panel displayed in the [Tracy Bar ](tracy:), which provides a clear list of routes as well as parameters that the router obtained from the URL. 491 | 492 | The green bar with symbol ✓ represents the route that matched the current URL, the blue bars with symbols ≈ indicate the routes that would also match the URL if green did not overtake them. We see the current controller & action further. 493 | 494 | 495 | Custom Router 496 | ============= 497 | 498 | The following lines are intended for very advanced users. You can create your own router and naturally add it into your route collection. The router is an implementation of the [Router](https://api.nette.org/routing/Nette/Routing/Router.html) interface with two methods: 499 | 500 | ```php 501 | use Nette\Http\IRequest as HttpRequest; 502 | use Nette\Http\UrlScript; 503 | 504 | class MyRouter implements Nette\Routing\Router 505 | { 506 | public function match(HttpRequest $httpRequest): ?array 507 | { 508 | // ... 509 | } 510 | 511 | public function constructUrl(array $params, UrlScript $refUrl): ?string 512 | { 513 | // ... 514 | } 515 | } 516 | ``` 517 | 518 | Method `match` processes the current request in the parameter [$httpRequest ](http-request-response#HTTP request) (which offers more than just URL) into the an array containing the name of the controller and its parameters. If it cannot process the request, it returns null. 519 | 520 | Method `constructUrl`, on the other hand, generates an absolute URL from the array of parameters. It can use the information from parameter `$refUrl`, which is the current URL. 521 | 522 | To add custom router to the route collection, use `add()`: 523 | 524 | ```php 525 | $router = new Nette\Application\Routers\RouteList; 526 | $router->add(new MyRouter); 527 | $router->addRoute(...); 528 | ... 529 | ``` 530 | -------------------------------------------------------------------------------- /src/Routing/Route.php: -------------------------------------------------------------------------------- 1 | [ // default style for path parameters 70 | self::Pattern => '[^/]+', 71 | self::FilterOut => [self::class, 'param2path'], 72 | ], 73 | ]; 74 | 75 | private string $mask; 76 | private array $sequence; 77 | 78 | /** regular expression pattern */ 79 | private string $re; 80 | 81 | /** @var string[] parameter aliases in regular expression */ 82 | private array $aliases = []; 83 | 84 | /** @var array of [value & fixity, filterIn, filterOut] */ 85 | private array $metadata = []; 86 | private array $xlat = []; 87 | 88 | /** Host, Path, Relative */ 89 | private int $type; 90 | 91 | /** http | https */ 92 | private string $scheme = ''; 93 | 94 | 95 | /** 96 | * @param string $mask e.g. '//' 97 | */ 98 | public function __construct(string $mask, array $metadata = []) 99 | { 100 | $this->mask = $mask; 101 | $this->metadata = $this->normalizeMetadata($metadata); 102 | $this->parseMask($this->detectMaskType()); 103 | } 104 | 105 | 106 | /** 107 | * Returns mask. 108 | */ 109 | public function getMask(): string 110 | { 111 | return $this->mask; 112 | } 113 | 114 | 115 | /** @internal */ 116 | protected function getMetadata(): array 117 | { 118 | return $this->metadata; 119 | } 120 | 121 | 122 | /** 123 | * Returns default values. 124 | */ 125 | public function getDefaults(): array 126 | { 127 | $defaults = []; 128 | foreach ($this->metadata as $name => $meta) { 129 | if (isset($meta[self::Fixity])) { 130 | $defaults[$name] = $meta[self::Value]; 131 | } 132 | } 133 | 134 | return $defaults; 135 | } 136 | 137 | 138 | /** @internal */ 139 | public function getConstantParameters(): array 140 | { 141 | $res = []; 142 | foreach ($this->metadata as $name => $meta) { 143 | if (isset($meta[self::Fixity]) && $meta[self::Fixity] === self::Constant) { 144 | $res[$name] = $meta[self::Value]; 145 | } 146 | } 147 | 148 | return $res; 149 | } 150 | 151 | 152 | /** 153 | * Maps HTTP request to an array. 154 | */ 155 | public function match(Nette\Http\IRequest $httpRequest): ?array 156 | { 157 | // combine with precedence: mask (params in URL-path), fixity, query, (post,) defaults 158 | 159 | // 1) URL MASK 160 | $url = $httpRequest->getUrl(); 161 | $re = $this->re; 162 | 163 | if ($this->type === self::Host) { 164 | $host = $url->getHost(); 165 | $path = '//' . $host . $url->getPath(); 166 | $parts = ip2long($host) 167 | ? [$host] 168 | : array_reverse(explode('.', $host)); 169 | $re = strtr($re, [ 170 | '/%basePath%/' => preg_quote($url->getBasePath(), '#'), 171 | '%tld%' => preg_quote($parts[0], '#'), 172 | '%domain%' => preg_quote(isset($parts[1]) ? "$parts[1].$parts[0]" : $parts[0], '#'), 173 | '%sld%' => preg_quote($parts[1] ?? '', '#'), 174 | '%host%' => preg_quote($host, '#'), 175 | ]); 176 | 177 | } elseif ($this->type === self::Relative) { 178 | $basePath = $url->getBasePath(); 179 | if (strncmp($url->getPath(), $basePath, strlen($basePath)) !== 0) { 180 | return null; 181 | } 182 | 183 | $path = substr($url->getPath(), strlen($basePath)); 184 | 185 | } else { 186 | $path = $url->getPath(); 187 | } 188 | 189 | $path = rawurldecode($path); 190 | if ($path !== '' && $path[-1] !== '/') { 191 | $path .= '/'; 192 | } 193 | 194 | if (!$matches = Strings::match($path, $re)) { 195 | return null; // stop, not matched 196 | } 197 | 198 | // assigns matched values to parameters 199 | $params = []; 200 | foreach ($matches as $k => $v) { 201 | if (is_string($k) && $v !== '') { 202 | $params[$this->aliases[$k]] = $v; 203 | } 204 | } 205 | 206 | // 2) CONSTANT FIXITY 207 | foreach ($this->metadata as $name => $meta) { 208 | if (!isset($params[$name]) && isset($meta[self::Fixity]) && $meta[self::Fixity] !== self::InQuery) { 209 | $params[$name] = null; // cannot be overwriten in 3) and detected by isset() in 4) 210 | } 211 | } 212 | 213 | // 3) QUERY 214 | $params += self::renameKeys($httpRequest->getQuery(), array_flip($this->xlat)); 215 | 216 | // 4) APPLY FILTERS & FIXITY 217 | foreach ($this->metadata as $name => $meta) { 218 | if (isset($params[$name])) { 219 | if (!is_scalar($params[$name])) { 220 | // do nothing 221 | } elseif (isset($meta[self::FilterTable][$params[$name]])) { // applies filterTable only to scalar parameters 222 | $params[$name] = $meta[self::FilterTable][$params[$name]]; 223 | 224 | } elseif (isset($meta[self::FilterTable]) && !empty($meta[self::FilterStrict])) { 225 | return null; // rejected by filterTable 226 | 227 | } elseif (isset($meta[self::FilterIn])) { // applies filterIn only to scalar parameters 228 | $params[$name] = $meta[self::FilterIn]((string) $params[$name]); 229 | if ($params[$name] === null && !isset($meta[self::Fixity])) { 230 | return null; // rejected by filter 231 | } 232 | } 233 | } elseif (isset($meta[self::Fixity])) { 234 | $params[$name] = $meta[self::Value]; 235 | } 236 | } 237 | 238 | if (isset($this->metadata[null][self::FilterIn])) { 239 | $params = $this->metadata[null][self::FilterIn]($params); 240 | if ($params === null) { 241 | return null; 242 | } 243 | } 244 | 245 | return $params; 246 | } 247 | 248 | 249 | /** 250 | * Constructs absolute URL from array. 251 | */ 252 | public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?string 253 | { 254 | if (!$this->preprocessParams($params)) { 255 | return null; 256 | } 257 | 258 | $url = $this->compileUrl($params); 259 | if ($url === null) { 260 | return null; 261 | } 262 | 263 | // absolutize 264 | if ($this->type === self::Relative) { 265 | $url = (($tmp = $refUrl->getAuthority()) ? "//$tmp" : '') . $refUrl->getBasePath() . $url; 266 | 267 | } elseif ($this->type === self::Path) { 268 | $url = (($tmp = $refUrl->getAuthority()) ? "//$tmp" : '') . $url; 269 | 270 | } else { 271 | $host = $refUrl->getHost(); 272 | $parts = ip2long($host) 273 | ? [$host] 274 | : array_reverse(explode('.', $host)); 275 | $port = $refUrl->getDefaultPort() === ($tmp = $refUrl->getPort()) ? '' : ':' . $tmp; 276 | $url = strtr($url, [ 277 | '/%basePath%/' => $refUrl->getBasePath(), 278 | '%tld%' => $parts[0] . $port, 279 | '%domain%' => (isset($parts[1]) ? "$parts[1].$parts[0]" : $parts[0]) . $port, 280 | '%sld%' => $parts[1] ?? '', 281 | '%host%' => $host . $port, 282 | ]); 283 | } 284 | 285 | $url = ($this->scheme ?: $refUrl->getScheme()) . ':' . $url; 286 | 287 | // build query string 288 | $params = self::renameKeys($params, $this->xlat); 289 | $sep = ini_get('arg_separator.input'); 290 | $query = http_build_query($params, '', $sep ? $sep[0] : '&'); 291 | if ($query !== '') { 292 | $url .= '?' . $query; 293 | } 294 | 295 | return $url; 296 | } 297 | 298 | 299 | private function preprocessParams(array &$params): bool 300 | { 301 | $filter = $this->metadata[null][self::FilterOut] ?? null; 302 | if ($filter) { 303 | $params = $filter($params); 304 | if ($params === null) { 305 | return false; // rejected by global filter 306 | } 307 | } 308 | 309 | foreach ($this->metadata as $name => $meta) { 310 | $fixity = $meta[self::Fixity] ?? null; 311 | 312 | if (!isset($params[$name])) { 313 | if ($fixity === self::Constant) { 314 | if ($meta[self::Value] === null) { 315 | continue; 316 | } 317 | 318 | return false; // wrong parameter value 319 | } 320 | 321 | continue; // retains null values 322 | } 323 | 324 | if (is_scalar($params[$name])) { 325 | $params[$name] = $params[$name] === false 326 | ? '0' 327 | : (string) $params[$name]; 328 | } 329 | 330 | if ($fixity !== null) { 331 | if ($params[$name] == $meta[self::Value]) { // default value may be object, intentionally == 332 | // remove default values; null values are retain 333 | unset($params[$name]); 334 | continue; 335 | 336 | } elseif ($fixity === self::Constant) { 337 | return false; // wrong parameter value 338 | } 339 | } 340 | 341 | if (is_scalar($params[$name]) && isset($meta[self::FilterTableOut][$params[$name]])) { 342 | $params[$name] = $meta[self::FilterTableOut][$params[$name]]; 343 | 344 | } elseif (isset($meta[self::FilterTableOut]) && !empty($meta[self::FilterStrict])) { 345 | return false; 346 | 347 | } elseif (isset($meta[self::FilterOut])) { 348 | $params[$name] = $meta[self::FilterOut]($params[$name]); 349 | } 350 | 351 | if ( 352 | isset($meta[self::Pattern]) 353 | && !preg_match("#(?:{$meta[self::Pattern]})$#DA", rawurldecode((string) $params[$name])) 354 | ) { 355 | return false; // pattern not match 356 | } 357 | } 358 | 359 | return true; 360 | } 361 | 362 | 363 | private function compileUrl(array &$params): ?string 364 | { 365 | $brackets = []; 366 | $required = null; // null for auto-optional 367 | $path = ''; 368 | $i = count($this->sequence) - 1; 369 | 370 | do { 371 | $path = $this->sequence[$i] . $path; 372 | if ($i === 0) { 373 | return $path; 374 | } 375 | 376 | $i--; 377 | 378 | $name = $this->sequence[$i--]; // parameter name 379 | 380 | if ($name === ']') { // opening optional part 381 | $brackets[] = $path; 382 | 383 | } elseif ($name[0] === '[') { // closing optional part 384 | $tmp = array_pop($brackets); 385 | if ($required < count($brackets) + 1) { // is this level optional? 386 | if ($name !== '[!') { // and not "required"-optional 387 | $path = $tmp; 388 | } 389 | } else { 390 | $required = count($brackets); 391 | } 392 | } elseif ($name[0] === '?') { // "foo" parameter 393 | continue; 394 | 395 | } elseif (isset($params[$name]) && $params[$name] !== '') { 396 | $required = count($brackets); // make this level required 397 | $path = $params[$name] . $path; 398 | unset($params[$name]); 399 | 400 | } elseif (isset($this->metadata[$name][self::Fixity])) { // has default value? 401 | $path = $required === null && !$brackets // auto-optional 402 | ? '' 403 | : $this->metadata[$name][self::Default] . $path; 404 | 405 | } else { 406 | return null; // missing parameter '$name' 407 | } 408 | } while (true); 409 | } 410 | 411 | 412 | private function detectMaskType(): string 413 | { 414 | // '//host/path' vs. '/abs. path' vs. 'relative path' 415 | if (preg_match('#(?:(https?):)?(//.*)#A', $this->mask, $m)) { 416 | $this->type = self::Host; 417 | [, $this->scheme, $path] = $m; 418 | return $path; 419 | 420 | } elseif (str_starts_with($this->mask, '/')) { 421 | $this->type = self::Path; 422 | 423 | } else { 424 | $this->type = self::Relative; 425 | } 426 | 427 | return $this->mask; 428 | } 429 | 430 | 431 | private function normalizeMetadata(array $metadata): array 432 | { 433 | foreach ($metadata as $name => $meta) { 434 | if (!is_array($meta)) { 435 | $metadata[$name] = $meta = [self::Value => $meta]; 436 | } 437 | 438 | if (array_key_exists(self::Value, $meta)) { 439 | if (is_scalar($meta[self::Value])) { 440 | $metadata[$name][self::Value] = $meta[self::Value] === false 441 | ? '0' 442 | : (string) $meta[self::Value]; 443 | } 444 | 445 | $metadata[$name]['fixity'] = self::Constant; 446 | } 447 | } 448 | 449 | return $metadata; 450 | } 451 | 452 | 453 | private function parseMask(string $path): void 454 | { 455 | // or [ or ] or ?... 456 | $parts = Strings::split($path, '/<([^<>= ]+)(=[^<> ]*)? *([^<>]*)>|(\[!?|\]|\s*\?.*)/'); 457 | 458 | $i = count($parts) - 1; 459 | if ($i === 0) { 460 | $this->re = '#' . preg_quote($parts[0], '#') . '/?$#DA'; 461 | $this->sequence = [$parts[0]]; 462 | return; 463 | } 464 | 465 | if ($this->parseQuery($parts)) { 466 | $i -= 5; 467 | } 468 | 469 | $brackets = 0; // optional level 470 | $re = ''; 471 | $sequence = []; 472 | $autoOptional = true; 473 | 474 | do { 475 | $part = $parts[$i]; // part of path 476 | if (strpbrk($part, '<>') !== false) { 477 | throw new Nette\InvalidArgumentException("Unexpected '$part' in mask '$this->mask'."); 478 | } 479 | 480 | array_unshift($sequence, $part); 481 | $re = preg_quote($part, '#') . $re; 482 | if ($i === 0) { 483 | break; 484 | } 485 | 486 | $i--; 487 | 488 | $part = $parts[$i]; // [ or ] 489 | if ($part === '[' || $part === ']' || $part === '[!') { 490 | $brackets += $part[0] === '[' ? -1 : 1; 491 | if ($brackets < 0) { 492 | throw new Nette\InvalidArgumentException("Unexpected '$part' in mask '$this->mask'."); 493 | } 494 | 495 | array_unshift($sequence, $part); 496 | $re = ($part[0] === '[' ? '(?:' : ')?') . $re; 497 | $i -= 4; 498 | continue; 499 | } 500 | 501 | $pattern = trim($parts[$i--]); // validation condition (as regexp) 502 | $default = $parts[$i--]; // default value 503 | $name = $parts[$i--]; // parameter name 504 | array_unshift($sequence, $name); 505 | 506 | if ($name[0] === '?') { // "foo" parameter 507 | $name = substr($name, 1); 508 | $re = $pattern 509 | ? '(?:' . preg_quote($name, '#') . "|$pattern)$re" 510 | : preg_quote($name, '#') . $re; 511 | $sequence[1] = $name . $sequence[1]; 512 | continue; 513 | } 514 | 515 | // pattern, condition & metadata 516 | $meta = ($this->metadata[$name] ?? []) + ($this->defaultMeta[$name] ?? $this->defaultMeta['#']); 517 | 518 | if ($pattern === '' && isset($meta[self::Pattern])) { 519 | $pattern = $meta[self::Pattern]; 520 | } 521 | 522 | if ($default !== '') { 523 | $meta[self::Value] = substr($default, 1); 524 | $meta[self::Fixity] = self::InPath; 525 | } 526 | 527 | $meta[self::FilterTableOut] = empty($meta[self::FilterTable]) 528 | ? null 529 | : array_flip($meta[self::FilterTable]); 530 | if (array_key_exists(self::Value, $meta)) { 531 | if (isset($meta[self::FilterTableOut][$meta[self::Value]])) { 532 | $meta[self::Default] = $meta[self::FilterTableOut][$meta[self::Value]]; 533 | 534 | } elseif (isset($meta[self::Value], $meta[self::FilterOut])) { 535 | $meta[self::Default] = $meta[self::FilterOut]($meta[self::Value]); 536 | 537 | } else { 538 | $meta[self::Default] = $meta[self::Value]; 539 | } 540 | } 541 | 542 | $meta[self::Pattern] = $pattern; 543 | 544 | // include in expression 545 | $this->aliases['p' . $i] = $name; 546 | $re = '(?P(?U)' . $pattern . ')' . $re; 547 | if ($brackets) { // is in brackets? 548 | if (!isset($meta[self::Value])) { 549 | $meta[self::Value] = $meta[self::Default] = null; 550 | } 551 | 552 | $meta[self::Fixity] = self::InPath; 553 | 554 | } elseif (isset($meta[self::Fixity])) { 555 | if ($autoOptional) { 556 | $re = '(?:' . $re . ')?'; 557 | } 558 | 559 | $meta[self::Fixity] = self::InPath; 560 | 561 | } else { 562 | $autoOptional = false; 563 | } 564 | 565 | $this->metadata[$name] = $meta; 566 | } while (true); 567 | 568 | if ($brackets) { 569 | throw new Nette\InvalidArgumentException("Missing '[' in mask '$this->mask'."); 570 | } 571 | 572 | $this->re = '#' . $re . '/?$#DA'; 573 | $this->sequence = $sequence; 574 | } 575 | 576 | 577 | private function parseQuery(array $parts): bool 578 | { 579 | $query = $parts[count($parts) - 2] ?? ''; 580 | if (!str_starts_with(ltrim($query), '?')) { 581 | return false; 582 | } 583 | 584 | // name= 585 | $matches = Strings::matchAll($query, '/(?:([a-zA-Z0-9_.-]+)=)?<([^> ]+) *([^>]*)>/'); 586 | 587 | foreach ($matches as [, $param, $name, $pattern]) { // $pattern is not used 588 | $meta = ($this->metadata[$name] ?? []) + ($this->defaultMeta['?' . $name] ?? []); 589 | 590 | if (array_key_exists(self::Value, $meta)) { 591 | $meta[self::Fixity] = self::InQuery; 592 | } 593 | 594 | unset($meta[self::Pattern]); 595 | $meta[self::FilterTableOut] = empty($meta[self::FilterTable]) 596 | ? null 597 | : array_flip($meta[self::FilterTable]); 598 | 599 | $this->metadata[$name] = $meta; 600 | if ($param !== '') { 601 | $this->xlat[$name] = $param; 602 | } 603 | } 604 | 605 | return true; 606 | } 607 | 608 | 609 | /********************* Utilities ****************d*g**/ 610 | 611 | 612 | /** 613 | * Rename keys in array. 614 | */ 615 | private static function renameKeys(array $arr, array $xlat): array 616 | { 617 | if (!$xlat) { 618 | return $arr; 619 | } 620 | 621 | $res = []; 622 | $occupied = array_flip($xlat); 623 | foreach ($arr as $k => $v) { 624 | if (isset($xlat[$k])) { 625 | $res[$xlat[$k]] = $v; 626 | 627 | } elseif (!isset($occupied[$k])) { 628 | $res[$k] = $v; 629 | } 630 | } 631 | 632 | return $res; 633 | } 634 | 635 | 636 | /** 637 | * Url encode. 638 | */ 639 | public static function param2path(string $s): string 640 | { 641 | // segment + "/", see https://datatracker.ietf.org/doc/html/rfc3986#appendix-A 642 | return preg_replace_callback( 643 | '#[^\w.~!$&\'()*+,;=:@"/-]#', 644 | fn($m) => rawurlencode($m[0]), 645 | $s, 646 | ); 647 | } 648 | } 649 | -------------------------------------------------------------------------------- /src/Routing/RouteList.php: -------------------------------------------------------------------------------- 1 | */ 23 | private array $list = []; 24 | 25 | /** @var Router[][]|null */ 26 | private ?array $ranks = null; 27 | private ?string $cacheKey; 28 | private ?string $domain = null; 29 | private ?string $path = null; 30 | private ?\SplObjectStorage $refUrlCache; 31 | 32 | 33 | public function __construct() 34 | { 35 | } 36 | 37 | 38 | /** 39 | * Maps HTTP request to an array. 40 | */ 41 | final public function match(Nette\Http\IRequest $httpRequest): ?array 42 | { 43 | if ($httpRequest = $this->prepareRequest($httpRequest)) { 44 | foreach ($this->list as [$router]) { 45 | if ( 46 | ($params = $router->match($httpRequest)) !== null 47 | && ($params = $this->completeParameters($params)) !== null 48 | ) { 49 | return $params; 50 | } 51 | } 52 | } 53 | return null; 54 | } 55 | 56 | 57 | protected function prepareRequest(Nette\Http\IRequest $httpRequest): ?Nette\Http\IRequest 58 | { 59 | if ($this->domain) { 60 | $host = $httpRequest->getUrl()->getHost(); 61 | if ($host !== $this->expandDomain($host)) { 62 | return null; 63 | } 64 | } 65 | 66 | if ($this->path) { 67 | $url = $httpRequest->getUrl(); 68 | $relativePath = $url->getRelativePath(); 69 | if (strncmp($relativePath, $this->path, strlen($this->path)) === 0) { 70 | $url = $url->withPath($url->getPath(), $url->getBasePath() . $this->path); 71 | } elseif ($relativePath . '/' === $this->path) { 72 | $url = $url->withPath($url->getPath() . '/'); 73 | } else { 74 | return null; 75 | } 76 | 77 | $httpRequest = $httpRequest->withUrl($url); 78 | } 79 | 80 | return $httpRequest; 81 | } 82 | 83 | 84 | protected function completeParameters(array $params): ?array 85 | { 86 | return $params; 87 | } 88 | 89 | 90 | /** 91 | * Constructs absolute URL from array. 92 | */ 93 | public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?string 94 | { 95 | if ($this->domain) { 96 | if (!isset($this->refUrlCache[$refUrl])) { 97 | $this->refUrlCache[$refUrl] = $refUrl->withHost( 98 | $this->expandDomain($refUrl->getHost()), 99 | ); 100 | } 101 | 102 | $refUrl = $this->refUrlCache[$refUrl]; 103 | } 104 | 105 | if ($this->path) { 106 | if (!isset($this->refUrlCache[$refUrl])) { 107 | $this->refUrlCache[$refUrl] = $refUrl->withPath($refUrl->getBasePath() . $this->path); 108 | } 109 | 110 | $refUrl = $this->refUrlCache[$refUrl]; 111 | } 112 | 113 | if ($this->ranks === null) { 114 | $this->warmupCache(); 115 | } 116 | 117 | $key = $params[$this->cacheKey] ?? null; 118 | if (!is_scalar($key) || !isset($this->ranks[$key])) { 119 | $key = '*'; 120 | } 121 | 122 | foreach ($this->ranks[$key] as $router) { 123 | $url = $router->constructUrl($params, $refUrl); 124 | if ($url !== null) { 125 | return $url; 126 | } 127 | } 128 | 129 | return null; 130 | } 131 | 132 | 133 | public function warmupCache(): void 134 | { 135 | // find best key 136 | $candidates = []; 137 | $routers = []; 138 | foreach ($this->list as [$router, $oneWay]) { 139 | if ($oneWay) { 140 | continue; 141 | } elseif ($router instanceof self) { 142 | $router->warmupCache(); 143 | } 144 | 145 | $params = $router instanceof Route 146 | ? $router->getConstantParameters() 147 | : []; 148 | 149 | foreach (array_filter($params, 'is_scalar') as $name => $value) { 150 | $candidates[$name][$value] = true; 151 | } 152 | 153 | $routers[] = [$router, $params]; 154 | } 155 | 156 | $this->cacheKey = $count = null; 157 | foreach ($candidates as $name => $items) { 158 | if (count($items) > $count) { 159 | $count = count($items); 160 | $this->cacheKey = $name; 161 | } 162 | } 163 | 164 | // classify routers 165 | $ranks = ['*' => []]; 166 | 167 | foreach ($routers as [$router, $params]) { 168 | $value = $params[$this->cacheKey] ?? null; 169 | $values = $value === null 170 | ? array_keys($ranks) 171 | : [is_scalar($value) ? $value : '*']; 172 | 173 | foreach ($values as $value) { 174 | if (!isset($ranks[$value])) { 175 | $ranks[$value] = $ranks['*']; 176 | } 177 | 178 | $ranks[$value][] = $router; 179 | } 180 | } 181 | 182 | $this->ranks = $ranks; 183 | } 184 | 185 | 186 | /** 187 | * Adds a router. 188 | */ 189 | public function add(Router $router, bool $oneWay = false): static 190 | { 191 | $this->list[] = [$router, $oneWay]; 192 | $this->ranks = null; 193 | return $this; 194 | } 195 | 196 | 197 | /** 198 | * Prepends a router. 199 | */ 200 | public function prepend(Router $router, bool $oneWay = false): void 201 | { 202 | array_splice($this->list, 0, 0, [[$router, $oneWay]]); 203 | $this->ranks = null; 204 | } 205 | 206 | 207 | /** @internal */ 208 | protected function modify(int $index, ?Router $router): void 209 | { 210 | if (!isset($this->list[$index])) { 211 | throw new Nette\OutOfRangeException('Offset invalid or out of range'); 212 | } elseif ($router) { 213 | $this->list[$index] = [$router, 0]; 214 | } else { 215 | array_splice($this->list, $index, 1); 216 | } 217 | 218 | $this->ranks = null; 219 | } 220 | 221 | 222 | public function addRoute(string $mask, array $metadata = [], bool $oneWay = false): static 223 | { 224 | $this->add(new Route($mask, $metadata), $oneWay); 225 | return $this; 226 | } 227 | 228 | 229 | /** 230 | * Returns an iterator over all routers. 231 | */ 232 | public function withDomain(string $domain): static 233 | { 234 | $router = new static; 235 | $router->domain = $domain; 236 | $router->refUrlCache = new \SplObjectStorage; 237 | $router->parent = $this; 238 | $this->add($router); 239 | return $router; 240 | } 241 | 242 | 243 | public function withPath(string $path): static 244 | { 245 | $router = new static; 246 | $router->path = rtrim($path, '/') . '/'; 247 | $router->refUrlCache = new \SplObjectStorage; 248 | $router->parent = $this; 249 | $this->add($router); 250 | return $router; 251 | } 252 | 253 | 254 | public function end(): ?self 255 | { 256 | return $this->parent; 257 | } 258 | 259 | 260 | /** 261 | * @return Router[] 262 | */ 263 | public function getRouters(): array 264 | { 265 | return array_column($this->list, 0); 266 | } 267 | 268 | 269 | /** 270 | * @return bool[][] 271 | */ 272 | public function getFlags(): array 273 | { 274 | return array_map(fn($info) => ['oneWay' => (bool) $info[1]], $this->list); 275 | } 276 | 277 | 278 | public function getDomain(): ?string 279 | { 280 | return $this->domain; 281 | } 282 | 283 | 284 | public function getPath(): ?string 285 | { 286 | return $this->path; 287 | } 288 | 289 | 290 | private function expandDomain(string $host): string 291 | { 292 | $parts = ip2long($host) ? [$host] : array_reverse(explode('.', $host)); 293 | return strtr($this->domain, [ 294 | '%tld%' => $parts[0], 295 | '%domain%' => isset($parts[1]) ? "$parts[1].$parts[0]" : $parts[0], 296 | '%sld%' => $parts[1] ?? '', 297 | ]); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/Routing/Router.php: -------------------------------------------------------------------------------- 1 | defaults = $defaults; 26 | } 27 | 28 | 29 | /** 30 | * Maps HTTP request to an array. 31 | */ 32 | public function match(Nette\Http\IRequest $httpRequest): ?array 33 | { 34 | return $httpRequest->getUrl()->getPathInfo() === '' 35 | ? $httpRequest->getQuery() + $this->defaults 36 | : null; 37 | } 38 | 39 | 40 | /** 41 | * Constructs absolute URL from array. 42 | */ 43 | public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?string 44 | { 45 | // remove default values; null values are retain 46 | foreach ($this->defaults as $key => $value) { 47 | if (isset($params[$key]) && $params[$key] == $value) { // default value may be object, intentionally == 48 | unset($params[$key]); 49 | } 50 | } 51 | 52 | $url = $refUrl->getHostUrl() . $refUrl->getPath(); 53 | $sep = ini_get('arg_separator.input'); 54 | $query = http_build_query($params, '', $sep ? $sep[0] : '&'); 55 | if ($query !== '') { 56 | $url .= '?' . $query; 57 | } 58 | 59 | return $url; 60 | } 61 | 62 | 63 | /** 64 | * Returns default values. 65 | */ 66 | public function getDefaults(): array 67 | { 68 | return $this->defaults; 69 | } 70 | } 71 | --------------------------------------------------------------------------------