diff --git a/README.md b/README.md index 0db8c11..20a8609 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 + '@[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); + } +); +``` diff --git a/Routes.php b/Routes.php index aaed4e0..151e1f8 100755 --- a/Routes.php +++ b/Routes.php @@ -1,43 +1,70 @@ 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); } /** @@ -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 @@ -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) { @@ -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; } @@ -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 @@ -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; -} diff --git a/tests/RoutesTest.php b/tests/RoutesTest.php index b552cae..917b33c 100644 --- a/tests/RoutesTest.php +++ b/tests/RoutesTest.php @@ -2,41 +2,45 @@ use Mantle\Testkit\Integration_Test_Case; +/** + * @internal + * + * @coversNothing + */ class RoutesTest extends Integration_Test_Case { - - function testThemeRoute() + public function testThemeRoute() { $template = Routes::load(__DIR__ . '/Supports/single.php'); $this->assertTrue($template); } - function testThemeRouteDoesntExist() + public function testThemeRouteDoesntExist() { $template = Routes::load('singlefoo.php'); $this->assertFalse($template); } - function testFullPathRoute() + public function testFullPathRoute() { $hello = WP_CONTENT_DIR . '/plugins/hello.php'; $template = Routes::load($hello); $this->assertTrue($template); } - function testFullPathRouteDoesntExist() + public function testFullPathRouteDoesntExist() { $hello = WP_CONTENT_DIR . '/plugins/hello-foo.php'; $template = Routes::load($hello); $this->assertFalse($template); } - function testRouterClass() + public function testRouterClass() { $this->assertTrue(class_exists('AltoRouter')); } - function testAppliedRoute() + public function testAppliedRoute() { $_SERVER['REQUEST_METHOD'] = 'GET'; global $matches; @@ -56,7 +60,7 @@ function () use ($phpunit) { $this->assertEquals(1, count($matches)); } - function testRouteWithVariable() + public function testRouteWithVariable() { $post_name = 'ziggy'; $post = $this->factory->post->create( @@ -70,7 +74,7 @@ function testRouteWithVariable() $phpunit = $this; Routes::map( 'mything/:slug', - function ($params) use ($phpunit) { + function ($params) { global $matches; $matches = []; if ('ziggy' == $params['slug']) { @@ -83,7 +87,7 @@ function ($params) use ($phpunit) { $this->assertEquals(1, count($matches)); } - function testRouteWithAltoVariable() + public function testRouteWithAltoVariable() { $post_name = 'ziggy'; $post = $this->factory->post->create( @@ -97,7 +101,7 @@ function testRouteWithAltoVariable() $phpunit = $this; Routes::map( 'mything/[*:slug]', - function ($params) use ($phpunit) { + function ($params) { global $matches; $matches = []; if ('ziggy' == $params['slug']) { @@ -110,18 +114,18 @@ function ($params) use ($phpunit) { $this->assertEquals(1, count($matches)); } - function testRouteWithMultiArguments() + public function testRouteWithMultiArguments() { $phpunit = $this; Routes::map( 'artist/[:artist]/song/[:song]', - function ($params) use ($phpunit) { + function ($params) { global $matches; $matches = []; - if ($params['artist'] == 'smashing-pumpkins') { + if ('smashing-pumpkins' == $params['artist']) { $matches[] = true; } - if ($params['song'] == 'mayonaise') { + if ('mayonaise' == $params['song']) { $matches[] = true; } } @@ -132,19 +136,19 @@ function ($params) use ($phpunit) { $this->assertEquals(2, count($matches)); } - function testRouteWithMultiArgumentsOldStyle() + public function testRouteWithMultiArgumentsOldStyle() { $phpunit = $this; global $matches; Routes::map( 'studio/:studio/movie/:movie', - function ($params) use ($phpunit) { + function ($params) { global $matches; $matches = []; - if ($params['studio'] == 'universal') { + if ('universal' == $params['studio']) { $matches[] = true; } - if ($params['movie'] == 'brazil') { + if ('brazil' == $params['movie']) { $matches[] = true; } } @@ -154,7 +158,7 @@ function ($params) use ($phpunit) { $this->assertEquals(2, count($matches)); } - function testRouteAgainstPostName() + public function testRouteAgainstPostName() { $post_name = 'jared'; $post = $this->factory->post->create( @@ -180,7 +184,7 @@ function () use ($phpunit) { $this->assertEquals(1, count($matches)); } - function testVerySimpleRoute() + public function testVerySimpleRoute() { $_SERVER['REQUEST_METHOD'] = 'GET'; global $matches; @@ -188,7 +192,7 @@ function testVerySimpleRoute() $phpunit = $this; Routes::map( 'crackers', - function () use ($phpunit) { + function () { global $matches; $matches = []; $matches[] = true; @@ -199,7 +203,7 @@ function () use ($phpunit) { $this->assertEquals(1, count($matches)); } - function testVerySimpleRouteTrailingSlash() + public function testVerySimpleRouteTrailingSlash() { $_SERVER['REQUEST_METHOD'] = 'GET'; global $matches; @@ -207,7 +211,7 @@ function testVerySimpleRouteTrailingSlash() $phpunit = $this; Routes::map( 'bip/', - function () use ($phpunit) { + function () { global $matches; $matches = []; $matches[] = true; @@ -218,7 +222,7 @@ function () use ($phpunit) { $this->assertEquals(1, count($matches)); } - function testVerySimpleRouteTrailingSlashInRequest() + public function testVerySimpleRouteTrailingSlashInRequest() { $_SERVER['REQUEST_METHOD'] = 'GET'; global $matches; @@ -226,7 +230,7 @@ function testVerySimpleRouteTrailingSlashInRequest() $phpunit = $this; Routes::map( 'bopp', - function () use ($phpunit) { + function () { global $matches; $matches = []; $matches[] = true; @@ -237,8 +241,7 @@ function () use ($phpunit) { $this->assertEquals(1, count($matches)); } - - function testVerySimpleRouteTrailingSlashInRequestAndMapping() + public function testVerySimpleRouteTrailingSlashInRequestAndMapping() { $_SERVER['REQUEST_METHOD'] = 'GET'; global $matches; @@ -246,7 +249,7 @@ function testVerySimpleRouteTrailingSlashInRequestAndMapping() $phpunit = $this; Routes::map( 'zappers', - function () use ($phpunit) { + function () { global $matches; $matches = []; $matches[] = true; @@ -265,7 +268,7 @@ function testVerySimpleRoutePrecedingSlash() $phpunit = $this; Routes::map( '/gobbles', - function () use ($phpunit) { + function () { global $matches; $matches = []; $matches[] = true; @@ -276,7 +279,7 @@ function () use ($phpunit) { $this->assertEquals(1, count($matches)); } - function testFailedRoute() + public function testFailedRoute() { $_SERVER['REQUEST_METHOD'] = 'GET'; global $matches; @@ -295,7 +298,7 @@ function () use ($phpunit) { $this->assertEquals(0, count($matches)); } - function testRouteWithClassCallback() + public function testRouteWithClassCallback() { Routes::map('classroute', ['RoutesTest', '_testCallback']); $this->get(home_url('classroute')); @@ -304,13 +307,55 @@ function testRouteWithClassCallback() $this->assertEquals(1, count($matches_class_test)); } - function matchRoutes() + public function testAddMatchTypes() + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + global $matches; + $matches = []; + Routes::add_match_types(['hex' => '[0-9A-Fa-f]+']); + Routes::map( + 'color/[hex:color]', + function ($params) { + global $matches; + $matches = []; + if ('ff5733' === $params['color']) { + $matches[] = true; + } + } + ); + $this->get(home_url('/color/ff5733')); + $this->matchRoutes(); + $this->assertEquals(1, count($matches)); + } + + public function testAddMatchTypesBeforeMap() + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + global $matches; + $matches = []; + // Calling add_match_types before map() should still work + Routes::add_match_types(['word' => '\w+']); + Routes::map( + 'tag/[word:name]', + function ($params) { + global $matches; + $matches = []; + if ('hello' === $params['name']) { + $matches[] = true; + } + } + ); + $this->get(home_url('/tag/hello')); + $this->matchRoutes(); + $this->assertEquals(1, count($matches)); + } + + public function matchRoutes() { - global $upstatement_routes; - $upstatement_routes->match_current_request(); + Routes::get_instance()->match_current_request(); } - static function _testCallback() + public static function _testCallback() { global $matches_class_test; $matches_class_test = [];