├── CONTRIBUTING.md ├── composer.json ├── README.md └── hm-rewrites.php /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | ## Contribution guidelines ## 3 | 4 | ## Workflow ## 5 | 6 | * Develop on a feature branch and send a pull request for review. 7 | * Assign the pull request to one of the following contacts: 8 | * Primary: Joe Hoyle [@joehoyle](https://github.com/joehoyle) 9 | * Secondary: 10 | 11 | ## Coding Standards ## 12 | 13 | Please follow these recommendations 14 | [http://codex.wordpress.org/WordPress_Coding_Standards](http://codex.wordpress.org/WordPress_Coding_Standards) 15 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"humanmade/hm-rewrite", 3 | "description":"HM_Rewrite is a wrapper for the WordPress WP Rewrite system. http://hmn.md/wordpress-rewrite-rules-hm-core-style/", 4 | "type":"wordpress-muplugin", 5 | "require":{ 6 | "php":">=5.2", 7 | "composer/installers":"~1.0" 8 | }, 9 | "license":"GPL-2.0-or-later", 10 | "authors":[ 11 | { 12 | "name":"Human Made", 13 | "email":"support@humanmade.co.uk", 14 | "homepage":"http://hmn.md/" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | hm-rewrite 2 | ========== 3 | 4 | `HM_Rewrite` and `HM_Rewrite_Rule` are wrappers for the WordPress rewrite / wp_query system. 5 | 6 | The goal of HM_Rewrite and associated fuctions / classes is to make it very easy to add new routing points with new pages (as in dynamic pages, `post_type_archive` etc). It basically wraps a few tasks into a nice API. Everything (almost) you need for setting up a new routing page can be done all at once, relying heavily on PHP Closures. It essentially wraps adding to the `rewrite_rules`, adding your template file to `template_redirect`, `wp_title` hook, `body_class` hook, `parse_query` hook etc. Also also provides some callbacks for conveniance. Each rewrite rule is an instance of `HM_Rewrite_Rule`. Here you add the regex / `wp_query` vars and any other options for the "page". For example a callback function to `parse_request` to add additional query vars, or a callback * `body_class`. There is also a wrapper function for all of this in one call `hm_add_rewrite_rule()`. `hm_add_rewrite_rule()` is generally the recommended interface, you can interact with the underlying objects for more advanced stuff (and also tacking onto other rewrite rules)Simple use case example: 7 | 8 | ```php 9 | hm_add_rewrite_rule( array( 10 | 'regex' => '^users/([^/]+)/?', 11 | 'query' => 'author_name=$matches[1]', 12 | 'template' => 'user-archive.php', 13 | 'body_class_callback' => function( $classes ) { 14 | $classes[] = 'user-archive'; 15 | $classes[] = 'user-' . get_query_var( 'author_name' ); 16 | 17 | return $classes; 18 | }, 19 | 'title_callback' => function( $title, $seperator ) { 20 | return get_query_var( 'author_name' ) . ' ' . $seperator . ' ' . $title; 21 | } 22 | ) ); 23 | ``` 24 | 25 | A more advanced example using more callbacks: 26 | 27 | ```php 28 | hm_add_rewrite_rule( array( 29 | 'regex' => '^reviews/([^/]+)/?', // a review category page 30 | 'query' => 'review_category=$matches[1]', 31 | 'template' => 'review-category.php', 32 | 'request_callback' => function( WP $wp ) { 33 | // if the review category is "laptops" then only show items in draft 34 | if ( $wp->query_vars['review_category'] == 'laptops' ) 35 | $wp->query_vars['post_status'] = 'draft'; 36 | }, 37 | 'query_callback' => function( WP_Query $query ) { 38 | //overwrite is_home because WordPress gets it wrong here 39 | $query->is_home = false; 40 | }, 41 | 'body_class_callback' => function( $classes ) { 42 | $classes[] = get_query_var( 'review_category' ); 43 | return $classes; 44 | }, 45 | 'title_callback' => function( $title, $seperator ) { 46 | return review_category . ' ' . $seperator . ' ' . $title; 47 | }, 48 | 'rewrite_tests_callback' => function() { 49 | return array( 50 | 'Review Category' => array( 51 | '/reviews/foo/', 52 | '/reviews/bar/', 53 | ), 54 | ); 55 | } 56 | ) ); 57 | ``` 58 | 59 | ## Contribution guidelines ## 60 | 61 | see https://github.com/humanmade/hm-rewrite/blob/master/CONTRIBUTING.md 62 | 63 | -------------------------------------------------------------------------------- /hm-rewrites.php: -------------------------------------------------------------------------------- 1 | id == $id_or_object ) 51 | unset( $rules[$key] ); 52 | } ); 53 | 54 | } 55 | 56 | /** 57 | * Get the rewrite rule for a given id (regex or id if specificed in the rule) 58 | * 59 | * @param string $id 60 | * @return HM_Rewrite_Rule 61 | */ 62 | public static function get_rule( $id ) { 63 | 64 | $rule = array_filter( self::$rules, function( HM_Rewrite_Rule $rule ) use ( $id ) { 65 | return $rule->id == $id; 66 | } ); 67 | 68 | return reset( $rule ); 69 | 70 | } 71 | 72 | public static function get_rule_by_regex( $regex ) { 73 | 74 | foreach ( self::$rules as $rule ) { 75 | if ( $rule->regex === $regex ) { 76 | return $rule; 77 | } 78 | } 79 | } 80 | 81 | /** 82 | * Called when a regex is matched on page load, will call the rewrite rule responsible for it (if any) 83 | * 84 | * @param string $regex 85 | */ 86 | public static function matched_regex( $regex ) { 87 | 88 | array_walk( self::$rules, function( HM_Rewrite_Rule $rule ) use ( $regex ) { 89 | if ( $rule->get_regex() == $regex ) { 90 | HM_Rewrite::$matched_rule = $rule; 91 | $rule->matched_rule(); 92 | } 93 | } ); 94 | } 95 | 96 | } 97 | 98 | class HM_Rewrite_Rule { 99 | 100 | public $id = ''; 101 | public $regex = ''; 102 | public $query_args = ''; 103 | public $request_callbacks = array(); 104 | public $query_callbacks = array(); 105 | public $title_callbacks = array(); 106 | public $parse_query_callbacks = array(); 107 | public $body_class_callbacks = array(); 108 | public $admin_bar_callbacks = array(); 109 | public $template = ''; 110 | public $access_rule = ''; 111 | public $request_methods = array(); 112 | public $rewrite_tests_callback = null; 113 | public $disable_canonical = false; 114 | 115 | public function __construct( $regex, $id = null ) { 116 | 117 | $this->regex = $regex; 118 | $this->id = $id ? $id : $regex; 119 | } 120 | 121 | /** 122 | * Get the regex for the rewrite rule 123 | * 124 | * @return string 125 | */ 126 | public function get_regex() { 127 | 128 | if ( substr( $this->regex, 0, 1 ) !== substr( $this->regex, strlen( $this->regex ) - 1 ) ) 129 | return $this->regex; 130 | 131 | return $this->regex; 132 | } 133 | 134 | public function set_wp_query_args( $vars ) { 135 | if ( ! is_string( $vars ) ) 136 | throw new Exception( 'set_wp_query_args currently only supports accepting a string.' ); 137 | 138 | $this->query_args = $vars; 139 | } 140 | 141 | /** 142 | * Get the WP_Query args for this rule 143 | * @return array 144 | */ 145 | public function get_wp_query_args() { 146 | return $this->query_args; 147 | } 148 | 149 | public function get_public_query_var_exports() { 150 | 151 | return array_keys( wp_parse_args( $this->get_wp_query_args() ) ); 152 | } 153 | 154 | /** 155 | * @param callable $callback 156 | * @return void 157 | */ 158 | public function set_rewrite_tests_callback( $callback ) { 159 | $this->rewrite_tests_callback = $callback; 160 | } 161 | 162 | /** 163 | * @return array 164 | */ 165 | public function get_rewrite_tests() { 166 | if ( null === $this->rewrite_tests_callback ) { 167 | return array(); 168 | } 169 | 170 | return call_user_func( $this->rewrite_tests_callback ); 171 | } 172 | 173 | /** 174 | * Called when this rule is matched for the page load 175 | * 176 | */ 177 | public function matched_rule() { 178 | 179 | global $wp; 180 | 181 | // check request methods match 182 | if ( $this->request_methods && ! in_array( strtolower( $_SERVER['REQUEST_METHOD'] ), $this->request_methods ) ) { 183 | header( 'HTTP/1.1 403 Forbidden' ); 184 | exit; 185 | } 186 | 187 | do_action( 'hm_parse_request_' . $this->get_regex(), $wp ); 188 | 189 | $bail = false; 190 | foreach ( $this->request_callbacks as $callback ) { 191 | $return = call_user_func_array( $callback, array( $wp, $this ) ); 192 | 193 | // Avoid counting `null`/no return as an error 194 | $bail |= ( $return === false ); 195 | } 196 | 197 | // If a callback returned false, bail from the request 198 | if ( $bail ) 199 | return; 200 | 201 | $t = $this; 202 | 203 | add_action( 'template_redirect', function() use ( $t ) { 204 | 205 | /* @var WP_Query $wp_query */ 206 | global $wp_query; 207 | 208 | foreach ( $t->query_callbacks as $callback ) 209 | call_user_func_array( $callback, array( $wp_query, $t ) ); 210 | 211 | }, 1 ); 212 | 213 | // set up the hooks for everything 214 | add_action( 'template_redirect', function() use ( $t ) { 215 | 216 | /* @var WP_Query $wp_query */ 217 | global $wp_query; 218 | 219 | // check permissions 220 | $permission = $t->access_rule; 221 | $redirect = ''; 222 | 223 | switch ( $permission ) { 224 | 225 | case 'logged_out_only' : 226 | 227 | $redirect = is_user_logged_in(); 228 | 229 | break; 230 | 231 | case 'logged_in_only' : 232 | 233 | $redirect = ! is_user_logged_in(); 234 | 235 | break; 236 | 237 | case 'displayed_user_only' : 238 | $redirect = ! is_user_logged_in() || get_query_var( 'author' ) != get_current_user_id(); 239 | 240 | break; 241 | } 242 | 243 | if ( $redirect ) { 244 | 245 | $redirect = home_url( '/' ); 246 | 247 | // If there is a "redirect_to" redirect there 248 | if ( ! empty( $_REQUEST['redirect_to'] ) ) 249 | $redirect = hm_parse_redirect( urldecode( esc_url( $_REQUEST['redirect_to'] ) ) ); 250 | 251 | wp_redirect( $redirect ); 252 | 253 | exit; 254 | } 255 | 256 | if ( $t->template ) { 257 | if ( ! $t->get_wp_query_args() && $wp_query->is_404() ) { 258 | include( get_404_template() ); 259 | } else if ( is_file( $t->template ) ) { 260 | include( $t->template ); 261 | } else{ 262 | locate_template( $t->template, true ); 263 | } 264 | exit; 265 | } 266 | }); 267 | 268 | add_filter( 'parse_query', $closure = function( WP_Query $query ) use ( $t, &$closure ) { 269 | 270 | // only run this hook once 271 | remove_filter( 'parse_query', $closure ); 272 | 273 | foreach ( $t->parse_query_callbacks as $callback ) 274 | call_user_func_array( $callback, array( $query ) ); 275 | } ); 276 | 277 | add_filter( 'redirect_canonical', function( $redirect_to ) use ( $t ) { 278 | if ( $t->disable_canonical ) 279 | return null; 280 | 281 | return $redirect_to; 282 | }); 283 | 284 | add_filter( 'body_class', function( $classes ) use ( $t ) { 285 | 286 | foreach ( $t->body_class_callbacks as $callback ) 287 | $classes = call_user_func_array( $callback, array( $classes ) ); 288 | 289 | return $classes; 290 | } ); 291 | 292 | /** 293 | * Add support for theme support title tag 294 | * @since 4.4.0 295 | */ 296 | if ( current_theme_supports( 'title-tag' ) ) { 297 | 298 | add_filter( 'pre_get_document_title', function ( $title ) use ( $t ) { 299 | 300 | $sep = apply_filters( 'document_title_separator', '-' ); 301 | 302 | foreach ( $t->title_callbacks as $callback ) { 303 | $title = call_user_func_array( $callback, array( $title, $sep ) ); 304 | } 305 | 306 | return $title; 307 | 308 | }, 20 ); 309 | 310 | } else { 311 | add_filter( 'wp_title', function ( $title, $sep = '' ) use ( $t ) { 312 | 313 | foreach ( $t->title_callbacks as $callback ) { 314 | $title = call_user_func_array( $callback, array( $title, $sep ) ); 315 | } 316 | 317 | return $title; 318 | 319 | }, 20, 2 ); 320 | 321 | } 322 | 323 | add_action( 'admin_bar_menu', function() use ( $t ) { 324 | global $wp_admin_bar; 325 | 326 | foreach ( $t->admin_bar_callbacks as $callback ) 327 | $title = call_user_func_array( $callback, array( $wp_admin_bar ) ); 328 | } ); 329 | } 330 | 331 | /** 332 | * Set the template file to render this request 333 | * 334 | * @param string $template 335 | */ 336 | public function set_template( $template ) { 337 | 338 | $this->template = $template; 339 | } 340 | 341 | public function set_access_rule( $rule ) { 342 | $this->access_rule = $rule; 343 | } 344 | 345 | /** 346 | * Add a callback for when the request is mached 347 | * 348 | * The callback will be called with the WP object 349 | * 350 | * @param function $callback 351 | */ 352 | public function add_request_callback( $callback ) { 353 | $this->request_callbacks[] = $callback; 354 | } 355 | 356 | /** 357 | * Add a callback for when the WP_Query's parse_query is fired 358 | * 359 | * The callback will be called with the WP_Query object, before the query is done, after parse_args 360 | * 361 | * @param function $callback 362 | */ 363 | public function add_parse_query_callback( $callback ) { 364 | $this->parse_query_callbacks[] = $callback; 365 | } 366 | 367 | /** 368 | * Add a callback for when the WP_Query is being set up 369 | * 370 | * The callback will be called with the WP_Query object, after the query is done, after parse_args 371 | * 372 | * @param function $callback 373 | */ 374 | public function add_query_callback( $callback ) { 375 | $this->query_callbacks[] = $callback; 376 | } 377 | 378 | /** 379 | * Add a callback for when the wp_title hook is fired 380 | * 381 | * The callback will be called with the title of the page 382 | * 383 | * @param function $callback 384 | */ 385 | public function add_title_callback( $callback ) { 386 | $this->title_callbacks[] = $callback; 387 | } 388 | 389 | /** 390 | * Add a callback for when the body_class hook is fired 391 | * 392 | * The callback will be called with the classes added so far 393 | * 394 | * @param function $callback 395 | */ 396 | public function add_body_class_callback( $callback ) { 397 | $this->body_class_callbacks[] = $callback; 398 | } 399 | 400 | /** 401 | * Add a callback for the admin_bar hooks fire 402 | * 403 | * @param function $callback 404 | */ 405 | public function add_admin_bar_callback( $callback ) { 406 | $this->admin_bar_callbacks[] = $callback; 407 | } 408 | 409 | /** 410 | * Set the request methods, e.g. PUT/POST 411 | */ 412 | public function set_request_methods( $methods ) { 413 | $this->request_methods = array_map( 'strtolower', $methods ); 414 | } 415 | } 416 | 417 | /** 418 | * The main wrapper function for the HM_Rewrite API. Use this to add new rewrite rules 419 | * 420 | * Create a new rewrite with the arguments listed below. The only required argument is 'regex' 421 | * 422 | * @param string $regex The rewrite regex, start / end delimter not required. Eg: '^people/([^/]+)/?' 423 | * @param string $query The WP_Query args to be used on this "page" 424 | * @param string $template The template file used to render the request. Not required, will use 425 | * the template file for the WP_Query if not set. Relative to template_directory() or absolute. 426 | * @param string $body_class A class to be added to body_class in the rendered template 427 | * @param function $body_class_callback A callback that will be hooked into body_class 428 | * @param function $request_callback A callback that will be called as soon as the request matching teh regex is hit. 429 | * called with the WP object 430 | * @param function $parse_query_callback A callback taht will be hooked into 'parse_query'. Use this to modify query vars 431 | * in the main WP_Query 432 | * @param function $title_callback A callback that will be hooked into wp_title 433 | * @param function $query_callback A callback taht will be called once the WP_Query has finished. Use to overrite any 434 | * annoying is_404, is_home etc that you custom query may not match to. 435 | * @param function $access_rule An access rule for restriciton to logged-in-users only for example. 436 | * @param function $rewrite_tests_callback A callback that returns an array of paths to test with the Rewrite Rule Testing plugin. 437 | * @param array $request_methods An array of request methods, e.g. PUT, POST 438 | */ 439 | function hm_add_rewrite_rule( $args = array() ) { 440 | if ( count( func_get_args() ) > 1 ) { 441 | trigger_error( 442 | 'Passing individual arguments to hm_add_rewrite_rule() is no longer supported', 443 | E_USER_WARNING 444 | ); 445 | return; 446 | } 447 | 448 | if ( ! empty( $args['rewrite'] ) ) 449 | $args['regex'] = $args['rewrite']; 450 | 451 | $regex = $args['regex']; 452 | 453 | $rule = new HM_Rewrite_Rule( $regex, isset( $args['id'] ) ? $args['id'] : null ); 454 | 455 | if ( ! empty( $args['template'] ) ) 456 | $rule->set_template( $args['template'] ); 457 | 458 | if ( ! empty( $args['body_class_callback'] ) ) 459 | $rule->add_body_class_callback( $args['body_class_callback'] ); 460 | 461 | if ( ! empty( $args['request_callback'] ) ) 462 | $rule->add_request_callback( $args['request_callback'] ); 463 | 464 | if ( ! empty( $args['parse_query_callback'] ) ) 465 | $rule->add_parse_query_callback( $args['parse_query_callback'] ); 466 | 467 | if ( ! empty( $args['title_callback'] ) ) 468 | $rule->add_title_callback( $args['title_callback'] ); 469 | 470 | if ( ! empty( $args['query_callback'] ) ) 471 | $rule->add_query_callback( $args['query_callback'] ); 472 | 473 | if ( ! empty( $args['access_rule'] ) ) 474 | $rule->set_access_rule( $args['access_rule'] ); 475 | 476 | if ( ! empty( $args['query'] ) ) 477 | $rule->set_wp_query_args( $args['query'] ); 478 | 479 | if ( ! empty( $args['rewrite_tests_callback'] ) ) 480 | $rule->set_rewrite_tests_callback( $args['rewrite_tests_callback'] ); 481 | 482 | if ( ! empty( $args['permission'] ) ) 483 | $rule->set_access_rule( $args['permission'] ); 484 | 485 | if ( ! empty( $args['admin_bar_callback'] ) ) 486 | $rule->add_admin_bar_callback( $args['admin_bar_callback'] ); 487 | 488 | if ( ! empty( $args['disable_canonical'] ) ) 489 | $rule->disable_canonical = true; 490 | 491 | // some convenenience properties. These are done here because they are not very nice 492 | if ( ! empty( $args['body_class'] ) ) 493 | $rule->add_body_class_callback( function( $classes ) use ( $args ) { 494 | $classes[] = $args['body_class']; 495 | return $classes; 496 | } ); 497 | 498 | if ( ! empty( $args['parse_query_properties'] ) ) 499 | $rule->add_parse_query_callback( function( WP_Query $query ) use ( $args ) { 500 | $args['parse_query_properties'] = wp_parse_args( $args['parse_query_properties'] ); 501 | foreach ( $args['parse_query_properties'] as $property => $value ) 502 | $query->$property = $value; 503 | 504 | } ); 505 | 506 | if ( ! empty( $args['post_query_properties'] ) ) 507 | $rule->add_query_callback( function( WP_Query $query ) use ( $args ) { 508 | $args['post_query_properties'] = wp_parse_args( $args['post_query_properties'] ); 509 | 510 | foreach ( $args['post_query_properties'] as $property => $value ) 511 | $query->$property = $value; 512 | 513 | } ); 514 | 515 | if ( ! empty( $args['request_methods'] ) ) { 516 | $rule->set_request_methods( $args['request_methods'] ); 517 | } 518 | 519 | if ( ! empty( $args['request_method'] ) ) { 520 | $rule->set_request_methods( array( $args['request_method'] ) ); 521 | } 522 | 523 | HM_Rewrite::add_rule( $rule ); 524 | } 525 | 526 | 527 | /** 528 | * Add the custom rewrite rules to the main 529 | * rewrite rules array 530 | * 531 | * @param array $rules 532 | * @return array $rules 533 | */ 534 | add_filter( 'rewrite_rules_array', function( $rules ) { 535 | 536 | $new_rules = array(); 537 | 538 | foreach ( HM_Rewrite::get_rules() as $rule ) 539 | $new_rules[$rule->get_regex()] = $rule->get_wp_query_args(); 540 | 541 | return array_merge( $new_rules, $rules ); 542 | 543 | } ); 544 | 545 | /** 546 | * Set the current rewrite rule 547 | * 548 | * @param object $request 549 | * @return null 550 | */ 551 | add_filter( 'parse_request', function( WP $request ) { 552 | 553 | $matched_regex = $request->matched_rule; 554 | 555 | HM_Rewrite::matched_regex( $matched_regex ); 556 | 557 | } ); 558 | 559 | /** 560 | * Add custom query vars from all rewrite rules automatically 561 | */ 562 | add_filter( 'query_vars', function( $query_vars ) { 563 | 564 | $new_vars = array(); 565 | 566 | foreach ( HM_Rewrite::get_rules() as $rule ) 567 | $query_vars = array_merge( $rule->get_public_query_var_exports(), $query_vars ); 568 | 569 | 570 | return $query_vars; 571 | 572 | } ); 573 | 574 | /** 575 | * Add rewrite tests to integrate with the Rewrite Rule Testing plugin. 576 | */ 577 | add_filter( 'rewrite_testing_tests', function( array $tests ) { 578 | foreach ( HM_Rewrite::get_rules() as $rule ) { 579 | $rule_tests = $rule->get_rewrite_tests(); 580 | 581 | if ( ! $rule_tests ) { 582 | continue; 583 | } 584 | 585 | $args = $rule->get_wp_query_args(); 586 | 587 | foreach ( $rule_tests as $group => $group_tests ) { 588 | if ( ! isset( $tests[ $group ] ) ) { 589 | $tests[ $group ] = array(); 590 | } 591 | 592 | foreach ( $group_tests as $rule_test ) { 593 | $tests[ $group ][ $rule_test ] = $args; 594 | } 595 | } 596 | } 597 | 598 | return $tests; 599 | } ); 600 | 601 | /** 602 | * Flush rewrite rules, without deleting the option 603 | * 604 | * Uses `update_option` without first using `delete_option`, allowing it to run 605 | * on every request. 606 | */ 607 | function hm_rewrite_flush() { 608 | do_action( 'hm_rewrite_before_flush' ); 609 | 610 | global $wp_rewrite; 611 | $wp_rewrite->matches = 'matches'; 612 | $rules = $wp_rewrite->rewrite_rules( true ); 613 | 614 | if ( update_option( 'rewrite_rules', $rules ) ) { 615 | do_action( 'hm_rewrite_flushed', $rules ); 616 | } 617 | else { 618 | do_action( 'hm_rewrite_cached', $rules ); 619 | } 620 | } 621 | 622 | if ( HM_REWRITE_AUTOFLUSH ) { 623 | /** 624 | * Automatically flush rewrite rules when they're changed 625 | */ 626 | add_action( 'wp_loaded', 'hm_rewrite_flush', 9999 ); 627 | } 628 | 629 | if ( ! function_exists( 'hm_parse_redirect' ) ) { 630 | /** 631 | * Parse the redirect string and replace _user_login_ with 632 | * the users login. 633 | * 634 | * @param string $redirect 635 | * @return string 636 | */ 637 | function hm_parse_redirect( $redirect ) { 638 | 639 | if ( is_user_logged_in() ) 640 | $redirect = str_replace( '_user_login_', wp_get_current_user()->user_login, $redirect ); 641 | 642 | $redirect = wp_sanitize_redirect( $redirect ); 643 | $redirect = wp_validate_redirect( $redirect, home_url() ); 644 | 645 | return apply_filters( 'hm_parse_login_redirect', $redirect ); 646 | } 647 | } 648 | --------------------------------------------------------------------------------