├── .gitignore ├── README.md ├── composer.json ├── readme.txt ├── routemaster.php └── src ├── Exception ├── NoFallbackException.php └── RoutemasterException.php ├── Model └── Route.php ├── Oowp ├── OowpRouter.php ├── OowpRouterHelper.php ├── Response │ ├── ContainerViewResponse.php │ └── ViewResponse.php └── View │ ├── ContainerView.php │ ├── NotFoundView.php │ ├── RoutemasterOowpView.php │ └── SitemapView.php ├── Response ├── FileResponse.php ├── HtmlResponse.php ├── JsonResponse.php ├── RedirectResponse.php ├── RobotsResponse.php ├── RoutemasterResponse.php └── XmlResponse.php ├── Router.php ├── RouterHelper.php └── View ├── HtmlPage.php └── Renderable.php /.gitignore: -------------------------------------------------------------------------------- 1 | ## Composer ## 2 | vendor/ 3 | wp-content/mu-plugins/ 4 | # Locked versions won't be reflected in installations, so not having any 5 | # is probably less confusing. 6 | # https://getcomposer.org/doc/02-libraries.md#lock-file 7 | composer.lock 8 | 9 | ## IDEs ## 10 | .idea/ 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Routemaster WordPress plugin 2 | 3 | Replaces the built-in WordPress routing logic with one defined by URL patterns. 4 | 5 | ## Installation 6 | 7 | - Install and activate plugin 8 | - Create Router subclass that implements abstract `routes` function 9 | - add the following to your theme: 10 | 11 | ~~~~ 12 | $router = MyRouter::getInstance(); 13 | $router->setup(); 14 | ~~~~ 15 | 16 | ### Use with [OOWP](https://github.com/outlandishideas/oowp) 17 | 18 | To successfully extend `OowpRouter` and gain router awareness of post objects, 19 | you should also install OOWP. This is optional if you avoid the 20 | `Outlandish\Wordpress\Routemaster\Oowp` namespace. 21 | 22 | To install it: 23 | 24 | composer require outlandish/oowp 25 | 26 | ### Wordpress.org 27 | 28 | Unfortunately for _new_ projects Wordpress [are not accepting](https://make.wordpress.org/plugins/2016/03/01/please-do-not-submit-frameworks/) 29 | libraries as plugins which they host. The GitHub Actions support for this 30 | is now deleted since we tried and failed to have the project added there. 31 | 32 | See [this PR](https://github.com/outlandishideas/routemaster/pull/14/files#diff-2b7bfbec6c9ddad9e63030b179d67ece) if you'd like to refer back to the 33 | GitHub Actions setup for Wordpress.org publishing, for another plugin which meets the 34 | current guidelines. 35 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "outlandish/routemaster", 3 | "type": "wordpress-muplugin", 4 | "description": "A routing plugin for WordPress", 5 | "minimum-stability": "stable", 6 | "license": "GPL-3.0", 7 | "autoload": { 8 | "psr-4": { 9 | "Outlandish\\Wordpress\\Routemaster\\": "src/" 10 | } 11 | }, 12 | "require": { 13 | "php": ">=5.6", 14 | "ext-json": "*" 15 | }, 16 | "suggest": { 17 | "outlandish/oowp": "To optionally use a post-object-aware version of the router and corresponding views" 18 | }, 19 | "authors": [ 20 | { 21 | "name": "Rasmus Winter", 22 | "email": "rasmus@outlandish.com" 23 | }, 24 | { 25 | "name": "Tamlyn Rhodes", 26 | "email": "tam@outlandish.com" 27 | }, 28 | { 29 | "name": "Matthew Kendon", 30 | "email": "matt@outlandish.com" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === Routemaster === 2 | Contributors: outlandishcoop 3 | Tags: routing, routes 4 | Requires at least: 4.9.8 5 | Tested up to: 5.4.1 6 | Stable tag: 2.4.1 7 | Requires PHP: 5.6 8 | License: GPLv3 or later 9 | License URI: https://www.gnu.org/licenses/gpl-3.0.html 10 | 11 | Replaces the built-in WordPress routing logic with one defined by URL patterns. 12 | -------------------------------------------------------------------------------- /routemaster.php: -------------------------------------------------------------------------------- 1 | post_name = sanitize_title($post->post_name ? $post->post_name : $post->post_title, $post->ID); 22 | $link = $post->permalink(); 23 | $link .= '?' . $qs; 24 | } 25 | } 26 | 27 | return $link; 28 | }); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /src/Exception/NoFallbackException.php: -------------------------------------------------------------------------------- 1 | allowFallback = false; 10 | } 11 | } -------------------------------------------------------------------------------- /src/Exception/RoutemasterException.php: -------------------------------------------------------------------------------- 1 | pattern = $pattern; 19 | $this->actionName = $actionName; 20 | $this->handler = $handler; 21 | } 22 | 23 | 24 | } -------------------------------------------------------------------------------- /src/Oowp/OowpRouter.php: -------------------------------------------------------------------------------- 1 | permalinkHook($postLink, $post, $leavename); 25 | }, 10, 4); 26 | } 27 | 28 | protected function getDefaultRoutePatterns() 29 | { 30 | return array_merge(parent::getDefaultRoutePatterns(), [ 31 | '|^sitemap.xml$|i' => 'sitemap', //xml sitemap for google etc 32 | '|^__preview__/([^/]+)/(\d+)/?$|' => 'previewPost', //__preview__/{post type}/{post id} 33 | '|([^/]+)/?$|' => 'defaultPost', //matches blah/blah/slug 34 | '|^$|' => 'frontPage' //matches empty string 35 | ]); 36 | } 37 | 38 | 39 | /** @var int|null Used in permalinkHook function, to prevent infinite recursion */ 40 | protected $permalinkHookPostId; 41 | 42 | /** 43 | * Overwrites the post_link with the post's permalink() 44 | * @param string $post_link 45 | * @param \WP_Post $post 46 | * @param boolean $leaveName 47 | * @return string|void 48 | */ 49 | public function permalinkHook($post_link, $post, $leaveName) 50 | { 51 | if ($post->post_name) { 52 | if ($post->ID != $this->permalinkHookPostId) { 53 | // prevent infinite recursion by saving the ID before calling permalink() (which may come back here again) 54 | $this->permalinkHookPostId = $post->ID; 55 | $post_link = WordpressPost::createWordpressPost($post)->permalink($leaveName); 56 | $this->permalinkHookPostId = null; 57 | } 58 | } elseif (in_array($post->post_status, ['draft', 'auto-draft', 'inherit'])) { 59 | $post_link = get_bloginfo('url') . '/__preview__/' . $post->post_type . '/' . $post->ID; 60 | } 61 | return $post_link; 62 | } 63 | 64 | /*********************************************** 65 | * 66 | * Methods for default routes (defined above) 67 | * 68 | ***********************************************/ 69 | 70 | /** 71 | * @route /sitemap.xml 72 | */ 73 | protected function sitemap() 74 | { 75 | $view = new SitemapView(new OowpQuery(array('post_type' => 'any', 'orderby' => 'date'))); 76 | return new XmlResponse($view); 77 | } 78 | 79 | /** 80 | * @route /any/unknown/route 81 | */ 82 | protected function show404() 83 | { 84 | global $post; 85 | $post = new FakePost(array('post_title' => 'Page not found')); 86 | return parent::show404(); 87 | } 88 | 89 | /** 90 | * @route /default/route/when/no/other/match 91 | * @param string $slug 92 | * @return array 93 | */ 94 | protected function defaultPost($slug) 95 | { 96 | $args = ['name' => $slug, 'post_type' => 'any']; 97 | return [ 98 | 'post' => $this->helper->querySingle($args, true) 99 | ]; 100 | } 101 | 102 | /** 103 | * @route /__preview__/{post type}/{post id} 104 | * @param string $postType 105 | * @param string $id 106 | * @return array 107 | */ 108 | protected function previewPost($postType, $id) 109 | { 110 | $args = ['id' => $id, 'post_type' => $postType]; // querySingle will apply the correct post status parameters 111 | return [ 112 | 'post' => $this->helper->querySingle($args, false) 113 | ]; 114 | } 115 | 116 | /** 117 | * @route / 118 | */ 119 | protected function frontPage() 120 | { 121 | $args = ['page_id' => get_option('page_on_front')]; 122 | return [ 123 | 'post' => $this->helper->querySingle($args, true) 124 | ]; 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /src/Oowp/OowpRouterHelper.php: -------------------------------------------------------------------------------- 1 | permalink(); 46 | if ("$scheme://$_SERVER[HTTP_HOST]$path" !== $permalink) { 47 | wp_redirect($permalink); 48 | die; 49 | } 50 | } 51 | 52 | /** 53 | * Create a new query object and set the global $wp_query 54 | * @param $args 55 | * @return OowpQuery 56 | */ 57 | protected function query($args) { 58 | global $wp_query, $wp_the_query; 59 | $wp_the_query = $wp_query = new OowpQuery($args); 60 | return $wp_query; 61 | } 62 | 63 | /** 64 | * Select a single post, set globals and throw 404 exception if nothing matches 65 | * @param array $args 66 | * @param bool $redirectCanonical true if should redirect canonically after fetching the post 67 | * @throws RoutemasterException 68 | * @return WordpressPost 69 | */ 70 | public function querySingle($args, $redirectCanonical = false) { 71 | global $post; 72 | 73 | if (current_user_can('edit_posts') && (isset($_GET['preview']) && $_GET['preview'] === 'true')) { 74 | //currently published posts just need this to show the latest autosave instead 75 | $args['preview'] = 'true'; 76 | 77 | //for unpublished posts, override query entirely 78 | if (isset($_GET['p']) || isset($_GET['page_id'])) { 79 | $args = array_intersect_key($_GET, array_flip(array('preview', 'p', 'page_id'))); 80 | } 81 | 82 | //for unpublished posts and posts returned to draft, allow draft status 83 | //for being able to preview edits of existing pages, allow inherit 84 | $args['post_status'] = array( 85 | 'auto-draft', 86 | 'draft', 87 | 'inherit', 88 | 'private', 89 | 'publish', 90 | ); 91 | 92 | $redirectCanonical = false; 93 | } 94 | 95 | $query = $this->query( $args ); 96 | //no matched posts, so first check if this is a logged in user with a private post 97 | if ( ( ! count( $query ) ) && is_user_logged_in() ) { 98 | $args['author'] = get_current_user_id(); 99 | $args['post_status'] = 'private'; 100 | $query = $this->query( $args ); 101 | } 102 | 103 | //no matched posts so 404 104 | if (!count($query)) { 105 | $query->is_404 = true; 106 | throw new RoutemasterException('Not found', 404); 107 | } 108 | 109 | $oowpPost = $query[0]; 110 | $post = $oowpPost->get_post(); 111 | 112 | if ($redirectCanonical) { 113 | $this->redirectCanonical($oowpPost); 114 | } 115 | return $oowpPost; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Oowp/Response/ContainerViewResponse.php: -------------------------------------------------------------------------------- 1 | view = $this->createContainerView($renderable); 21 | } 22 | 23 | protected function createContainerView($view) 24 | { 25 | return new ContainerView($view); 26 | } 27 | 28 | protected function render() 29 | { 30 | if ($this->view) { 31 | foreach ($this->outputArgs as $name=>$value) { 32 | $this->view->$name = $value; 33 | } 34 | $this->view->render(); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/Oowp/Response/ViewResponse.php: -------------------------------------------------------------------------------- 1 | view) { 16 | foreach ($this->outputArgs as $key=>$value) { 17 | $this->view->$key = $value; 18 | } 19 | $this->view->render(); 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/Oowp/View/ContainerView.php: -------------------------------------------------------------------------------- 1 | content = $content; 19 | } 20 | 21 | 22 | public function render($args = []) 23 | { 24 | ?> 25 | 26 | 27 | 28 | 29 | content) { 31 | $this->content->render($args); 32 | } 33 | ?> 34 | 35 | 36 | 13 |
14 |

Page not found

15 | 16 |
17 | pageItems = $pageItems; 19 | } 20 | 21 | 22 | public function render($args = []) 23 | { 24 | echo ''; 25 | ?> 26 | 28 | pageItems as $post) : ?> 29 | 30 | renderPost($post); ?> 31 | 32 | 33 | 34 | 43 | permalink()); ?> 44 | modifiedDate('Y-m-d'); ?> 45 | headers[] = 'Content-Type: ' . $contentType; 15 | 16 | if (!$enableBrowserCache) { 17 | $this->headers[] = "Cache-Control: no-cache, no-store, must-revalidate"; // HTTP 1.1 18 | $this->headers[] = "Pragma: no-cache"; // HTTP 1.0 19 | $this->headers[] = "Expires: 0"; // Proxies 20 | } 21 | 22 | $this->headers[] = 'Content-Disposition: ' . ($asAttachment ? 'attachment' : 'inline') . '; filename=' . $fileName; 23 | 24 | $this->file = $filePath; 25 | } 26 | 27 | protected function render() 28 | { 29 | readfile($this->file); 30 | } 31 | 32 | protected function postRender() 33 | { 34 | parent::postRender(); 35 | 36 | if ($this->deleteOnSent) { 37 | unlink($this->file); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/Response/HtmlResponse.php: -------------------------------------------------------------------------------- 1 | view = $view; 18 | } 19 | 20 | protected function render() 21 | { 22 | $this->view->render(); 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /src/Response/JsonResponse.php: -------------------------------------------------------------------------------- 1 | $outputArgs]; 16 | } 17 | parent::__construct($outputArgs); 18 | $this->headers[] = "access-control-allow-origin: *"; 19 | $this->headers[] = 'Content-type: application/json'; 20 | $this->status = $status; 21 | } 22 | 23 | protected function render() 24 | { 25 | http_response_code($this->status); 26 | 27 | // don't output anything for 'no content' or 'not modified' statuses 28 | if (!$this->outputArgs && in_array($this->status, [204, 304])) { 29 | return; 30 | } 31 | 32 | // zip response if accepted 33 | if (isset($_SERVER['HTTP_ACCEPT_ENCODING']) && substr_count($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip')) { 34 | ob_start('ob_gzhandler'); 35 | } 36 | 37 | $rendered = json_encode($this->outputArgs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 38 | 39 | echo $rendered; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Response/RedirectResponse.php: -------------------------------------------------------------------------------- 1 | headers[] = ['Location: ' . $redirect, true, $status]; 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /src/Response/RobotsResponse.php: -------------------------------------------------------------------------------- 1 | outputArgs = $outputArgs; 15 | } 16 | 17 | /** 18 | * Sets the name of the route that generated this response 19 | * @param string $routeName 20 | */ 21 | public function setRouteName($routeName) 22 | { 23 | $this->routeName = $routeName; 24 | } 25 | 26 | final public function handleRequest() 27 | { 28 | $this->preRender(); 29 | $this->render(); 30 | $this->postRender(); 31 | exit; 32 | } 33 | 34 | protected function preRender() 35 | { 36 | foreach ($this->headers as $header) { 37 | if (is_array($header)) { 38 | header($header[0], $header[1], $header[2]); 39 | } else { 40 | header($header); 41 | } 42 | } 43 | } 44 | 45 | protected function render() 46 | { 47 | /* do nothing by default */ 48 | } 49 | 50 | protected function postRender() 51 | { 52 | /* do nothing by default */ 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Response/XmlResponse.php: -------------------------------------------------------------------------------- 1 | renderable = $renderable; 18 | $this->headers[] = 'Content-Type: application/xml'; 19 | } 20 | 21 | protected function render() 22 | { 23 | $this->renderable->render(); 24 | } 25 | } -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | helper = $helper ?: new RouterHelper(); 30 | 31 | remove_action('wp_head', 'feed_links', 2); 32 | remove_action('wp_head', 'feed_links_extra', 3); 33 | 34 | //remove these built-in WP actions 35 | remove_action('template_redirect', 'wp_old_slug_redirect'); 36 | remove_action('template_redirect', 'redirect_canonical'); 37 | } 38 | 39 | /** 40 | * Initialise the router 41 | */ 42 | public function setup() 43 | { 44 | if (is_admin() || !defined('WP_USE_THEMES')) { 45 | //don't do any routing for admin pages 46 | return; 47 | }elseif (!get_option('permalink_structure')) { 48 | $url = admin_url('options-permalink.php'); 49 | die("Permalinks must be enabled."); 50 | } 51 | 52 | //do routing once WP is fully loaded 53 | add_action('wp_loaded', function() { 54 | $this->route(); 55 | }); 56 | } 57 | 58 | public function setRoutes(array $routes) 59 | { 60 | $this->routes = $routes; 61 | } 62 | 63 | public function addRoute($pattern, $action, $handler = null) 64 | { 65 | if (!$this->routes) { 66 | $this->routes = []; 67 | } 68 | $this->routes[] = new Route($pattern, $action, $handler ?: $this); 69 | } 70 | 71 | /** 72 | * @static 73 | * @return Router Singleton instance 74 | */ 75 | public static function getInstance() 76 | { 77 | if (!isset(self::$instance)) { 78 | self::$instance = new static(); 79 | } 80 | return self::$instance; 81 | } 82 | 83 | /** 84 | * Gets default routes 85 | * Routes are tested in descending order 86 | * @return array Map of regular expressions to method names 87 | */ 88 | protected function getDefaultRoutePatterns() 89 | { 90 | return [ 91 | '|^robots.txt$|' => 'robots', 92 | ]; 93 | } 94 | 95 | /** 96 | * Concatenates the routes in $this->routes with any default route patterns 97 | * @return Route[] 98 | */ 99 | protected function buildRoutes(){ 100 | $routes = []; 101 | if(!empty($this->routes) && is_array($this->routes)){ 102 | $routes = $this->routes; 103 | } 104 | foreach ($this->getDefaultRoutePatterns() as $path => $action) { 105 | $routes[] = new Route($path, $action, $this); 106 | } 107 | return $routes; 108 | } 109 | 110 | /** 111 | * Main workhorse method. 112 | * Attempts to match URI against routes and dispatches routing methods. 113 | * Falls back to 404 if none match. 114 | */ 115 | public function route() 116 | { 117 | global $wp_query; 118 | 119 | //strip base dir and query string from request URI 120 | $base = dirname($_SERVER['SCRIPT_NAME']); 121 | 122 | $requestUri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); 123 | $requestUri = preg_replace("|^$base/?|", '', $requestUri); 124 | $requestUri = ltrim($requestUri, '/'); //ensure left-leading "/" is stripped. 125 | 126 | // jump out now if this is e.g. a wp-json request 127 | if ($this->shouldIgnoreRequest($requestUri)) { 128 | return; 129 | } 130 | 131 | $allRoutes = $this->buildRoutes(); 132 | 133 | $this->requestUri = $requestUri; 134 | $this->_debug = [ 135 | 'routes' => $allRoutes, 136 | 'requestUri' => $this->requestUri 137 | ]; 138 | 139 | //find matching route(s) 140 | $matchingRoutes = []; 141 | foreach ($allRoutes as $route) { 142 | if (preg_match($route->pattern, $this->requestUri, $matches)) { 143 | array_shift($matches); //remove first element 144 | $matchingRoutes[] = [ 145 | 'route' => $route, 146 | 'matches' => $matches 147 | ]; 148 | } 149 | } 150 | 151 | $handled = false; 152 | foreach ($matchingRoutes as $match) { 153 | /** @var Route $route */ 154 | $route = $match['route']; 155 | $matches = $match['matches']; 156 | 157 | $this->_debug['matched_route'] = $route->pattern; 158 | $this->_debug['matched_action'] = $route->actionName; 159 | $this->_debug['matched_handler'] = $route->handler; 160 | $this->_debug['action_parameters'] = $matches; 161 | 162 | try { 163 | $this->dispatch($route->handler, $route->actionName, $matches); 164 | $handled = true; 165 | } catch (RoutemasterException $e) { 166 | if (!isset($this->_debug['dispatch_failures'])) { 167 | $this->_debug['dispatch_failures'] = []; 168 | } 169 | $this->_debug['dispatch_failures'][] = $e; 170 | 171 | if ($e->allowFallback) { 172 | //route failed so reset and continue routing 173 | $wp_query->init(); 174 | } else { 175 | $this->dispatch($this, 'show404'); 176 | $handled = true; 177 | break; 178 | } 179 | } 180 | } 181 | 182 | if (!$handled) { 183 | //no matched route 184 | $wp_query->is_404 = true; 185 | $this->dispatch($this, 'show404'); 186 | } 187 | } 188 | 189 | protected function shouldIgnoreRequest($requestUri) 190 | { 191 | // don't do any routing for wp-json API requests 192 | // (rest_route parameter is used when permalinks are not prettified: https://developer.wordpress.org/rest-api/extending-the-rest-api/routes-and-endpoints/#routes-vs-endpoints) 193 | return strpos($requestUri, 'wp-json') === 0 || strpos($requestUri, 'index.php/wp-json') === 0 || !empty($_GET['rest_route']); 194 | } 195 | 196 | /** 197 | * Runs an action and renders the view. 198 | * @param $handler 199 | * @param string $actionName Action/method to run 200 | * @param array $requestArgs URI parameters 201 | */ 202 | public function dispatch($handler, $actionName, $requestArgs = array()) 203 | { 204 | //call action method 205 | try { 206 | $response = call_user_func_array(array($handler, $actionName), $requestArgs); 207 | } catch (RoutemasterException $ex) { 208 | if ($ex->response) { 209 | $response = $ex->response; 210 | } else { 211 | throw $ex; 212 | } 213 | } 214 | 215 | //allow plugins to hook in after $wp_query is set but before view is rendered 216 | do_action('template_redirect'); 217 | 218 | if (!$response || !($response instanceof RoutemasterResponse)) { 219 | $response = $this->helper->createDefaultResponse($response); 220 | } 221 | 222 | $response->setRouteName($actionName); 223 | $response->handleRequest(); 224 | } 225 | 226 | /** 227 | * Default 404 handler 228 | */ 229 | protected function show404() 230 | { 231 | $response = $this->helper->createNotFoundResponse(); 232 | $response->setRouteName('404'); 233 | $response->headers[] = 'HTTP/1.0 404 Not Found'; 234 | return $response; 235 | } 236 | 237 | /** 238 | * @route /robots.txt 239 | */ 240 | protected function robots() { 241 | return new RobotsResponse(); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/RouterHelper.php: -------------------------------------------------------------------------------- 1 | getRequestMethod() === 'POST'; 36 | } 37 | 38 | public function isGet() { 39 | return $this->getRequestMethod() === 'GET'; 40 | } 41 | 42 | public function isPut() { 43 | return $this->getRequestMethod() === 'PUT'; 44 | } 45 | 46 | public function isDelete() { 47 | return $this->getRequestMethod() === 'DELETE'; 48 | } 49 | 50 | public function getRequestBody() { 51 | $body = file_get_contents('php://input'); 52 | return $body ? json_decode($body) : null; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/View/HtmlPage.php: -------------------------------------------------------------------------------- 1 | title = $title; 12 | $this->content = $content; 13 | } 14 | 15 | public function render($args = []) 16 | { 17 | ?> 18 | 19 | 20 | 21 | My Site :: <?php echo $this->title; ?> 22 | 23 | 24 | 25 | 26 | 27 |

title; ?>

28 |
29 | content; ?> 30 |
31 | 32 | 33 | 34 |