diff --git a/bootstrap.php b/bootstrap.php new file mode 100644 index 0000000..9de86b2 --- /dev/null +++ b/bootstrap.php @@ -0,0 +1,11 @@ + 'Underpin\Routes\Loaders\Routes' ] ) ); \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fb7cd52 --- /dev/null +++ b/composer.json @@ -0,0 +1,21 @@ +{ + "name": "underpin/route-loader", + "description": "Custom route loader for Underpin", + "type": "library", + "license": "GPL-2.0-or-later", + "authors": [ + { + "name": "Alex Standiford", + "email": "a@alexstandiford.com" + } + ], + "require": { + "underpin/underpin": "^2.0" + }, + "autoload": { + "psr-4": {"Underpin\\Routes\\": "lib/"}, + "files": [ + "bootstrap.php" + ] + } +} diff --git a/lib/Abstracts/Route.php b/lib/Abstracts/Route.php new file mode 100644 index 0000000..47f5d19 --- /dev/null +++ b/lib/Abstracts/Route.php @@ -0,0 +1,66 @@ +query_vars, 'index.php' ); + } + + abstract public function get_id(); + + /** + * Determines if is route is the current route. + * + * @since 1.0.0 + * + * @return bool True if this is the current route, otherwise false. + */ + protected function is_current_route() { + foreach ( array_keys( $this->query_vars ) as $query_var ) { + if ( get_query_var( $query_var, self::FALSE_HASH ) === self::FALSE_HASH ) { + return false; + } + } + + return true; + } + + public function __get( $key ) { + + if ( 'path' === $key && empty( $this->path ) ) { + $this->path = $this->get_path(); + return $this->path; + } + + if ( 'id' === $key && empty( $this->id ) ) { + $this->id = $this->get_id(); + return $this->id; + } + + if ( 'is_current_route' === $key && empty ( $this->is_current_route ) ) { + $this->is_current_route = $this->is_current_route(); + return $this->is_current_route; + } + + if ( isset( $this->$key ) ) { + return $this->$key; + } else { + return new \WP_Error( 'route_param_not_set', 'The key ' . $key . ' could not be found.' ); + } + } + +} \ No newline at end of file diff --git a/lib/Factories/Route.php b/lib/Factories/Route.php new file mode 100644 index 0000000..b06af7d --- /dev/null +++ b/lib/Factories/Route.php @@ -0,0 +1,26 @@ +set_values( $args ); + } + + public function get_id() { + return $this->set_callable( $this->id_callback, $this->query_vars ); + } + +} \ No newline at end of file diff --git a/lib/Loaders/Routes.php b/lib/Loaders/Routes.php new file mode 100644 index 0000000..59f8e93 --- /dev/null +++ b/lib/Loaders/Routes.php @@ -0,0 +1,116 @@ +do_actions(); + } + + protected function do_actions() { + add_filter( 'init', [ $this, 'setup_rewrite_rules' ] ); + add_filter( 'query_vars', [ $this, 'whitelist_rewrite_vars' ] ); + } + + function whitelist_rewrite_vars( $vars ) { + $items = []; + foreach ( (array) $this as $item ) { + $items = array_merge( $items, array_keys( $item->query_vars ) ); + } + return array_merge( $vars, array_unique( $items ) ); + } + + /** + * Sets up rewrite rules for registered routes. + * + * @return void + */ + public function setup_rewrite_rules() { + /* @var Route $item */ + foreach ( (array) $this as $item ) { + add_rewrite_rule( $item->route, $item->query_vars, $item->priority ); + } + } + + /** + * @param $key + * + * @return Route|WP_Error + */ + public function get( $key ) { + return parent::get( $key ); + } + + /** + * @param $route_id string The route ID + * + * @return boolean true if this is the current route, otherwise false. + */ + public function is_current_route( $route_id ) { + + $route = $this->find( [ 'id' => $route_id ] ); + + if ( is_wp_error( $route ) ) { + return false; + } + + /* @var Route $route */ + return $route->is_current_route; + } + + /** + * Attempts to retrieve the current route from routes registered against this plugin. + * + * @since 1.0.0 + * + * @return Route|WP_Error The route if found, otherwise WP_Error + */ + public function current() { + return $this->find( [ 'is_current_route' => true ] ); + } + + /** + * Attempts to retrieve the current route from routes registered against this plugin. + * + * @since 1.0.0 + * + * @return Route|WP_Error The route if found, otherwise WP_Error + */ + public function get_by_route( $route ) { + return $this->find( [ 'route' => $route ] ); + } + + protected function set_default_items() { + // TODO: Implement set_default_items() method. + } + +} \ No newline at end of file diff --git a/lib/Middlewares/Prevent_Main_Query.php b/lib/Middlewares/Prevent_Main_Query.php new file mode 100644 index 0000000..7f8269c --- /dev/null +++ b/lib/Middlewares/Prevent_Main_Query.php @@ -0,0 +1,53 @@ +route = $instance; + add_filter( 'posts_request', [ $this, 'prevent_main_query' ], 10, 2 ); + } + } + + /** + * @param $sql + * @param WP_Query $query + * + * @return string|false The current query if this route should not be prevented, otherwise false. + */ + public function prevent_main_query( $sql, WP_Query $query ) { + + if ( $query->is_main_query() && true === $this->route->is_current_route ) { + // prevent SELECT FOUND_ROWS() query + $query->query_vars['no_found_rows'] = true; + + // prevent post term and meta cache update queries + $query->query_vars['cache_results'] = false; + + return false; + } + return $sql; + } + +} \ No newline at end of file diff --git a/lib/Middlewares/Use_Template.php b/lib/Middlewares/Use_Template.php new file mode 100644 index 0000000..63fa867 --- /dev/null +++ b/lib/Middlewares/Use_Template.php @@ -0,0 +1,43 @@ +template = $template; + parent::__construct( 'use_template' ); + } + + public function update( $instance, Storage $args ) { + if ( $instance instanceof Route ) { + $this->route = $instance; + add_filter( 'template_include', [ $this, 'include_template' ], 10, 2 ); + } + } + + public function include_template( $template ) { + if ( $this->route->is_current_route ) { + return $this->template; + } + + return $template; + } + +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..14452a5 --- /dev/null +++ b/readme.md @@ -0,0 +1,153 @@ +# Underpin Route Loader + +Loader That assists with adding custom routes to a WordPress website. + +## Installation + +### Using Composer + +`composer require underpin/route-loader` + +### Manually + +This plugin uses a built-in autoloader, so as long as it is required _before_ +Underpin, it should work as-expected. + +`require_once(__DIR__ . '/route-loader/bootstrap.php');` + +## Setup + +1. Install Underpin. See [Underpin Docs](https://www.github.com/underpin-wp/underpin) +1. Register new routes as-needed. + +## Example + +A very basic example could look something like this. + +```php +plugin_name()->routes()->add( 'dashboard-home', [ + 'name' => 'Dashboard', + 'description' => "Route for dashboard screen", + 'id_callback' => function () { // Callback function to set the route ID. This should be unique. + $pieces = [ 'account' ]; + $account_screen = get_query_var( 'account_screen', false ); + $account_child_screen = get_query_var( 'account_child_screen', false ); + + if ( $account_screen ) { + $pieces[] = $account_screen; + } + + if ( $account_child_screen ) { + $pieces[] = $account_child_screen; + } + + return implode( '_', $pieces ); + }, + 'priority' => 'top', // Optional. sets the priority of add_rewrite_rule. Defaults to bottom. Can be "bottom" or "top" + 'route' => '^account/?([A-Za-z0-9-]+)?/?([A-Za-z0-9-]+)?/?$', // Regex for route. See https://developer.wordpress.org/reference/functions/add_rewrite_rule/ + 'query_vars' => [ 'account_screen' => '$matches[1]', 'account_child_screen' => '$matches[2]' ], // Query vars for route. See https://developer.wordpress.org/reference/functions/add_rewrite_rule/ +] ); +``` + +Alternatively, you can extend `Route` and reference the extended class directly. This would allow you to use Underpin's +Template loader trait, as well as other more-advanced class-based utilities: + +```php +plugin_name()->routes()->add('key','Namespace\To\Class'); +``` + +## Middleware + +Since there are many ways a route can be used, this loader simply _registers_ the route to use, ensures any custom query +params are whitelisted, and ensures that they are sorted to minimize collisions with routes. In-order to _do_ something +with your route, you need to register additional actions. To help facilitate common actions, this loader comes with +middleware that can extend the behavior of routes. + +### Template Middleware + +The `Use_Template` middleware allows you to render a custom template when this route is used. + +```php +plugin_name()->routes()->add( 'dashboard-home', [ + 'name' => 'Dashboard', + 'description' => "Route for dashboard screen", + 'route' => '^account/?([A-Za-z0-9-]+)?/?([A-Za-z0-9-]+)?/?$', // Regex for route. See https://developer.wordpress.org/reference/functions/add_rewrite_rule/ + 'query_vars' => [ 'account_screen' => '$matches[1]', 'account_child_screen' => '$matches[2]' ], // Query vars for route. See https://developer.wordpress.org/reference/functions/add_rewrite_rule/ + 'id_callback' => function () { // Callback function to set the route ID. This should be unique. + $pieces = [ 'account' ]; + $account_screen = get_query_var( 'account_screen', false ); + $account_child_screen = get_query_var( 'account_child_screen', false ); + + if ( $account_screen ) { + $pieces[] = $account_screen; + } + + if ( $account_child_screen ) { + $pieces[] = $account_child_screen; + } + + return implode( '_', $pieces ); + }, + 'middlewares' => [ + new \Underpin\Routes\Middlewares\Use_Template('/path/to/template/file.php') + ] +] ); +``` + +### Prevent Query Middleware + +By default, WordPress makes a database call on every page load to load a post object in the query. Sometimes, however, +this is not necessary on custom routes. However, Even if you don't specify a post, WordPress will load a default post +instead. This causes an additional query and can cause other unwanted behaviors, as well. + +To circumvent this, use the `Prevent_Main_Query` middleware, like so: + +```php +plugin_name()->routes()->add( 'dashboard-home', [ + 'name' => 'Dashboard', + 'description' => "Route for dashboard screen", + 'route' => '^account/?([A-Za-z0-9-]+)?/?([A-Za-z0-9-]+)?/?$', // Regex for route. See https://developer.wordpress.org/reference/functions/add_rewrite_rule/ + 'query_vars' => [ 'account_screen' => '$matches[1]', 'account_child_screen' => '$matches[2]' ], // Query vars for route. See https://developer.wordpress.org/reference/functions/add_rewrite_rule/ + 'id_callback' => function () { // Callback function to set the route ID. This should be unique. + $pieces = [ 'account' ]; + $account_screen = get_query_var( 'account_screen', false ); + $account_child_screen = get_query_var( 'account_child_screen', false ); + + if ( $account_screen ) { + $pieces[] = $account_screen; + } + + if ( $account_child_screen ) { + $pieces[] = $account_child_screen; + } + + return implode( '_', $pieces ); + }, + 'middlewares' => [ + new \Underpin\Routes\Middlewares\Prevent_Main_Query + ] +] ); +``` + +This middleware will stop the primary query from running, while leaving the global WP_Query otherwise intact. + +## Working With Routes + +### Testing for Current Route + +Usually you'll need to do some kind-of dynamic logic to determine certain behaviors that only run when the current page +matches your route. This loader helps facilitate that with `is_current_route()`, which can be used like so: + +```php +if( plugin_name()->routes()->is_current_route( 'route-id' ) ){ + // Do something specific to this route. +} +``` + +If you happen to have the `Route` object directly, you can access it like so: + +```php +if( $route->is_current_route ){ + // Do something specific to this route. +} +``` \ No newline at end of file