Skip to content
Merged
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
131 changes: 131 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,113 @@ Simple routing for WordPress. Designed for usage with [Timber](https://github.co
[![PHP unit tests](https://github.com/Upstatement/routes/actions/workflows/php-unit-tests.yml/badge.svg?branch=2.x)](https://github.com/Upstatement/routes/actions/workflows/php-unit-tests.yml?query=branch:2.x)
[![Latest Stable Version](https://img.shields.io/packagist/v/Upstatement/routes.svg?style=flat-square)](https://packagist.org/packages/Upstatement/routes)

## Installation

Install via [Composer](https://getcomposer.org/):

```bash
composer require upstatement/routes
```

Then make sure Composer's autoloader is included in your project. In a standard WordPress setup this is typically done in `functions.php` or your plugin's main file:

```php
require_once __DIR__ . '/vendor/autoload.php';
```

If you're using a WordPress-specific Composer setup (e.g. [Bedrock](https://roots.io/bedrock/)), the autoloader is usually already loaded for you.

## Upgrading to 1.x

Version 1.0 introduces several breaking changes. If you're upgrading from 0.x, read through the following sections to make sure your code is compatible.

### PHP 8.2+ required

Routes now requires PHP 8.2 or higher.

### Singleton — no direct instantiation

The class is now a singleton. You can no longer instantiate it directly:

```php
// Before (0.x) — no longer works
$routes = new Routes();

// After (1.x)
$instance = Routes::get_instance();
```

In practice most applications never instantiated `Routes` directly, so this is unlikely to affect you.

### No more global `$upstatement_routes`

The global variable `$upstatement_routes` used internally in 0.x has been removed. If your code accessed called `$upstatement_routes->match_current_request()` directly, update it:

```php
// Before (0.x)
global $upstatement_routes;
$upstatement_routes->match_current_request();

// After (1.x)
Routes::get_instance()->match_current_request();
```

### New `add_match_types()` method

Custom AltoRouter match types can now be registered via `Routes::add_match_types()` and may be called before or after `Routes::map()`:

```php
Routes::add_match_types(['hex' => '[0-9A-Fa-f]+']);
Routes::map('color/[hex:color]', function($params) {
// $params['color'] is guaranteed to be a hex string
});
```

### Class is no longer auto-instantiated on include

In 0.x, including `Routes.php` immediately instantiated the class and registered WordPress hooks. In 1.x, the singleton is created lazily on the first call to `Routes::map()` or `Routes::add_match_types()`. No action is required as long as you call `Routes::map()` before `wp_loaded` fires, which is the standard usage pattern.

### Base path handling for subdirectory installs

**⚠️ Potential breaking change for WordPress subdirectory installations**

In 0.x, the base path logic was route-dependent and would detect if you included the subdirectory in your route pattern. In 1.x, the base path is always calculated from your site URL regardless of your route patterns.

If your WordPress site is installed in a subdirectory (e.g., `https://example.com/blog/`) and your routes **included** that subdirectory in the pattern, those routes will break in 1.x.

**Example of code that will break:**

```php
// Site URL: https://example.com/blog/
// WordPress installed in /blog/ subdirectory

// This route WILL BREAK in 1.x:
Routes::map('blog/my-page', function($params) {
// This won't match anymore because AltoRouter strips /blog/
// and then tries to match the remainder against 'blog/my-page'
});
```

**How to fix it:**

Simply remove the subdirectory prefix from your route patterns:

```php
// Site URL: https://example.com/blog/
// WordPress installed in /blog/ subdirectory

// ✅ Correct way to define routes in 1.x:
Routes::map('my-page', function($params) {
// This will correctly match https://example.com/blog/my-page
});

Routes::map('my-users/:userid/edit', function($params) {
// This will correctly match https://example.com/blog/my-users/123/edit
});
```

**Note:** If your site is **not** in a subdirectory, or if your routes never included the subdirectory prefix, this change does not affect you.

### Basic Usage

```php
Expand Down Expand Up @@ -127,3 +234,27 @@ The query you want to use, it can accept a string or array just like `Timber::ge

`$status_code`
Send an optional status code. Defaults to 200 for 'Success/OK'

## add_match_types

This method makes it possible to add custom match types in Routes.

```php
<?php
/* functions.php */

Routes::add_match_types([
'oldID' => '@[0-9]++',
]);

Routes::map(
'[oldID:id]/[:slug]',
function ($params) {
$old_id = $params['id'];
$slug = $params['slug'];

/* the rest as normal... */
Timber::render('single.php', $context);
}
);
```
163 changes: 92 additions & 71 deletions Routes.php
Original file line number Diff line number Diff line change
@@ -1,43 +1,70 @@
<?php

/**
* Plugin Name: Routes
* Plugin URI: http://www.upstatement.com
* Description: Routes makes it easy to add custom routing to your WordPress site. That's why we call it Routes. That is all.
* Author: Jared Novack + Upstatement
* Author URI: http://www.upstatement.com
* Text Domain: routes
* Version: 0.9.2
*/

/**
* Routes makes it easy to add custom routing to your WordPress site. That's why we call it Routes. That is all.
*
* The Routes class is responsible for defining the routing functionality of the plugin.
* It uses the AltoRouter library to match the current request to the defined routes,
* and to call the appropriate callback function when a route is matched.
* It also provides a method for loading a template file and sending data to it, which can be used in the callback functions for the routes defined with the map() method.
*
* @package Routes
*/
class Routes
{
/**
* The singleton instance of the Routes class.
*/
private static ?self $instance = null;

/**
* The version of the library.
*/
public static $version = '1.0.0'; // x-release-please-version
/**
* The AltoRouter instance used to match the current request to the defined routes.
*
* @var AltoRouter
*/
protected $router;
protected ?AltoRouter $router = null;

/**
* Constructor.
* Private constructor to enforce the singleton pattern.
*
* Adds the match_current_request function to the init and wp_loaded hooks,
* which will check if the current request matches any of the routes defined in this plugin,
* and if so, will call the appropriate callback function.
*/
public function __construct()
private function __construct()
{
add_action('init', [$this, 'match_current_request']);
add_action('wp_loaded', [$this, 'match_current_request']);
}

/**
* Returns the singleton instance, creating it if it does not yet exist.
*/
public static function get_instance(): self
{
if (null === self::$instance) {
self::$instance = new self();
}

return self::$instance;
}

/**
* Initializes the AltoRouter instance if it has not been created yet.
* Called lazily by map() and add_match_types().
*/
private function ensure_router(): void
{
add_action('init', [self::class, 'match_current_request']);
add_action('wp_loaded', [self::class, 'match_current_request']);
if (null !== $this->router) {
return;
}
$this->router = new AltoRouter();
$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);
}

/**
Expand All @@ -46,34 +73,48 @@ public function __construct()
*
* @internal
*/
public static function match_current_request()
public function match_current_request()
{
global $upstatement_routes;
if (isset($upstatement_routes->router)) {
$route = $upstatement_routes->router->match();
if (null == $this->router) {
return;
}

unset($upstatement_routes->router);
$route = $this->router->match();
$this->router = null;

if ($route && isset($route['target'])) {
if (isset($route['params'])) {
call_user_func($route['target'], $route['params']);
} else {
call_user_func($route['target']);
}
}
if ($route && isset($route['target'])) {
call_user_func($route['target'], ...array_filter([$route['params'] ?? null]));
}
}

/**
* Wrapper for AltoRouter's addMatchTypes function. See AltoRouter documentation for more details.
*
* @api
*
* @link https://dannyvankooten.github.io/AltoRouter/usage/mapping-routes.html
*
* @param array $match_types An array of custom match types to add to AltoRouter.
* Keys are type names and values are regex patterns.
* ex: Routes::add_match_types(['hex' => '[0-9A-Fa-f]+']);
*/
public static function add_match_types($match_types)
{
$instance = self::get_instance();
$instance->ensure_router();
$instance->router->addMatchTypes($match_types);
}

/**
* Maps a route to a callback function.
*
* @api
* @param string $route A string to match (ex: 'myfoo').
* @param callable $callback A callback function to call when the route is matched.
* This can be a string for a function name,
* an array for a class method, or an anonymous function.
* @param string $name An optional name for the route, which can be used to generate URLs with the url() method.
* @return void
*
* @param string $route a string to match (ex: 'myfoo')
* @param callable $callback A callback function to call when the route is matched.
* This can be a string for a function name,
* an array for a class method, or an anonymous function.
* @param string $name an optional name for the route, which can be used to generate URLs with the url() method
*
* @example
* ```php
Expand All @@ -86,38 +127,25 @@ public static function match_current_request()
*/
public static function map($route, $callback, $name = '')
{
global $upstatement_routes;
if (!isset($upstatement_routes->router)) {
$upstatement_routes->router = new AltoRouter();
$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);
if (!$base_path || str_starts_with($route, $base_path)) {
$base_path = '/';
} else {
$base_path = '/' . $base_path . '/';
}
// Clean any double slashes that have resulted
$base_path = str_replace('//', '/', $base_path);
$upstatement_routes->router->setBasePath($base_path);
}
$instance = self::get_instance();
$instance->ensure_router();
$route = self::convert_route($route);
$upstatement_routes->router->map('GET|POST|PUT|DELETE|HEAD', trailingslashit($route), $callback, $name);
$upstatement_routes->router->map('GET|POST|PUT|DELETE|HEAD', untrailingslashit($route), $callback, $name);
$instance->router->map('GET|POST|PUT|DELETE|HEAD', trailingslashit($route), $callback, $name);
$instance->router->map('GET|POST|PUT|DELETE|HEAD', untrailingslashit($route), $callback, $name);
}

/**
*
* Used internally to convert a route string with :param style parameters
* to the format used by AltoRouter, which is [:param].
* If the route string already contains [ and ] characters,
* it is assumed to be in the correct format and is returned unchanged.
*
* @internal
* @param string $route_string A route string with :param style parameters (ex: 'myfoo/:my_param').
* @return string A string in a format for AltoRouter
* ex: [:my_param]
*
* @param string $route_string a route string with :param style parameters (ex: 'myfoo/:my_param')
*
* @return string A string in a format for AltoRouter
* ex: [:my_param]
*/
public static function convert_route($route_string)
{
Expand All @@ -132,6 +160,7 @@ public static function convert_route($route_string)
if (str_starts_with($route_string, '/')) {
$route_string = substr($route_string, 1);
}

return $route_string;
}

Expand Down Expand Up @@ -162,8 +191,8 @@ public static function load($template, $tparams = false, $query = false, $status
'status_header',
function ($status_header, $header, $text, $protocol) use ($status_code) {
$text = get_status_header_desc($status_code);
$header_string = "$protocol $status_code $text";
return $header_string;

return "{$protocol} {$status_code} {$text}";
},
10,
4
Expand Down Expand Up @@ -216,18 +245,10 @@ function () use ($query) {
fn($current_template) => $template,
$priority
);

return true;
}

return false;
}
}

global $upstatement_routes;
$upstatement_routes = new Routes();

if (
file_exists($composer_autoload = __DIR__ . '/vendor/autoload.php')
|| file_exists($composer_autoload = WP_CONTENT_DIR . '/vendor/autoload.php')
) {
require_once $composer_autoload;
}
Loading
Loading