├── src
├── View
│ ├── Renderable.php
│ └── HtmlPage.php
├── Response
│ ├── RobotsResponse.php
│ ├── RedirectResponse.php
│ ├── HtmlResponse.php
│ ├── XmlResponse.php
│ ├── RoutemasterResponse.php
│ ├── FileResponse.php
│ └── JsonResponse.php
├── Oowp
│ ├── View
│ │ ├── RoutemasterOowpView.php
│ │ ├── NotFoundView.php
│ │ ├── ContainerView.php
│ │ └── SitemapView.php
│ ├── Response
│ │ ├── ViewResponse.php
│ │ └── ContainerViewResponse.php
│ ├── OowpRouterHelper.php
│ └── OowpRouter.php
├── Exception
│ ├── NoFallbackException.php
│ └── RoutemasterException.php
├── Model
│ └── Route.php
├── RouterHelper.php
└── Router.php
├── .gitignore
├── readme.txt
├── composer.json
├── routemaster.php
└── README.md
/src/View/Renderable.php:
--------------------------------------------------------------------------------
1 | headers[] = ['Location: ' . $redirect, true, $status];
11 | }
12 |
13 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/Exception/NoFallbackException.php:
--------------------------------------------------------------------------------
1 | allowFallback = false;
10 | }
11 | }
--------------------------------------------------------------------------------
/src/Oowp/View/NotFoundView.php:
--------------------------------------------------------------------------------
1 |
13 |
14 |
Page not found
15 |
16 |
17 | pattern = $pattern;
19 | $this->actionName = $actionName;
20 | $this->handler = $handler;
21 | }
22 |
23 |
24 | }
--------------------------------------------------------------------------------
/src/Response/HtmlResponse.php:
--------------------------------------------------------------------------------
1 | view = $view;
18 | }
19 |
20 | protected function render()
21 | {
22 | $this->view->render();
23 | }
24 |
25 | }
--------------------------------------------------------------------------------
/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/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/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 | =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 |
--------------------------------------------------------------------------------
/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 :: title; ?>
22 |
23 |
24 |
25 |
26 |
27 | title; ?>
28 |
29 | content; ?>
30 |
31 |
32 |
33 |
34 | 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/Response/RoutemasterResponse.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/FileResponse.php:
--------------------------------------------------------------------------------
1 | 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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/Oowp/View/SitemapView.php:
--------------------------------------------------------------------------------
1 | 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 | 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/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/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/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 |
--------------------------------------------------------------------------------