Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Routes::map('blog/:name/page/:pg', function($params){

## map

`Routes::map($pattern, $callback)`
`Routes::map($pattern, $callback, $name = '')`

### Usage

Expand Down Expand Up @@ -181,6 +181,21 @@ page/:pagenum
my-users/:userid/edit
```

**Optional parameters**

End a parameter with `?` to make it optional, so one route can serve the request with and without that segment:

```php
<?php
Routes::map('events/:event?', function($params){
if (isset($params['event'])) {
// /events/my-gig
} else {
// /events
}
});
```

`$callback`
A function that should fire when the pattern matches the request. Callback takes one argument which is an array of the parameters passed in the URL.

Expand All @@ -191,6 +206,43 @@ So in this example: `'info/:name/page/:pg'`, $params would have data for:

... which you can use in the callback function as a part of your query

The array is always passed, so a route with no parameters — or one whose optional parameter was left out of the request — calls the callback with an empty array rather than no argument at all.

`$name`
An optional name for the route, which you can pass to `Routes::url()` to build the route's URL somewhere else in your theme:

```php
<?php
Routes::map('my-users/:userid/edit', 'my_callback_function', 'user-edit');
```

---

## url

`Routes::url($name, $params = [])`

Builds the path of a route that was mapped with a name, filling in its parameters. Handy in a template, so links are generated from the route definition instead of being written out by hand:

```php
<?php
/* functions.php */
Routes::map('my-users/:userid/edit', 'my_callback_function', 'user-edit');

/* somewhere in your theme */
echo Routes::url('user-edit', array('userid' => 123)); // /my-users/123/edit
```

### Arguments

`$name` (required)
The name the route was mapped with. A name that was never mapped throws a `RuntimeException`.

`$params`
An array of values for the route's parameters, keyed by parameter name. An optional parameter you leave out is dropped from the path.

The returned path is relative to the site root and already includes the subdirectory WordPress is installed under, if any, so it can go straight into an `href`.

---

## load
Expand Down
79 changes: 74 additions & 5 deletions Routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ class Routes
*/
protected ?AltoRouter $router = null;

/**
* The routes that were mapped with a name, keyed by that name.
*
* Kept on the instance rather than only inside AltoRouter so that url() can
* still generate a path after match_current_request() has released the router.
*
* @var array<string, string>
*/
private array $named_routes = [];

/**
* Private constructor to enforce the singleton pattern.
*
Expand Down Expand Up @@ -64,12 +74,21 @@ private function ensure_router(): void
// routes like /download/:version match version numbers (1.5.1); unlike an
// explicit allow-list it keeps matching Unicode segments (e.g. /blog/café).
$this->router->addMatchTypes(['slug' => '[^/]++']);
$this->router->setBasePath($this->base_path());
}

/**
* Returns the path WordPress is installed under, wrapped in slashes.
* ex: '/' for a root install, '/blog/' for a subdirectory install.
*/
private function base_path(): string
{
$site_url = get_bloginfo('url');
$site_url_parts = explode('/', $site_url);
$site_url_parts = array_slice($site_url_parts, 3);
$base_path = implode('/', $site_url_parts);
$base_path = $base_path ? '/' . trim($base_path, '/') . '/' : '/';
$this->router->setBasePath($base_path);

return $base_path ? '/' . trim($base_path, '/') . '/' : '/';
}

/**
Expand All @@ -88,7 +107,11 @@ public function match_current_request()
$this->router = null;

if ($route && isset($route['target'])) {
call_user_func($route['target'], ...array_filter([$route['params'] ?? null]));
// Always hand the callback its parameters, even when the matched route
// has none or its optional parameters were absent from the request.
// Dropping the argument makes callbacks declared as function ($params)
// -- the form used throughout the README -- fatal on those requests.
call_user_func($route['target'], $route['params'] ?? []);
}
}

Expand Down Expand Up @@ -135,8 +158,54 @@ public static function map($route, $callback, $name = '')
$instance = self::get_instance();
$instance->ensure_router();
$route = self::convert_route($route);
$instance->router->map('GET|POST|PUT|DELETE|HEAD', trailingslashit($route), $callback, $name);
$instance->router->map('GET|POST|PUT|DELETE|HEAD', untrailingslashit($route), $callback, $name);
$unslashed = untrailingslashit($route);
// A route is mapped in both slash variants so it matches either way, but only
// one of them may carry the name: AltoRouter rejects a name it has already
// seen, so naming both would throw on every named route.
$instance->router->map('GET|POST|PUT|DELETE|HEAD', trailingslashit($route), $callback);
$instance->router->map('GET|POST|PUT|DELETE|HEAD', $unslashed, $callback, $name);

if ($name) {
$instance->named_routes[$name] = $unslashed;
}
}

/**
* Generates the path of a route that was mapped with a name.
*
* @api
*
* @param string $name the name given as map()'s third argument
* @param array $params values for the route's named parameters, keyed by
* parameter name (ex: ['userid' => 123])
*
* @return string A path relative to the site root, including the subdirectory
* WordPress is installed under, if any. Optional parameters that
* are not supplied are left out.
*
* @throws RuntimeException if no route was mapped under that name
*
* @example
* ```php
* Routes::map('my-users/:userid/edit', 'my_callback_function', 'user-edit');
* $href = Routes::url('user-edit', ['userid' => 123]); // '/my-users/123/edit'
* ```
*/
public static function url($name, $params = [])
{
$instance = self::get_instance();

if (!isset($instance->named_routes[$name])) {
throw new RuntimeException("Route '{$name}' does not exist.");
}

// Generating from a dedicated AltoRouter instance keeps reverse routing
// available after match_current_request() has released the matching one.
$generator = new AltoRouter();
$generator->setBasePath($instance->base_path());
$generator->map('GET', $instance->named_routes[$name], null, $name);

return $generator->generate($name, $params);
}

/**
Expand Down
110 changes: 110 additions & 0 deletions tests/RoutesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,116 @@ function () {
$response->assertRedirect(get_permalink($post_id));
}

public function testNamedRouteMatchesRequest()
{
// Regression test for issue #11: map() registered the same name on both slash
// variants of the route, and AltoRouter refuses a name it has already seen, so
// the documented third argument threw "Can not redeclare route" on every use.
global $matches;
$matches = [];
Routes::map(
'named-artist/:artist',
function ($params) {
global $matches;
$matches = [];
if ('bowie' === $params['artist']) {
$matches[] = true;
}
},
'named-artist'
);
$this->get(home_url('/named-artist/bowie'));
$this->matchRoutes();
$this->assertCount(1, $matches);
}

public function testUrlGeneratesPathForNamedRoute()
{
Routes::map('named-users/:userid/edit', function ($params) {}, 'named-user-edit');

$this->assertSame(
wp_parse_url(home_url('/named-users/123/edit'), PHP_URL_PATH),
Routes::url('named-user-edit', ['userid' => 123])
);
}

public function testUrlGeneratesPathAfterRequestHasBeenMatched()
{
// match_current_request() releases the AltoRouter instance, but a template
// rendered later in the same request still has to be able to build the URL.
Routes::map('named-events/:event', function ($params) {}, 'named-event');
$this->get(home_url('/named-events/gig'));
$this->matchRoutes();

$this->assertSame(
wp_parse_url(home_url('/named-events/gig'), PHP_URL_PATH),
Routes::url('named-event', ['event' => 'gig'])
);
}

public function testUrlLeavesOutAnAbsentOptionalParameter()
{
Routes::map('named-archive/:year?', function ($params) {}, 'named-archive');

$this->assertSame(
wp_parse_url(home_url('/named-archive/2026'), PHP_URL_PATH),
Routes::url('named-archive', ['year' => 2026])
);
$this->assertSame(
wp_parse_url(home_url('/named-archive/'), PHP_URL_PATH),
Routes::url('named-archive')
);
}

public function testUrlThrowsForAnUnknownRouteName()
{
$this->expectException(RuntimeException::class);

Routes::url('a-route-that-was-never-mapped');
}

public function testOptionalParameterCallbackRunsWhenTheValueIsAbsent()
{
// Regression test for issue #11: AltoRouter's optional parameter syntax already
// matched, but an absent value left the params array empty and the callback was
// then called with no argument at all, so the function ($params) callback form
// the README documents died with an ArgumentCountError.
global $matches;
$matches = [];
Routes::map(
'optional-events/:event?',
function ($params) {
global $matches;
$matches = [];
$matches[] = $params;
}
);
$this->get(home_url('/optional-events'));
$this->matchRoutes();

$this->assertCount(1, $matches);
$this->assertSame([], $matches[0]);
}

public function testOptionalParameterCallbackReceivesTheValueWhenPresent()
{
global $matches;
$matches = [];
Routes::map(
'optional-shows/:show?',
function ($params) {
global $matches;
$matches = [];
$matches[] = $params;
}
);
$this->get(home_url('/optional-shows/ziggy'));
$this->matchRoutes();

$this->assertCount(1, $matches);
$this->assertSame(['show' => 'ziggy'], $matches[0]);
}

public function matchRoutes()
{
Routes::get_instance()->match_current_request();
Expand Down