From d434e33f56b478987961b5bece7e62987864425d Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 9 Nov 2026 13:45:28 +1100 Subject: [PATCH 01/20] Tests for additional random rewrites. --- .../tests/query/randomContentRedirect.php | 220 +++++++ .../tests/rewrite/randomRewriteRules.php | 571 ++++++++++++++++++ 2 files changed, 791 insertions(+) diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index 4f1c06c8fcea6..94d4b7f119270 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -96,6 +96,25 @@ class Tests_Query_RandomContentRedirect extends WP_UnitTestCase { */ protected $recording_queries = false; + /** + * Rewrite rules added to the top of the rules before each test. + * + * WP_Rewrite::init() does not reset these, so they are restored after each test. + * + * @var array + */ + protected $original_extra_rules_top = array(); + + /** + * Extra permalink structures registered before each test. + * + * Unregistering post types between tests does not remove their permalink structures, + * so they are restored after each test. + * + * @var array> + */ + protected $original_extra_permastructs = array(); + /** * Creates shared user, category and post fixtures. * @@ -140,8 +159,13 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void * Sets up pretty permalinks and redirect capturing before each test. */ public function set_up(): void { + global $wp_rewrite; + parent::set_up(); + $this->original_extra_rules_top = $wp_rewrite->extra_rules_top; + $this->original_extra_permastructs = $wp_rewrite->extra_permastructs; + $this->set_permalink_structure( '/%postname%/' ); // Register the taxonomy rewrite rules for the permalink structure. create_initial_taxonomies(); @@ -160,6 +184,11 @@ public function set_up(): void { * Unregisters the test post types and taxonomy after each test. */ public function tear_down(): void { + global $wp_rewrite; + + $wp_rewrite->extra_rules_top = $this->original_extra_rules_top; + $wp_rewrite->extra_permastructs = $this->original_extra_permastructs; + unregister_post_type( 'wptests_book' ); unregister_post_type( 'wptests_hidden' ); unregister_post_type( 'wptests_unsearchable' ); @@ -2703,4 +2732,195 @@ public function test_unpublishing_a_post_named_random_resumes_random_content_red $this->assertRedirectedToOneOf( array( self::$arts_post, self::$sport_post ), 'Random content redirects should resume when the post named random is unpublished.' ); } + + /** + * Ensure a post type archive at the random base is not redirected to random content. + * + * @dataProvider data_post_type_archive_at_the_random_base_is_not_redirected + * + * @param string $post_type Post type name. + * @param array $args Post type registration arguments. + */ + public function test_post_type_archive_at_the_random_base_is_not_redirected( string $post_type, array $args ): void { + register_post_type( $post_type, $args ); + flush_rewrite_rules(); + + $this->create_post( array( 'post_type' => $post_type ) ); + + $this->go_to( home_url( '/random/' ) ); + + $this->assertNotRedirected( 'The post type archive at the random base should not redirect to random content.' ); + $this->assertQueryTrue( 'is_archive', 'is_post_type_archive' ); + $this->assertSame( $post_type, get_query_var( 'post_type' ), 'The post type archive at the random base should be queried.' ); + } + + /** + * Data provider. + * + * @return array}> + */ + public static function data_post_type_archive_at_the_random_base_is_not_redirected(): array { + return array( + 'post type named random' => array( + 'random', + array( + 'public' => true, + 'has_archive' => true, + ), + ), + 'archive slug random' => array( + 'wptests_random', + array( + 'public' => true, + 'has_archive' => 'random', + ), + ), + ); + } + + /** + * Ensure random archives redirect when a post type archive is at the random base. + */ + public function test_random_archives_redirect_when_a_post_type_archive_is_at_the_random_base(): void { + register_post_type( + 'wptests_random', + array( + 'public' => true, + 'has_archive' => 'random', + ) + ); + flush_rewrite_rules(); + + $this->go_to( home_url( '/random/category/arts/' ) ); + + $this->assertSame( get_permalink( self::$arts_post ), $this->redirect_location, 'Random archives should redirect when a post type archive is at the random base.' ); + } + + /** + * Ensure the random base is not redirected to random content when an existing rule takes it. + * + * @dataProvider data_random_base_is_not_redirected_when_an_existing_rule_takes_it + * + * @param string $regex Rewrite rule regex. + */ + public function test_random_base_is_not_redirected_when_an_existing_rule_takes_it( string $regex ): void { + $page = $this->create_post( + array( + 'post_type' => 'page', + 'post_name' => 'wptests-taken', + ) + ); + + add_rewrite_rule( $regex, 'index.php?pagename=wptests-taken', 'top' ); + flush_rewrite_rules(); + + $this->go_to( home_url( '/random/' ) ); + + $this->assertNotRedirected( "The random base should not redirect to random content when the rule {$regex} takes it." ); + $this->assertSame( $page, get_queried_object_id(), "The page served by the rule {$regex} should be queried." ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_random_base_is_not_redirected_when_an_existing_rule_takes_it(): array { + return array( + 'exact match' => array( 'random/?$' ), + 'anchored exact match' => array( '^random/?$' ), + 'alternation' => array( '(random|pens|pencils)/?$' ), + 'partial group' => array( '(rand)om/?$' ), + 'character class' => array( '[r]andom/?$' ), + 'optional suffix' => array( 'random(?:-ful)?/?$' ), + ); + } + + /** + * Ensure random archives redirect when an existing rule takes the random base. + */ + public function test_random_archives_redirect_when_an_existing_rule_takes_the_random_base(): void { + add_rewrite_rule( '^random/?$', 'index.php?pagename=wptests-taken', 'top' ); + flush_rewrite_rules(); + + $this->go_to( home_url( '/random/category/arts/' ) ); + + $this->assertSame( get_permalink( self::$arts_post ), $this->redirect_location, 'Random archives should redirect when an existing rule takes the random base.' ); + } + + /** + * Ensure the random base redirects to random content when existing rules do not take it. + * + * @dataProvider data_random_base_redirects_when_existing_rules_do_not_take_it + * + * @param string $regex Rewrite rule regex. + * @param string $position Position of the rewrite rule, either 'top' or 'bottom'. + */ + public function test_random_base_redirects_when_existing_rules_do_not_take_it( string $regex, string $position ): void { + add_rewrite_rule( $regex, 'index.php?pagename=wptests-not-taken', $position ); + flush_rewrite_rules(); + + $this->go_to( home_url( '/random/' ) ); + + $this->assertRedirectedToOneOf( array( self::$arts_post, self::$sport_post ), "The random base should redirect to random content when the rule {$regex} does not take it." ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_random_base_redirects_when_existing_rules_do_not_take_it(): array { + return array( + 'suffixed slug' => array( 'randomful/?$', 'top' ), + 'hyphenated suffixed slug' => array( '^random-ful/?$', 'top' ), + 'similar alternation' => array( '(randomful|random-ful)/?$', 'top' ), + 'sub path' => array( '^random/wptests/?$', 'top' ), + 'requires a sub path' => array( '^random/(.*)?', 'top' ), + 'catch-all' => array( '(.+?)/?$', 'bottom' ), + 'single segment catch-all' => array( '([^/]+)/?$', 'bottom' ), + ); + } + + /** + * Ensure a category at the random base with the category base removed is not redirected to random content. + * + * This replicates plugins that remove the category base from category archive URLs. + */ + public function test_category_at_the_random_base_without_a_category_base_is_not_redirected(): void { + $category = self::factory()->category->create( array( 'slug' => 'random' ) ); + + add_rewrite_rule( '(random)/?$', 'index.php?category_name=$matches[1]', 'top' ); + flush_rewrite_rules(); + + $this->go_to( home_url( '/random/' ) ); + + $this->assertNotRedirected( 'A category at the random base should not redirect to random content.' ); + $this->assertQueryTrue( 'is_archive', 'is_category' ); + $this->assertSame( $category, get_queried_object_id(), 'The category at the random base should be queried.' ); + } + + /** + * Ensure the random base is not redirected to random content when filtered as taken. + */ + public function test_random_base_is_not_redirected_when_filtered_as_taken(): void { + add_filter( 'wp_rewrite_random_base_is_taken', '__return_true' ); + flush_rewrite_rules(); + + $this->go_to( home_url( '/random/' ) ); + + $this->assertNotRedirected( 'The random base should not redirect to random content when filtered as taken.' ); + } + + /** + * Ensure random archives redirect when the random base is filtered as taken. + */ + public function test_random_archives_redirect_when_the_random_base_is_filtered_as_taken(): void { + add_filter( 'wp_rewrite_random_base_is_taken', '__return_true' ); + flush_rewrite_rules(); + + $this->go_to( home_url( '/random/category/arts/' ) ); + + $this->assertSame( get_permalink( self::$arts_post ), $this->redirect_location, 'Random archives should redirect when the random base is filtered as taken.' ); + } } diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index 47e828489df0b..7aa9ca8603b33 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -11,9 +11,33 @@ */ class Tests_Rewrite_RandomRewriteRules extends WP_UnitTestCase { + /** + * Rewrite rules added to the top of the rules before each test. + * + * WP_Rewrite::init() does not reset these, so they are restored after each test. + * + * @var array + */ + private $original_extra_rules_top = array(); + + /** + * Extra permalink structures registered before each test. + * + * Unregistering post types between tests does not remove their permalink structures, + * so they are restored after each test. + * + * @var array> + */ + private $original_extra_permastructs = array(); + public function set_up(): void { + global $wp_rewrite; + parent::set_up(); + $this->original_extra_rules_top = $wp_rewrite->extra_rules_top; + $this->original_extra_permastructs = $wp_rewrite->extra_permastructs; + // Prevent random content redirects from exiting, only the rewrite rules are under test. add_filter( 'wp_redirect', '__return_false' ); @@ -23,6 +47,11 @@ public function set_up(): void { } public function tear_down(): void { + global $wp_rewrite; + + $wp_rewrite->extra_rules_top = $this->original_extra_rules_top; + $wp_rewrite->extra_permastructs = $this->original_extra_permastructs; + /* * The parent class re-initializes the rewrite base from this option before the * database transaction is rolled back, so it needs to be removed first. @@ -1027,4 +1056,546 @@ public static function data_posts_that_do_not_conflict(): array { ), ); } + + /** + * Ensure the root rule is kept when the rules are regenerated. + * + * Once generated, the root rule is in the existing rules and must not be treated + * as taking the random base itself. + * + * @global WP_Rewrite $wp_rewrite WordPress rewrite component. + */ + public function test_random_root_rule_is_kept_when_the_rules_are_regenerated(): void { + global $wp_rewrite; + + $this->get_random_rewrite_rules(); + $this->assertArrayHasKey( 'random/?$', $wp_rewrite->rules, 'The root rule should be in the generated rules.' ); + + $rules = $wp_rewrite->random_rewrite_rules(); + + $this->assertArrayHasKey( 'random/?$', $rules, 'The root rule should be kept when the rules are regenerated.' ); + } + + /** + * Ensure the root rule is omitted when an existing rule takes the random base. + * + * @dataProvider data_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base + * + * @param string $regex Rewrite rule regex. + */ + public function test_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base( string $regex ): void { + add_rewrite_rule( $regex, 'index.php?pagename=wptests-taken', 'top' ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayNotHasKey( 'random/?$', $rules, "The root rule should be omitted when the rule {$regex} takes the random base." ); + $this->assertArrayHasKey( 'random/category/(.+?)/?$', $rules, 'Random archive rules should be kept when the random base is taken.' ); + $this->assertArrayNotHasKey( 'random/?$', $this->filter_random_rules( $this->get_stored_rewrite_rules() ), 'The stored rules should not include the root rule.' ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base(): array { + return array( + 'exact match' => array( 'random/?$' ), + 'anchored exact match' => array( '^random/?$' ), + 'unterminated' => array( 'random' ), + 'optional suffix' => array( 'random(ful)?/?$' ), + 'optional non-capturing suffix' => array( 'random(?:-ful)?/?$' ), + 'anchored optional suffix' => array( '^random(?:-ful)?/?$' ), + 'alternation, first' => array( '(random|pens|pencils)/?$' ), + 'alternation, last' => array( '(pens|pencils|random)/?$' ), + 'anchored alternation' => array( '^(random|pens|pencils)/?$' ), + 'non-capturing alternation' => array( '(?:pens|random)/?$' ), + 'partial group' => array( '(rand)om/?$' ), + 'anchored partial group' => array( '^(rand)om/?$' ), + 'group in the middle' => array( 'r(and)om/?$' ), + 'character class' => array( '[r]andom/?$' ), + 'character class range' => array( '[a-z]andom/?$' ), + 'wildcard' => array( '.andom/?$' ), + 'optional final character' => array( 'random?/?$' ), + ); + } + + /** + * Ensure the root rule is kept when existing rules do not take the random base. + * + * @dataProvider data_random_root_rule_is_kept_when_existing_rules_do_not_take_the_random_base + * + * @param string $regex Rewrite rule regex. + */ + public function test_random_root_rule_is_kept_when_existing_rules_do_not_take_the_random_base( string $regex ): void { + add_rewrite_rule( $regex, 'index.php?pagename=wptests-not-taken', 'top' ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayHasKey( 'random/?$', $rules, "The root rule should be kept when the rule {$regex} does not take the random base." ); + $this->assertSame( 'index.php?random=1', $rules['random/?$'], 'The root rule should set the random query variable.' ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_random_root_rule_is_kept_when_existing_rules_do_not_take_the_random_base(): array { + return array( + 'suffixed slug' => array( 'randomful/?$' ), + 'hyphenated suffixed slug' => array( 'random-ful/?$' ), + 'anchored suffixed slug' => array( '^randomful/?$' ), + 'anchored hyphenated slug' => array( '^random-ful/?$' ), + 'suffixed slug with sub path' => array( '^randomful/(.*)?' ), + 'hyphenated slug feed' => array( 'random-ful/feed/(feed|rdf|rss|rss2|atom)/?$' ), + 'required suffix' => array( 'random(ful|ness)/?$' ), + 'prefixed slug' => array( 'not-random/?$' ), + 'anchored prefixed slug' => array( '^not-random/?$' ), + 'sub path of the random base' => array( 'random/wptests/?$' ), + 'catch-all' => array( '(.+?)/?$' ), + 'anchored catch-all' => array( '^(.+?)/?$' ), + 'single segment catch-all' => array( '([^/]+)/?$' ), + 'letters only catch-all' => array( '([a-z]+)/?$' ), + 'page catch-all' => array( '(.?.+?)(?:/([0-9]+))?/?$' ), + 'alternation without random' => array( '(pens|pencils)/?$' ), + 'alternation of similar slugs' => array( '(randomful|random-ful)/?$' ), + 'partial group, suffixed' => array( '(rand)omful/?$' ), + 'character class, suffixed' => array( '[r]andomful/?$' ), + ); + } + + /** + * Ensure a request for the random base is a random content request when existing rules do not take the random base. + * + * @dataProvider data_random_base_request_matches_the_root_rule_when_existing_rules_do_not_take_the_random_base + * + * @global WP $wp Current WordPress environment instance. + * @global WP_Rewrite $wp_rewrite WordPress rewrite component. + * + * @param string $regex Rewrite rule regex. + * @param string $position Position of the rewrite rule, either 'top' or 'bottom'. + * @param string $structure Permalink structure. + * @param string $home_path Path of the site's home URL, for sites installed in a subdirectory. + */ + public function test_random_base_request_matches_the_root_rule_when_existing_rules_do_not_take_the_random_base( string $regex, string $position, string $structure, string $home_path ): void { + global $wp, $wp_rewrite; + + if ( '' !== $home_path ) { + update_option( 'home', untrailingslashit( home_url() ) . $home_path ); + } + + $this->set_permalink_structure( $structure ); + create_initial_taxonomies(); + + add_rewrite_rule( $regex, 'index.php?pagename=wptests-not-taken', $position ); + flush_rewrite_rules(); + + $this->go_to( home_url( user_trailingslashit( $wp_rewrite->root . 'random' ) ) ); + + $this->assertSame( $wp_rewrite->root . 'random/?$', $wp->matched_rule, "A request for the random base should match the root rule rather than {$wp->matched_rule}." ); + $this->assertSame( '1', $wp->query_vars['random'] ?? null, 'The random query variable should be set for a request for the random base.' ); + } + + /** + * Data provider. + * + * Each rule is tested with each permalink structure. For PATHINFO permalink structures, + * each rule is also tested prefixed with `index.php/`, as rules for such sites usually are. + * + * Catch-all rules are added to the bottom of the rules. A catch-all rule added to the top + * takes precedence over all of the rules WordPress generates, including the random content + * rules, so matches every request, as intended by the 'top' position. + * + * @return array + */ + public static function data_random_base_request_matches_the_root_rule_when_existing_rules_do_not_take_the_random_base(): array { + $structures = array( + 'post name' => array( '/%postname%/', '' ), + 'no trailing slash' => array( '/%postname%', '' ), + 'verbose page rules' => array( '/%category%/%postname%/', '' ), + 'day and name' => array( '/%year%/%monthnum%/%day%/%postname%/', '' ), + 'numeric' => array( '/archives/%post_id%', '' ), + 'subdirectory install' => array( '/%postname%/', '/wptests-subdirectory' ), + 'pathinfo post name' => array( '/index.php/%postname%/', '' ), + 'pathinfo day and name' => array( '/index.php/%year%/%monthnum%/%day%/%postname%/', '' ), + 'pathinfo no trailing' => array( '/index.php/%postname%', '' ), + ); + + $data = array(); + foreach ( self::data_random_root_rule_is_kept_when_existing_rules_do_not_take_the_random_base() as $rule_name => $rule ) { + $position = str_contains( $rule_name, 'catch-all' ) ? 'bottom' : 'top'; + + foreach ( $structures as $structure_name => $structure ) { + $data[ "{$rule_name}, {$structure_name}" ] = array( $rule[0], $position, $structure[0], $structure[1] ); + + if ( str_starts_with( $structure[0], '/index.php/' ) ) { + $prefixed_rule = ( str_starts_with( $rule[0], '^' ) ? '^' : '' ) . 'index.php/' . ltrim( $rule[0], '^' ); + + $data[ "{$rule_name} prefixed with index.php, {$structure_name}" ] = array( $prefixed_rule, $position, $structure[0], $structure[1] ); + } + } + } + + return $data; + } + + /** + * Ensure a rule requiring a trailing slash does not take the random base. + * + * This applies regardless of whether the permalink structure uses a trailing slash. + * WP::parse_request() trims slashes from the requested path, so such a rule never + * matches a request for the random base. + * + * @dataProvider data_rules_requiring_a_trailing_slash_do_not_take_the_random_base + * + * @global WP $wp Current WordPress environment instance. + * + * @param string $structure Permalink structure. + * @param string $path Request path for the random base. + */ + public function test_rules_requiring_a_trailing_slash_do_not_take_the_random_base( string $structure, string $path ): void { + global $wp; + + $this->set_permalink_structure( $structure ); + create_initial_taxonomies(); + + add_rewrite_rule( '^random/(.*)?', 'index.php?pagename=wptests-not-taken', 'top' ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayHasKey( 'random/?$', $rules, 'The root rule should be kept for a rule requiring a trailing slash.' ); + + $this->go_to( home_url( $path ) ); + + $this->assertSame( 'random/?$', $wp->matched_rule, 'The random base should be matched by the root rule.' ); + $this->assertSame( '1', $wp->query_vars['random'] ?? null, 'The random query variable should be set.' ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_rules_requiring_a_trailing_slash_do_not_take_the_random_base(): array { + return array( + 'trailing slash' => array( '/%postname%/', '/random/' ), + 'no trailing slash' => array( '/%postname%', '/random' ), + ); + } + + /** + * Ensure a request for the random base matches the root rule. + * + * Random content rules must be matched ahead of the generic post and page rules. + * + * @dataProvider data_permalink_structures + * + * @global WP $wp Current WordPress environment instance. + * + * @param string $structure Permalink structure. + */ + public function test_random_base_request_matches_the_root_rule( string $structure ): void { + global $wp; + + $this->set_permalink_structure( $structure ); + create_initial_taxonomies(); + flush_rewrite_rules(); + + $this->go_to( home_url( '/random/' ) ); + + $this->assertSame( 'random/?$', $wp->matched_rule, 'The random base should be matched by the root rule.' ); + } + + /** + * Ensure a post type archive at the random base takes precedence over the root rule. + * + * @dataProvider data_post_types_using_the_random_base + * + * @param string $post_type Post type name. + * @param array $args Post type registration arguments. + */ + public function test_random_root_rule_is_omitted_for_a_post_type_archive_at_the_random_base( string $post_type, array $args ): void { + register_post_type( $post_type, $args ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayNotHasKey( 'random/?$', $rules, 'The root rule should be omitted for a post type archive at the random base.' ); + $this->assertSame( "index.php?post_type={$post_type}", $this->get_stored_rewrite_rules()['random/?$'] ?? null, 'The post type archive should be reachable at the random base.' ); + } + + /** + * Ensure a post type archive at the random base is not redirected to random content. + * + * @dataProvider data_post_types_using_the_random_base + * + * @param string $post_type Post type name. + * @param array $args Post type registration arguments. + */ + public function test_post_type_archive_at_the_random_base_is_not_redirected( string $post_type, array $args ): void { + register_post_type( $post_type, $args ); + $this->get_random_rewrite_rules(); + + self::factory()->post->create_many( 2, array( 'post_type' => $post_type ) ); + self::factory()->post->create(); + + $redirect = new MockAction(); + add_filter( 'wp_redirect', array( $redirect, 'filter' ), 5 ); + + $this->go_to( home_url( '/random/' ) ); + + $this->assertSame( 0, $redirect->get_call_count(), 'The post type archive should not redirect to random content.' ); + $this->assertQueryTrue( 'is_archive', 'is_post_type_archive' ); + $this->assertSame( $post_type, get_query_var( 'post_type' ), 'The post type archive should be queried.' ); + } + + /** + * Ensure a single post of a post type at the random base is not redirected to random content. + * + * Registering a post type named `random` also registers `random` as its query variable, + * which is the query variable used by random content requests. + * + * @dataProvider data_post_types_using_the_random_base + * + * @param string $post_type Post type name. + * @param array $args Post type registration arguments. + */ + public function test_single_post_of_a_post_type_at_the_random_base_is_not_redirected( string $post_type, array $args ): void { + register_post_type( $post_type, $args ); + $this->get_random_rewrite_rules(); + + $post_id = self::factory()->post->create( array( 'post_type' => $post_type ) ); + self::factory()->post->create(); + + $permalink = get_permalink( $post_id ); + $this->assertIsString( $permalink, 'The post permalink could not be retrieved.' ); + $this->assertStringStartsWith( home_url( '/random/' ), $permalink, 'The post permalink should use the random base.' ); + + $redirect = new MockAction(); + add_filter( 'wp_redirect', array( $redirect, 'filter' ), 5 ); + + $this->go_to( $permalink ); + + $this->assertSame( 0, $redirect->get_call_count(), 'The single post should not redirect to random content.' ); + $this->assertQueryTrue( 'is_single', 'is_singular' ); + $this->assertSame( $post_id, get_queried_object_id(), 'The single post should be queried.' ); + } + + /** + * Data provider. + * + * @return array}> + */ + public static function data_post_types_using_the_random_base(): array { + return array( + 'post type named random' => array( + 'random', + array( + 'public' => true, + 'has_archive' => true, + ), + ), + 'archive slug random' => array( + 'wptests_random', + array( + 'public' => true, + 'has_archive' => 'random', + 'rewrite' => array( 'slug' => 'random' ), + ), + ), + 'rewrite slug random' => array( + 'wptests_random', + array( + 'public' => true, + 'has_archive' => true, + 'rewrite' => array( 'slug' => 'random' ), + ), + ), + ); + } + + /** + * Ensure a post type archive with a slug similar to the random base does not take it. + * + * @dataProvider data_random_root_rule_is_kept_for_a_post_type_archive_with_a_similar_slug + * + * @param string $slug Post type archive slug. + */ + public function test_random_root_rule_is_kept_for_a_post_type_archive_with_a_similar_slug( string $slug ): void { + register_post_type( + 'wptests_similar', + array( + 'public' => true, + 'has_archive' => $slug, + ) + ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayHasKey( 'random/?$', $rules, "The root rule should be kept for a post type archive at {$slug}." ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_random_root_rule_is_kept_for_a_post_type_archive_with_a_similar_slug(): array { + return array( + 'suffixed' => array( 'randomful' ), + 'hyphenated' => array( 'random-ful' ), + 'prefixed' => array( 'not-random' ), + ); + } + + /** + * Ensure the root rule is omitted when an existing rule takes a custom random base. + * + * @global WP_Rewrite $wp_rewrite WordPress rewrite component. + */ + public function test_random_root_rule_is_omitted_when_an_existing_rule_takes_a_custom_random_base(): void { + global $wp_rewrite; + + $wp_rewrite->random_base = 'surprise-me'; + add_rewrite_rule( '^surprise-me/?$', 'index.php?pagename=wptests-taken', 'top' ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayNotHasKey( 'surprise-me/?$', $rules, 'The root rule should be omitted when a rule takes the custom random base.' ); + } + + /** + * Ensure a rule at the default random base does not take a custom random base. + * + * @global WP_Rewrite $wp_rewrite WordPress rewrite component. + */ + public function test_random_root_rule_is_kept_when_an_existing_rule_uses_the_default_base_with_a_custom_random_base(): void { + global $wp_rewrite; + + $wp_rewrite->random_base = 'surprise-me'; + add_rewrite_rule( '^random/?$', 'index.php?pagename=wptests-taken', 'top' ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayHasKey( 'surprise-me/?$', $rules, 'The root rule should be kept when a rule only uses the default random base.' ); + } + + /** + * Ensure the root rule is omitted when an existing rule takes the random base with PATHINFO permalinks. + * + * @dataProvider data_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base_with_pathinfo_permalinks + * + * @param string $regex Rewrite rule regex. + */ + public function test_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base_with_pathinfo_permalinks( string $regex ): void { + $this->set_permalink_structure( '/index.php/%postname%/' ); + create_initial_taxonomies(); + + add_rewrite_rule( $regex, 'index.php?pagename=wptests-taken', 'top' ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayNotHasKey( 'index.php/random/?$', $rules, "The root rule should be omitted when the rule {$regex} takes the random base." ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base_with_pathinfo_permalinks(): array { + return array( + 'exact match' => array( 'index.php/random/?$' ), + 'anchored exact match' => array( '^index.php/random/?$' ), + ); + } + + /** + * Ensure the root rule is kept when an existing catch-all rule matches the random base with PATHINFO permalinks. + * + * @dataProvider data_random_root_rule_is_kept_when_an_existing_catch_all_rule_matches_the_random_base_with_pathinfo_permalinks + * + * @param string $regex Rewrite rule regex. + */ + public function test_random_root_rule_is_kept_when_an_existing_catch_all_rule_matches_the_random_base_with_pathinfo_permalinks( string $regex ): void { + $this->set_permalink_structure( '/index.php/%postname%/' ); + create_initial_taxonomies(); + + add_rewrite_rule( $regex, 'index.php?pagename=wptests-not-taken', 'top' ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayHasKey( 'index.php/random/?$', $rules, "The root rule should be kept when the catch-all rule {$regex} matches the random base." ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_random_root_rule_is_kept_when_an_existing_catch_all_rule_matches_the_random_base_with_pathinfo_permalinks(): array { + return array( + 'catch-all' => array( 'index.php/(.+?)/?$' ), + 'anchored catch-all' => array( '^index.php/(.+?)/?$' ), + 'single segment catch-all' => array( 'index.php/([^/]+)/?$' ), + 'page catch-all' => array( 'index.php/(.?.+?)(?:/([0-9]+))?/?$' ), + ); + } + + /** + * Ensure the random base is taken filter can omit the root rule. + */ + public function test_random_base_is_taken_filter_can_omit_the_root_rule(): void { + add_filter( 'wp_rewrite_random_base_is_taken', '__return_true' ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayNotHasKey( 'random/?$', $rules, 'The filter should be able to omit the root rule.' ); + $this->assertArrayHasKey( 'random/category/(.+?)/?$', $rules, 'Random archive rules should be kept when the filter omits the root rule.' ); + } + + /** + * Ensure the random base is taken filter can keep the root rule when a rule takes the random base. + */ + public function test_random_base_is_taken_filter_can_keep_the_root_rule(): void { + add_rewrite_rule( '^random/?$', 'index.php?pagename=wptests-taken', 'top' ); + add_filter( 'wp_rewrite_random_base_is_taken', '__return_false' ); + + $rules = $this->get_random_rewrite_rules(); + + $this->assertArrayHasKey( 'random/?$', $rules, 'The filter should be able to keep the root rule when a rule takes the random base.' ); + } + + /** + * Ensure the random base is taken filter receives the detected value and the random base. + */ + public function test_random_base_is_taken_filter_receives_the_detected_value_and_random_base(): void { + add_rewrite_rule( '^random/?$', 'index.php?pagename=wptests-taken', 'top' ); + + $filter = new MockAction(); + add_filter( 'wp_rewrite_random_base_is_taken', array( $filter, 'filter' ), 10, 2 ); + + $this->get_random_rewrite_rules(); + + $args = $filter->get_args(); + $this->assertNotEmpty( $args, 'The filter was not called.' ); + $this->assertSame( array( true, 'random' ), end( $args ), 'The filter should receive whether the base is taken and the random base.' ); + } + + /** + * Filters rewrite rules to those setting the random query variable. + * + * @param array $rules Rewrite rules, keyed by their regex pattern. + * @return array Random content rewrite rules, keyed by their regex pattern. + */ + private function filter_random_rules( array $rules ): array { + return array_filter( + $rules, + static function ( $query ): bool { + parse_str( (string) wp_parse_url( (string) $query, PHP_URL_QUERY ), $query_vars ); + + return isset( $query_vars['random'] ); + } + ); + } } From 2f3742d368b19436dca3b4dcb6b1d6fa8e04c5a9 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 9 Nov 2026 14:18:37 +1100 Subject: [PATCH 02/20] Add test for rewrite rule defined as `random`. --- tests/phpunit/tests/query/randomContentRedirect.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index 94d4b7f119270..ed83b5de3b9ee 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -2775,6 +2775,14 @@ public static function data_post_type_archive_at_the_random_base_is_not_redirect 'has_archive' => 'random', ), ), + 'rewrite slug random' => array( + 'wptests_random', + array( + 'public' => true, + 'has_archive' => true, + 'rewrite' => array( 'slug' => 'random' ), + ), + ), ); } From 34f6fb743c489ca4ffecbd1eb220e8514c7e70a3 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 9 Nov 2026 13:46:16 +1100 Subject: [PATCH 03/20] Use existing rewrite rules to test if /random is taken. --- src/wp-includes/class-wp-rewrite.php | 118 ++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 20 deletions(-) diff --git a/src/wp-includes/class-wp-rewrite.php b/src/wp-includes/class-wp-rewrite.php index 20967bafa0206..584a267e3400b 100644 --- a/src/wp-includes/class-wp-rewrite.php +++ b/src/wp-includes/class-wp-rewrite.php @@ -1423,22 +1423,6 @@ public function rewrite_rules() { */ $page_rewrite = apply_filters( 'page_rewrite_rules', $page_rewrite ); - // Random content rewrite rules. - $random_rewrite = $this->random_rewrite_rules(); - - /** - * Filters rewrite rules used for random content redirects. - * - * Likely random content rules would include `/random/`, as well as - * term, author and post type archives prefixed with the random base, - * for example `/random/category/news/`. - * - * @since 7.2.0 - * - * @param array $random_rewrite Array of rewrite rules for random content redirects, keyed by their regex pattern. - */ - $random_rewrite = apply_filters( 'random_rewrite_rules', $random_rewrite ); - // Extra permastructs. foreach ( $this->extra_permastructs as $permastructname => $struct ) { if ( is_array( $struct ) ) { @@ -1485,7 +1469,35 @@ public function rewrite_rules() { $this->extra_rules_top = array_merge( $this->extra_rules_top, $rules ); } - // Put them together. + /* + * Random content rewrite rules. + * + * The random content rules check whether an existing rule already uses the + * random base. The page and post rules are generic catch-all rules matching + * any path, so are not checked. Pages and posts at the random base are + * accounted for separately by WP_Rewrite::random_rewrite_rules(). + */ + $random_rewrite = $this->random_rewrite_rules( + array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite, $this->extra_rules ) + ); + + /** + * Filters rewrite rules used for random content redirects. + * + * Likely random content rules would include `/random/`, as well as + * term, author and post type archives prefixed with the random base, + * for example `/random/category/news/`. + * + * @since 7.2.0 + * + * @param array $random_rewrite Array of rewrite rules for random content redirects, keyed by their regex pattern. + */ + $random_rewrite = apply_filters( 'random_rewrite_rules', $random_rewrite ); + + /* + * Put them together, with the random content rules ahead of the generic rules + * such as the page and post rules which would otherwise match them first. + */ if ( $this->use_verbose_page_rules ) { $this->rules = array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $random_rewrite, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite, $page_rewrite, $post_rewrite, $this->extra_rules ); } else { @@ -1539,17 +1551,23 @@ public function rewrite_rules() { * * @see wp_is_random_content_redirect_enabled() * + * @param array|null $existing_rules Optional. Existing rewrite rules to check for rules using the + * random base, keyed by their regex pattern. Generic catch-all + * rules, such as the page and post rules, should not be included + * as they match any path. Default null, the extra rules added + * via WP_Rewrite::add_rule() and WP_Rewrite::add_permastruct(). * @return array Array of rewrite rules for random content redirects, keyed by their regex pattern. * * @phpstan-return array */ - public function random_rewrite_rules(): array { + public function random_rewrite_rules( ?array $existing_rules = null ): array { if ( ! wp_is_random_content_redirect_enabled() ) { return array(); } - $random_slug = $this->get_random_base(); - $random_base = $this->root . $random_slug; + $random_slug = $this->get_random_base(); + $random_base = $this->root . $random_slug; + $catch_all_test_base = "{$this->root}catchall{$random_slug}"; // No hyphens to account for [A-z0-9] rules. $rules = array(); @@ -1564,6 +1582,66 @@ public function random_rewrite_rules(): array { fn( $random_base_post ) => $random_base_post instanceof WP_Post && is_post_publicly_viewable( $random_base_post ) ); + if ( ! $random_base_is_taken ) { + /* + * WP::parse_request() trims slashes from the requested path before matching + * it against the rules, so the path is matched without a trailing slash + * regardless of the permalink structure. + */ + $random_base_path = untrailingslashit( $random_base ); + + /* + * The generic catch-all page and post rules match any path, including the + * random base, so are excluded from the existing rules. Pages and posts at + * the random base, which these rules serve, are accounted for above. + */ + $existing_rules ??= array_merge( $this->extra_rules_top, $this->extra_rules ); + + foreach ( $existing_rules as $regex => $query ) { + /* + * Match the same way WP::parse_request() does, so the regex is anchored to + * the start of the path whether or not it includes a leading caret. + * + * Matching the regex rather than comparing strings accounts for rules + * written in any form, such as `random/?$`, `^random/?$`, `(random|pens)/?$`, + * `(rand)om/?$` or `[r]andom/?$`, while rules for similarly named paths, + * such as `randomful/?$` or `random-ful/?$`, do not match. + * + * A rule requiring a trailing slash, such as `^random/(.*)?`, never matches + * a request for the random base, so it does not take it. + */ + $pattern = "#^{$regex}#"; + if ( ! preg_match( $pattern, $random_base_path ) ) { + continue; + } + + /* + * Generic catch-all rules, such as `(.+?)/?$` or `([^/]+)/?$`, match any + * path including the random base. A rule also matching a path other than + * the random base, for example `notrandom`, is a catch-all rule so does + * not take the random base. + */ + if ( preg_match( $pattern, $catch_all_test_base ) ) { + continue; + } + + $random_base_is_taken = true; + break; + } + } + + /** + * Filter whether the Random Redirect base is taken. + * + * Determine whether the random base redirect is taken. The random base may be + * modified depending on the site's language or permalink settings. Always determin + * the value from the second parameter, `$random_base`. + * + * @param bool $random_base_is_taken Whether the random base is known to be taken. + * @param string $random_base The random base slug for the site. + */ + $random_base_is_taken = apply_filters( 'wp_rewrite_random_base_is_taken', $random_base_is_taken, $random_base ); + if ( ! $random_base_is_taken ) { $rules[ $random_base . '/?$' ] = $this->index . '?random=1'; } From bb09f68f5ffc89c39efb9d65012218e64b06d24a Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 9 Nov 2026 14:50:35 +1100 Subject: [PATCH 04/20] Tidy up new filter: typos, docs and passing the slug alone. --- src/wp-includes/class-wp-rewrite.php | 18 ++++++++++----- .../tests/rewrite/randomRewriteRules.php | 23 ++++++++++++++++--- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/class-wp-rewrite.php b/src/wp-includes/class-wp-rewrite.php index 584a267e3400b..c8a250ee0941a 100644 --- a/src/wp-includes/class-wp-rewrite.php +++ b/src/wp-includes/class-wp-rewrite.php @@ -1631,16 +1631,22 @@ public function random_rewrite_rules( ?array $existing_rules = null ): array { } /** - * Filter whether the Random Redirect base is taken. + * Filters whether the random content redirect base is taken. * - * Determine whether the random base redirect is taken. The random base may be - * modified depending on the site's language or permalink settings. Always determin - * the value from the second parameter, `$random_base`. + * When the random base is taken, the `/random/` rewrite rule is omitted. The random + * base may vary depending on the site's language or permalink settings, so always + * determine the value from the `$random_base` or `$random_slug` parameters. + * + * @since 7.2.0 * * @param bool $random_base_is_taken Whether the random base is known to be taken. - * @param string $random_base The random base slug for the site. + * @param string $random_base The random base for the site, including the PATHINFO + * prefix when using PATHINFO permalinks, for example + * `random` or `index.php/random`. + * @param string $random_slug The random base slug for the site, without the PATHINFO + * prefix, for example `random`. */ - $random_base_is_taken = apply_filters( 'wp_rewrite_random_base_is_taken', $random_base_is_taken, $random_base ); + $random_base_is_taken = apply_filters( 'wp_rewrite_random_base_is_taken', $random_base_is_taken, $random_base, $random_slug ); if ( ! $random_base_is_taken ) { $rules[ $random_base . '/?$' ] = $this->index . '?random=1'; diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index 7aa9ca8603b33..6e5935ed2454c 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -1567,19 +1567,36 @@ public function test_random_base_is_taken_filter_can_keep_the_root_rule(): void } /** - * Ensure the random base is taken filter receives the detected value and the random base. + * Ensure the random base is taken filter receives the detected value, the random base and the random slug. */ public function test_random_base_is_taken_filter_receives_the_detected_value_and_random_base(): void { add_rewrite_rule( '^random/?$', 'index.php?pagename=wptests-taken', 'top' ); $filter = new MockAction(); - add_filter( 'wp_rewrite_random_base_is_taken', array( $filter, 'filter' ), 10, 2 ); + add_filter( 'wp_rewrite_random_base_is_taken', array( $filter, 'filter' ), 10, 3 ); $this->get_random_rewrite_rules(); $args = $filter->get_args(); $this->assertNotEmpty( $args, 'The filter was not called.' ); - $this->assertSame( array( true, 'random' ), end( $args ), 'The filter should receive whether the base is taken and the random base.' ); + $this->assertSame( array( true, 'random', 'random' ), end( $args ), 'The filter should receive whether the base is taken, the random base and the random slug.' ); + } + + /** + * Ensure the random base is taken filter receives the random base with the PATHINFO prefix and the random slug without it. + */ + public function test_random_base_is_taken_filter_receives_the_random_base_and_slug_with_pathinfo_permalinks(): void { + $this->set_permalink_structure( '/index.php/%postname%/' ); + create_initial_taxonomies(); + + $filter = new MockAction(); + add_filter( 'wp_rewrite_random_base_is_taken', array( $filter, 'filter' ), 10, 3 ); + + $this->get_random_rewrite_rules(); + + $args = $filter->get_args(); + $this->assertNotEmpty( $args, 'The filter was not called with PATHINFO permalinks.' ); + $this->assertSame( array( false, 'index.php/random', 'random' ), end( $args ), 'The filter should receive the random base with the PATHINFO prefix and the random slug without it.' ); } /** From d170d3b0f9fb5673152b81ebf19f0e81e8a9fbd8 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 9 Nov 2026 15:06:08 +1100 Subject: [PATCH 05/20] Remove duplicate test. --- .../tests/rewrite/randomRewriteRules.php | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index 6e5935ed2454c..f072466944bf9 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -1324,31 +1324,6 @@ public function test_random_root_rule_is_omitted_for_a_post_type_archive_at_the_ $this->assertSame( "index.php?post_type={$post_type}", $this->get_stored_rewrite_rules()['random/?$'] ?? null, 'The post type archive should be reachable at the random base.' ); } - /** - * Ensure a post type archive at the random base is not redirected to random content. - * - * @dataProvider data_post_types_using_the_random_base - * - * @param string $post_type Post type name. - * @param array $args Post type registration arguments. - */ - public function test_post_type_archive_at_the_random_base_is_not_redirected( string $post_type, array $args ): void { - register_post_type( $post_type, $args ); - $this->get_random_rewrite_rules(); - - self::factory()->post->create_many( 2, array( 'post_type' => $post_type ) ); - self::factory()->post->create(); - - $redirect = new MockAction(); - add_filter( 'wp_redirect', array( $redirect, 'filter' ), 5 ); - - $this->go_to( home_url( '/random/' ) ); - - $this->assertSame( 0, $redirect->get_call_count(), 'The post type archive should not redirect to random content.' ); - $this->assertQueryTrue( 'is_archive', 'is_post_type_archive' ); - $this->assertSame( $post_type, get_query_var( 'post_type' ), 'The post type archive should be queried.' ); - } - /** * Ensure a single post of a post type at the random base is not redirected to random content. * From d88426e9c4a08a1b1f7be6a9e3a276c193056926 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 9 Nov 2026 15:08:30 +1100 Subject: [PATCH 06/20] Move `test_single_post_of_a_post_type_at_the_random_base_is_not_redirected` to content tests. --- .../tests/query/randomContentRedirect.php | 65 +++++++++++++++++++ .../tests/rewrite/randomRewriteRules.php | 32 --------- 2 files changed, 65 insertions(+), 32 deletions(-) diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index ed83b5de3b9ee..0dc763dd3d66a 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -2786,6 +2786,71 @@ public static function data_post_type_archive_at_the_random_base_is_not_redirect ); } + /** + * Ensure a single post of a post type at the random base is not redirected to random content. + * + * Registering a post type named `random` also registers `random` as its query variable, + * which is the query variable used by random content requests. + * + * @dataProvider data_single_post_of_a_post_type_at_the_random_base_is_not_redirected + * + * @param string $post_type Post type name. + * @param array $args Post type registration arguments. + */ + public function test_single_post_of_a_post_type_at_the_random_base_is_not_redirected( string $post_type, array $args ): void { + register_post_type( $post_type, $args ); + flush_rewrite_rules(); + + $post_id = self::factory()->post->create( array( 'post_type' => $post_type ) ); + self::factory()->post->create(); + + $permalink = get_permalink( $post_id ); + $this->assertIsString( $permalink, 'The post permalink could not be retrieved.' ); + $this->assertStringStartsWith( home_url( '/random/' ), $permalink, 'The post permalink should use the random base.' ); + + $redirect = new MockAction(); + add_filter( 'wp_redirect', array( $redirect, 'filter' ), 5 ); + + $this->go_to( $permalink ); + + $this->assertSame( 0, $redirect->get_call_count(), 'The single post should not redirect to random content.' ); + $this->assertQueryTrue( 'is_single', 'is_singular' ); + $this->assertSame( $post_id, get_queried_object_id(), 'The single post should be queried.' ); + } + + /** + * Data provider. + * + * @return array}> + */ + public static function data_single_post_of_a_post_type_at_the_random_base_is_not_redirected(): array { + return array( + 'post type named random' => array( + 'random', + array( + 'public' => true, + 'has_archive' => true, + ), + ), + 'archive slug random' => array( + 'wptests_random', + array( + 'public' => true, + 'has_archive' => 'random', + 'rewrite' => array( 'slug' => 'random' ), + ), + ), + 'rewrite slug random' => array( + 'wptests_random', + array( + 'public' => true, + 'has_archive' => true, + 'rewrite' => array( 'slug' => 'random' ), + ), + ), + ); + } + /** * Ensure random archives redirect when a post type archive is at the random base. */ diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index f072466944bf9..4c976191701eb 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -1324,38 +1324,6 @@ public function test_random_root_rule_is_omitted_for_a_post_type_archive_at_the_ $this->assertSame( "index.php?post_type={$post_type}", $this->get_stored_rewrite_rules()['random/?$'] ?? null, 'The post type archive should be reachable at the random base.' ); } - /** - * Ensure a single post of a post type at the random base is not redirected to random content. - * - * Registering a post type named `random` also registers `random` as its query variable, - * which is the query variable used by random content requests. - * - * @dataProvider data_post_types_using_the_random_base - * - * @param string $post_type Post type name. - * @param array $args Post type registration arguments. - */ - public function test_single_post_of_a_post_type_at_the_random_base_is_not_redirected( string $post_type, array $args ): void { - register_post_type( $post_type, $args ); - $this->get_random_rewrite_rules(); - - $post_id = self::factory()->post->create( array( 'post_type' => $post_type ) ); - self::factory()->post->create(); - - $permalink = get_permalink( $post_id ); - $this->assertIsString( $permalink, 'The post permalink could not be retrieved.' ); - $this->assertStringStartsWith( home_url( '/random/' ), $permalink, 'The post permalink should use the random base.' ); - - $redirect = new MockAction(); - add_filter( 'wp_redirect', array( $redirect, 'filter' ), 5 ); - - $this->go_to( $permalink ); - - $this->assertSame( 0, $redirect->get_call_count(), 'The single post should not redirect to random content.' ); - $this->assertQueryTrue( 'is_single', 'is_singular' ); - $this->assertSame( $post_id, get_queried_object_id(), 'The single post should be queried.' ); - } - /** * Data provider. * From 9185b45c80c611b9643f0e72e8d026e977f7f2ca Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 9 Nov 2026 15:11:44 +1100 Subject: [PATCH 07/20] Rename data provider for use with a single test. --- tests/phpunit/tests/rewrite/randomRewriteRules.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index 4c976191701eb..16064672d4469 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -1310,7 +1310,7 @@ public function test_random_base_request_matches_the_root_rule( string $structur /** * Ensure a post type archive at the random base takes precedence over the root rule. * - * @dataProvider data_post_types_using_the_random_base + * @dataProvider data_random_root_rule_is_omitted_for_a_post_type_archive_at_the_random_base * * @param string $post_type Post type name. * @param array $args Post type registration arguments. @@ -1329,7 +1329,7 @@ public function test_random_root_rule_is_omitted_for_a_post_type_archive_at_the_ * * @return array}> */ - public static function data_post_types_using_the_random_base(): array { + public static function data_random_root_rule_is_omitted_for_a_post_type_archive_at_the_random_base(): array { return array( 'post type named random' => array( 'random', From 4b64321e1b862d7944aa99498e982a32d9d3c454 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 9 Nov 2026 15:18:17 +1100 Subject: [PATCH 08/20] Merge mergable tests. --- .../tests/rewrite/randomRewriteRules.php | 60 +++++++------------ 1 file changed, 23 insertions(+), 37 deletions(-) diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index 16064672d4469..56a1b35b81a6e 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -668,7 +668,7 @@ static function ( array $rules ): array { * Random rules must be matched before any other rule matching the same URL, * such as the greedy page and post rules. * - * @dataProvider data_permalink_structures + * @dataProvider data_random_rewrite_rules_precede_other_matching_rules * * @param string $structure Permalink structure. */ @@ -706,7 +706,7 @@ public function test_random_rewrite_rules_precede_other_matching_rules( $structu * * @return array */ - public static function data_permalink_structures(): array { + public static function data_random_rewrite_rules_precede_other_matching_rules(): array { return array( 'post name' => array( '/%postname%/' ), 'verbose page rules' => array( '/%category%/%postname%/' ), @@ -721,10 +721,17 @@ public static function data_permalink_structures(): array { * * @param string $path Request path. * @param array $expected_query Expected query variables. + * @param string $structure Optional. Permalink structure. Default empty, the + * structure set up for each test. */ - public function test_random_urls_resolve_to_random_query_vars( $path, $expected_query ): void { + public function test_random_urls_resolve_to_random_query_vars( $path, $expected_query, string $structure = '' ): void { global $wp; + if ( '' !== $structure ) { + $this->set_permalink_structure( $structure ); + create_initial_taxonomies(); + } + register_taxonomy( 'wptests_genre', 'post', array( 'rewrite' => array( 'slug' => 'genre' ) ) ); register_post_type( 'wptests_book', @@ -746,56 +753,58 @@ public function test_random_urls_resolve_to_random_query_vars( $path, $expected_ /** * Data provider. * - * @return array}> + * @return array, 2?: string}> */ public static function data_random_urls(): array { return array( - 'random' => array( '/random/', array( 'random' => '1' ) ), - 'random without slash' => array( '/random', array( 'random' => '1' ) ), - 'random category' => array( + 'random' => array( '/random/', array( 'random' => '1' ) ), + 'random without slash' => array( '/random', array( 'random' => '1' ) ), + 'random, verbose page rules' => array( '/random/', array( 'random' => '1' ), '/%category%/%postname%/' ), + 'random, date and name' => array( '/random/', array( 'random' => '1' ), '/%year%/%monthnum%/%postname%/' ), + 'random category' => array( '/random/category/news/', array( 'random' => '1', 'category_name' => 'news', ), ), - 'random child category' => array( + 'random child category' => array( '/random/category/news/local/', array( 'random' => '1', 'category_name' => 'news/local', ), ), - 'random tag' => array( + 'random tag' => array( '/random/tag/featured/', array( 'random' => '1', 'tag' => 'featured', ), ), - 'random author' => array( + 'random author' => array( '/random/author/admin/', array( 'random' => '1', 'author_name' => 'admin', ), ), - 'random custom taxonomy' => array( + 'random custom taxonomy' => array( '/random/genre/sci-fi/', array( 'random' => '1', 'wptests_genre' => 'sci-fi', ), ), - 'random post type archive' => array( + 'random post type archive' => array( '/random/books/', array( 'random' => '1', 'post_type' => 'wptests_book', ), ), - 'query string random' => array( '/?random', array( 'random' => '' ) ), - 'query string random value' => array( '/?random=1', array( 'random' => '1' ) ), + 'query string random' => array( '/?random', array( 'random' => '' ) ), + 'query string random value' => array( '/?random=1', array( 'random' => '1' ) ), ); } @@ -1284,29 +1293,6 @@ public static function data_rules_requiring_a_trailing_slash_do_not_take_the_ran ); } - /** - * Ensure a request for the random base matches the root rule. - * - * Random content rules must be matched ahead of the generic post and page rules. - * - * @dataProvider data_permalink_structures - * - * @global WP $wp Current WordPress environment instance. - * - * @param string $structure Permalink structure. - */ - public function test_random_base_request_matches_the_root_rule( string $structure ): void { - global $wp; - - $this->set_permalink_structure( $structure ); - create_initial_taxonomies(); - flush_rewrite_rules(); - - $this->go_to( home_url( '/random/' ) ); - - $this->assertSame( 'random/?$', $wp->matched_rule, 'The random base should be matched by the root rule.' ); - } - /** * Ensure a post type archive at the random base takes precedence over the root rule. * From b6a012b86ab8ce8bfbb4fd19adffc6dc82ba06f2 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 9 Nov 2026 15:25:23 +1100 Subject: [PATCH 09/20] Document preceident of rewrite rules. --- src/wp-includes/class-wp-rewrite.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/wp-includes/class-wp-rewrite.php b/src/wp-includes/class-wp-rewrite.php index c8a250ee0941a..3f41761340c16 100644 --- a/src/wp-includes/class-wp-rewrite.php +++ b/src/wp-includes/class-wp-rewrite.php @@ -1544,6 +1544,20 @@ public function rewrite_rules() { * the URL, see {@see WP_Rewrite::post_permalinks_share_random_base()}. The rules are flushed * when such a page or post changes, see {@see wp_random_content_flush_rewrite_rules_for_post()}. * + * The `/random/` rule is also omitted if an existing rewrite rule matches the random base, + * for example a post type archive at `/random/`. Generic catch-all rules matching any path, + * such as the page and post rules, are ignored. See the {@see 'wp_rewrite_random_base_is_taken'} + * filter. + * + * Rules added to the top of the rewrite rules, including those for post types and taxonomies, + * take precedence over the random content rules. If a post type or taxonomy uses the random + * base as its rewrite slug, its rules may match random archive URLs before the random content + * rules do. For example, the attachment rules of a post type with the rewrite slug `random` + * match `/random/category/news/`, and the rules of a hierarchical post type or taxonomy match + * every URL beginning with `/random/`. Such random archive URLs display the post type or + * taxonomy content instead. To use random archives on these sites, change the random base on + * the Permalinks screen. + * * The `random` query variable can be added to any other archive or search URL, for example * `/2026/?random`, to request a random post from that archive. * From a4c7f9b35d4ea40904491bb6bf7602b7d022b3c8 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Mon, 9 Nov 2026 15:29:32 +1100 Subject: [PATCH 10/20] Rename single use data providers for their test. --- .../tests/query/randomContentRedirect.php | 48 +++++++++---------- .../tests/rewrite/randomRewriteRules.php | 20 ++++---- tests/phpunit/tests/wp/handleRandom.php | 12 ++--- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index 0dc763dd3d66a..be558f2bd81a6 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -336,7 +336,7 @@ protected function assertQueryIsNotRandomized( WP_Query $query, string $message /** * Ensure random content URLs redirect to a published post. * - * @dataProvider data_random_urls + * @dataProvider data_random_url_redirects_to_a_published_post * * @param string $path Random content request path. */ @@ -351,7 +351,7 @@ public function test_random_url_redirects_to_a_published_post( string $path ): v * * @return array */ - public static function data_random_urls(): array { + public static function data_random_url_redirects_to_a_published_post(): array { return array( 'pretty permalink' => array( '/random/' ), 'pretty without slash' => array( '/random' ), @@ -874,7 +874,7 @@ public function test_random_redirect_ignores_pagination(): void { /** * Ensure unpublished and password protected posts are not selected, even for administrators. * - * @dataProvider data_ineligible_posts + * @dataProvider data_random_redirect_excludes_ineligible_posts * * @param array $args Post arguments. */ @@ -894,7 +894,7 @@ public function test_random_redirect_excludes_ineligible_posts( array $args ): v * * @return array }> */ - public static function data_ineligible_posts(): array { + public static function data_random_redirect_excludes_ineligible_posts(): array { return array( 'draft' => array( array( 'post_status' => 'draft' ) ), 'pending' => array( array( 'post_status' => 'pending' ) ), @@ -1027,7 +1027,7 @@ public function test_no_redirect_for_static_front_page_with_random_query_var_fro /** * Ensure feeds do not redirect with the random query variable. * - * @dataProvider data_feed_urls + * @dataProvider data_no_redirect_for_feeds * * @param string $path Feed path. */ @@ -1043,7 +1043,7 @@ public function test_no_redirect_for_feeds( string $path ): void { * * @return array */ - public static function data_feed_urls(): array { + public static function data_no_redirect_for_feeds(): array { return array( 'site feed' => array( '/feed/?random' ), 'category feed' => array( '/category/arts/feed/?random' ), @@ -1103,7 +1103,7 @@ static function ( string $where, WP_Query $query ): string { /** * Ensure requests using HTTP methods other than GET or HEAD do not redirect. * - * @dataProvider data_non_redirecting_http_methods + * @dataProvider data_no_redirect_for_non_get_requests * * @param string $method HTTP request method. */ @@ -1120,7 +1120,7 @@ public function test_no_redirect_for_non_get_requests( string $method ): void { * * @return array */ - public static function data_non_redirecting_http_methods(): array { + public static function data_no_redirect_for_non_get_requests(): array { return array( 'POST' => array( 'POST' ), 'PUT' => array( 'PUT' ), @@ -1143,7 +1143,7 @@ public function test_no_redirect_without_request_method(): void { /** * Ensure GET and HEAD requests redirect, regardless of case. * - * @dataProvider data_redirecting_http_methods + * @dataProvider data_redirect_for_get_and_head_requests * * @param string $method HTTP request method. */ @@ -1160,7 +1160,7 @@ public function test_redirect_for_get_and_head_requests( string $method ): void * * @return array */ - public static function data_redirecting_http_methods(): array { + public static function data_redirect_for_get_and_head_requests(): array { return array( 'GET' => array( 'GET' ), 'HEAD' => array( 'HEAD' ), @@ -1333,7 +1333,7 @@ public function test_draft_page_named_random_does_not_take_precedence(): void { /** * Ensure random content redirects resume once a page named "random" no longer conflicts. * - * @dataProvider data_page_named_random_changes + * @dataProvider data_random_content_redirects_resume_when_the_page_no_longer_conflicts * * @param callable $change Callback to change the page so it no longer conflicts. */ @@ -1359,7 +1359,7 @@ public function test_random_content_redirects_resume_when_the_page_no_longer_con * * @return array */ - public static function data_page_named_random_changes(): array { + public static function data_random_content_redirects_resume_when_the_page_no_longer_conflicts(): array { return array( 'renamed' => array( static function ( int $page_id ): void { @@ -1450,7 +1450,7 @@ public function test_saving_unrelated_pages_does_not_flush_rewrite_rules(): void } /** - * @dataProvider data_invalid_arguments + * @dataProvider data_invalid_arguments_do_not_flush_rewrite_rules * * @param array $args The arguments to call the function with. */ @@ -1484,7 +1484,7 @@ function ( $arg ) { * * @return array}> */ - public function data_invalid_arguments(): array { + public function data_invalid_arguments_do_not_flush_rewrite_rules(): array { return array( 'nulls' => array( array( null, null, false, null ) ), 'strings' => array( array( 'random', 'random', 'yes', 'random' ) ), @@ -2025,7 +2025,7 @@ public function test_random_selection_varies_between_requests(): void { /** * Ensure the random query selects a single post when a theme overrides the limit. * - * @dataProvider data_query_vars_overriding_the_limit + * @dataProvider data_random_query_is_limited_to_one_post_when_a_theme_overrides_the_limit * * @param string $path Request path. * @param string $query_var Query variable set by a theme. @@ -2056,7 +2056,7 @@ static function ( WP_Query $query ) use ( $query_var, $value ): void { * * @return array */ - public static function data_query_vars_overriding_the_limit(): array { + public static function data_random_query_is_limited_to_one_post_when_a_theme_overrides_the_limit(): array { $overrides = array( 'nopaging' => array( 'nopaging', true ), 'posts_per_archive_page' => array( 'posts_per_archive_page', 10 ), @@ -2262,7 +2262,7 @@ static function ( WP_Query $query ): void { /** * Ensure the enabled filter value is cast to a boolean. * - * @dataProvider data_enabled_filter_values + * @dataProvider data_wp_is_random_content_redirect_enabled_returns_a_boolean * * @param mixed $value Value returned by the filter. * @param bool $expected Expected result. @@ -2283,7 +2283,7 @@ static function () use ( $value ) { * * @return array */ - public static function data_enabled_filter_values(): array { + public static function data_wp_is_random_content_redirect_enabled_returns_a_boolean(): array { return array( 'default true' => array( true, true ), 'false' => array( false, false ), @@ -2455,7 +2455,7 @@ public function test_password_protected_page_named_random_takes_precedence(): vo /** * Ensure paths beneath the random base without random rules do not redirect. * - * @dataProvider data_unsupported_random_paths + * @dataProvider data_no_redirect_for_paths_without_random_rules * * @global WP $wp Current WordPress environment instance. * @@ -2475,7 +2475,7 @@ public function test_no_redirect_for_paths_without_random_rules( string $path ): * * @return array */ - public static function data_unsupported_random_paths(): array { + public static function data_no_redirect_for_paths_without_random_rules(): array { return array( 'pagination' => array( '/random/page/2/' ), 'feed' => array( '/random/feed/' ), @@ -2540,7 +2540,7 @@ public function test_handle_random_runs_the_main_query_for_random_requests(): vo /** * Ensure the main query is only run once for random content requests. * - * @dataProvider data_random_requests + * @dataProvider data_main_query_is_run_once_for_random_requests * * @param string $path Path to request. */ @@ -2558,7 +2558,7 @@ public function test_main_query_is_run_once_for_random_requests( string $path ): * * @return array */ - public static function data_random_requests(): array { + public static function data_main_query_is_run_once_for_random_requests(): array { return array( 'random post' => array( '/random/' ), 'random empty archive' => array( '/random/category/empty/' ), @@ -2629,7 +2629,7 @@ static function ( WP_Query $query ): void { /** * Ensure a post named "random" takes precedence over random content redirects. * - * @dataProvider data_post_name_at_the_root_structures + * @dataProvider data_post_named_random_takes_precedence_over_random_content_redirects * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. * @@ -2657,7 +2657,7 @@ public function test_post_named_random_takes_precedence_over_random_content_redi * * @return array */ - public static function data_post_name_at_the_root_structures(): array { + public static function data_post_named_random_takes_precedence_over_random_content_redirects(): array { return array( 'post name' => array( '/%postname%/' ), 'pathinfo' => array( '/index.php/%postname%/' ), diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index 56a1b35b81a6e..e8e2cd5db20de 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -89,7 +89,7 @@ private function get_stored_rewrite_rules(): array { } /** - * @dataProvider data_core_random_rewrite_rules + * @dataProvider data_core_random_rewrite_rules_are_registered * * @param string $regex Expected rewrite rule regex. * @param string $query Expected rewrite rule query. @@ -106,7 +106,7 @@ public function test_core_random_rewrite_rules_are_registered( $regex, $query ): * * @return array */ - public static function data_core_random_rewrite_rules(): array { + public static function data_core_random_rewrite_rules_are_registered(): array { return array( 'any post' => array( 'random/?$', 'index.php?random=1' ), 'category' => array( 'random/category/(.+?)/?$', 'index.php?category_name=$matches[1]&random=1' ), @@ -715,7 +715,7 @@ public static function data_random_rewrite_rules_precede_other_matching_rules(): } /** - * @dataProvider data_random_urls + * @dataProvider data_random_urls_resolve_to_random_query_vars * * @global WP $wp Current WordPress environment instance. * @@ -755,7 +755,7 @@ public function test_random_urls_resolve_to_random_query_vars( $path, $expected_ * * @return array, 2?: string}> */ - public static function data_random_urls(): array { + public static function data_random_urls_resolve_to_random_query_vars(): array { return array( 'random' => array( '/random/', array( 'random' => '1' ) ), 'random without slash' => array( '/random', array( 'random' => '1' ) ), @@ -827,7 +827,7 @@ public function test_random_root_rule_is_omitted_when_a_page_named_random_exists } /** - * @dataProvider data_pages_that_do_not_conflict + * @dataProvider data_random_root_rule_is_kept_for_pages_that_do_not_conflict * * @param array $args Page arguments. A `post_parent` is replaced with a parent page ID. */ @@ -846,7 +846,7 @@ public function test_random_root_rule_is_kept_for_pages_that_do_not_conflict( $a * * @return array}> */ - public static function data_pages_that_do_not_conflict(): array { + public static function data_random_root_rule_is_kept_for_pages_that_do_not_conflict(): array { return array( 'slug starting with random' => array( array( 'post_name' => 'random-is-just-patterns-we-cant-decipher' ) ), 'draft page named random' => array( @@ -1000,7 +1000,7 @@ public static function data_post_permalinks_share_random_base(): array { /** * @covers WP_Rewrite::post_permalinks_share_random_base * - * @dataProvider data_post_name_at_the_root_structures + * @dataProvider data_random_root_rule_is_omitted_when_a_post_named_random_exists * * @param string $structure Permalink structure. */ @@ -1022,7 +1022,7 @@ public function test_random_root_rule_is_omitted_when_a_post_named_random_exists * * @return array */ - public static function data_post_name_at_the_root_structures(): array { + public static function data_random_root_rule_is_omitted_when_a_post_named_random_exists(): array { return array( 'post name' => array( '/%postname%/' ), 'pathinfo' => array( '/index.php/%postname%/' ), @@ -1030,7 +1030,7 @@ public static function data_post_name_at_the_root_structures(): array { } /** - * @dataProvider data_posts_that_do_not_conflict + * @dataProvider data_random_root_rule_is_kept_for_posts_that_do_not_conflict * * @param string $structure Permalink structure. * @param array $args Post arguments. @@ -1049,7 +1049,7 @@ public function test_random_root_rule_is_kept_for_posts_that_do_not_conflict( st * * @return array}> */ - public static function data_posts_that_do_not_conflict(): array { + public static function data_random_root_rule_is_kept_for_posts_that_do_not_conflict(): array { return array( 'blog prefix' => array( '/blog/%postname%/', array() ), 'date and post name' => array( '/%year%/%monthnum%/%postname%/', array() ), diff --git a/tests/phpunit/tests/wp/handleRandom.php b/tests/phpunit/tests/wp/handleRandom.php index 8fe206cb6b15e..e73b5f5a7f0e9 100644 --- a/tests/phpunit/tests/wp/handleRandom.php +++ b/tests/phpunit/tests/wp/handleRandom.php @@ -72,7 +72,7 @@ protected function handle_random( array $query_vars ): bool { * * @ticket 64498 * - * @dataProvider data_requests_without_the_random_query_var + * @dataProvider data_requests_without_the_random_query_var_are_not_handled * * @param array $query_vars Query variables for the request. */ @@ -89,7 +89,7 @@ public function test_requests_without_the_random_query_var_are_not_handled( arra * * @return array }> */ - public static function data_requests_without_the_random_query_var(): array { + public static function data_requests_without_the_random_query_var_are_not_handled(): array { return array( 'no query vars' => array( array() ), 'other query vars' => array( array( 'post_type' => 'post' ) ), @@ -140,7 +140,7 @@ public function test_random_content_queries_are_not_cached(): void { * * @ticket 64498 * - * @dataProvider data_requests_that_are_not_random_content_requests + * @dataProvider data_main_query_runs_unchanged_for_requests_that_are_not_random_content_requests * * @param array $query_vars Query variables for the request. * @param string $request_method HTTP request method. @@ -164,7 +164,7 @@ public function test_main_query_runs_unchanged_for_requests_that_are_not_random_ * * @return array, 1: non-falsy-string }> */ - public static function data_requests_that_are_not_random_content_requests(): array { + public static function data_main_query_runs_unchanged_for_requests_that_are_not_random_content_requests(): array { return array( 'post request' => array( array( 'random' => '' ), 'POST' ), 'non-randomable without posts' => array( @@ -182,7 +182,7 @@ public static function data_requests_that_are_not_random_content_requests(): arr * * @ticket 64498 * - * @dataProvider data_permalinks_that_cannot_be_redirected_to + * @dataProvider data_request_continues_with_the_selected_post_when_it_is_not_redirected * * @param string $permalink Permalink of the selected post. */ @@ -207,7 +207,7 @@ static function () use ( $permalink ): string { * * @return array */ - public static function data_permalinks_that_cannot_be_redirected_to(): array { + public static function data_request_continues_with_the_selected_post_when_it_is_not_redirected(): array { return array( 'empty permalink' => array( '' ), 'off-site permalink' => array( 'https://elsewhere.example/post/' ), From 23001cd0f6cfc8230000d4512e4cc7764f891af0 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Tue, 10 Nov 2026 08:57:25 +1100 Subject: [PATCH 11/20] Revert "Rename single use data providers for their test." This reverts commit a4c7f9b35d4ea40904491bb6bf7602b7d022b3c8. --- .../tests/query/randomContentRedirect.php | 48 +++++++++---------- .../tests/rewrite/randomRewriteRules.php | 20 ++++---- tests/phpunit/tests/wp/handleRandom.php | 12 ++--- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index be558f2bd81a6..0dc763dd3d66a 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -336,7 +336,7 @@ protected function assertQueryIsNotRandomized( WP_Query $query, string $message /** * Ensure random content URLs redirect to a published post. * - * @dataProvider data_random_url_redirects_to_a_published_post + * @dataProvider data_random_urls * * @param string $path Random content request path. */ @@ -351,7 +351,7 @@ public function test_random_url_redirects_to_a_published_post( string $path ): v * * @return array */ - public static function data_random_url_redirects_to_a_published_post(): array { + public static function data_random_urls(): array { return array( 'pretty permalink' => array( '/random/' ), 'pretty without slash' => array( '/random' ), @@ -874,7 +874,7 @@ public function test_random_redirect_ignores_pagination(): void { /** * Ensure unpublished and password protected posts are not selected, even for administrators. * - * @dataProvider data_random_redirect_excludes_ineligible_posts + * @dataProvider data_ineligible_posts * * @param array $args Post arguments. */ @@ -894,7 +894,7 @@ public function test_random_redirect_excludes_ineligible_posts( array $args ): v * * @return array }> */ - public static function data_random_redirect_excludes_ineligible_posts(): array { + public static function data_ineligible_posts(): array { return array( 'draft' => array( array( 'post_status' => 'draft' ) ), 'pending' => array( array( 'post_status' => 'pending' ) ), @@ -1027,7 +1027,7 @@ public function test_no_redirect_for_static_front_page_with_random_query_var_fro /** * Ensure feeds do not redirect with the random query variable. * - * @dataProvider data_no_redirect_for_feeds + * @dataProvider data_feed_urls * * @param string $path Feed path. */ @@ -1043,7 +1043,7 @@ public function test_no_redirect_for_feeds( string $path ): void { * * @return array */ - public static function data_no_redirect_for_feeds(): array { + public static function data_feed_urls(): array { return array( 'site feed' => array( '/feed/?random' ), 'category feed' => array( '/category/arts/feed/?random' ), @@ -1103,7 +1103,7 @@ static function ( string $where, WP_Query $query ): string { /** * Ensure requests using HTTP methods other than GET or HEAD do not redirect. * - * @dataProvider data_no_redirect_for_non_get_requests + * @dataProvider data_non_redirecting_http_methods * * @param string $method HTTP request method. */ @@ -1120,7 +1120,7 @@ public function test_no_redirect_for_non_get_requests( string $method ): void { * * @return array */ - public static function data_no_redirect_for_non_get_requests(): array { + public static function data_non_redirecting_http_methods(): array { return array( 'POST' => array( 'POST' ), 'PUT' => array( 'PUT' ), @@ -1143,7 +1143,7 @@ public function test_no_redirect_without_request_method(): void { /** * Ensure GET and HEAD requests redirect, regardless of case. * - * @dataProvider data_redirect_for_get_and_head_requests + * @dataProvider data_redirecting_http_methods * * @param string $method HTTP request method. */ @@ -1160,7 +1160,7 @@ public function test_redirect_for_get_and_head_requests( string $method ): void * * @return array */ - public static function data_redirect_for_get_and_head_requests(): array { + public static function data_redirecting_http_methods(): array { return array( 'GET' => array( 'GET' ), 'HEAD' => array( 'HEAD' ), @@ -1333,7 +1333,7 @@ public function test_draft_page_named_random_does_not_take_precedence(): void { /** * Ensure random content redirects resume once a page named "random" no longer conflicts. * - * @dataProvider data_random_content_redirects_resume_when_the_page_no_longer_conflicts + * @dataProvider data_page_named_random_changes * * @param callable $change Callback to change the page so it no longer conflicts. */ @@ -1359,7 +1359,7 @@ public function test_random_content_redirects_resume_when_the_page_no_longer_con * * @return array */ - public static function data_random_content_redirects_resume_when_the_page_no_longer_conflicts(): array { + public static function data_page_named_random_changes(): array { return array( 'renamed' => array( static function ( int $page_id ): void { @@ -1450,7 +1450,7 @@ public function test_saving_unrelated_pages_does_not_flush_rewrite_rules(): void } /** - * @dataProvider data_invalid_arguments_do_not_flush_rewrite_rules + * @dataProvider data_invalid_arguments * * @param array $args The arguments to call the function with. */ @@ -1484,7 +1484,7 @@ function ( $arg ) { * * @return array}> */ - public function data_invalid_arguments_do_not_flush_rewrite_rules(): array { + public function data_invalid_arguments(): array { return array( 'nulls' => array( array( null, null, false, null ) ), 'strings' => array( array( 'random', 'random', 'yes', 'random' ) ), @@ -2025,7 +2025,7 @@ public function test_random_selection_varies_between_requests(): void { /** * Ensure the random query selects a single post when a theme overrides the limit. * - * @dataProvider data_random_query_is_limited_to_one_post_when_a_theme_overrides_the_limit + * @dataProvider data_query_vars_overriding_the_limit * * @param string $path Request path. * @param string $query_var Query variable set by a theme. @@ -2056,7 +2056,7 @@ static function ( WP_Query $query ) use ( $query_var, $value ): void { * * @return array */ - public static function data_random_query_is_limited_to_one_post_when_a_theme_overrides_the_limit(): array { + public static function data_query_vars_overriding_the_limit(): array { $overrides = array( 'nopaging' => array( 'nopaging', true ), 'posts_per_archive_page' => array( 'posts_per_archive_page', 10 ), @@ -2262,7 +2262,7 @@ static function ( WP_Query $query ): void { /** * Ensure the enabled filter value is cast to a boolean. * - * @dataProvider data_wp_is_random_content_redirect_enabled_returns_a_boolean + * @dataProvider data_enabled_filter_values * * @param mixed $value Value returned by the filter. * @param bool $expected Expected result. @@ -2283,7 +2283,7 @@ static function () use ( $value ) { * * @return array */ - public static function data_wp_is_random_content_redirect_enabled_returns_a_boolean(): array { + public static function data_enabled_filter_values(): array { return array( 'default true' => array( true, true ), 'false' => array( false, false ), @@ -2455,7 +2455,7 @@ public function test_password_protected_page_named_random_takes_precedence(): vo /** * Ensure paths beneath the random base without random rules do not redirect. * - * @dataProvider data_no_redirect_for_paths_without_random_rules + * @dataProvider data_unsupported_random_paths * * @global WP $wp Current WordPress environment instance. * @@ -2475,7 +2475,7 @@ public function test_no_redirect_for_paths_without_random_rules( string $path ): * * @return array */ - public static function data_no_redirect_for_paths_without_random_rules(): array { + public static function data_unsupported_random_paths(): array { return array( 'pagination' => array( '/random/page/2/' ), 'feed' => array( '/random/feed/' ), @@ -2540,7 +2540,7 @@ public function test_handle_random_runs_the_main_query_for_random_requests(): vo /** * Ensure the main query is only run once for random content requests. * - * @dataProvider data_main_query_is_run_once_for_random_requests + * @dataProvider data_random_requests * * @param string $path Path to request. */ @@ -2558,7 +2558,7 @@ public function test_main_query_is_run_once_for_random_requests( string $path ): * * @return array */ - public static function data_main_query_is_run_once_for_random_requests(): array { + public static function data_random_requests(): array { return array( 'random post' => array( '/random/' ), 'random empty archive' => array( '/random/category/empty/' ), @@ -2629,7 +2629,7 @@ static function ( WP_Query $query ): void { /** * Ensure a post named "random" takes precedence over random content redirects. * - * @dataProvider data_post_named_random_takes_precedence_over_random_content_redirects + * @dataProvider data_post_name_at_the_root_structures * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. * @@ -2657,7 +2657,7 @@ public function test_post_named_random_takes_precedence_over_random_content_redi * * @return array */ - public static function data_post_named_random_takes_precedence_over_random_content_redirects(): array { + public static function data_post_name_at_the_root_structures(): array { return array( 'post name' => array( '/%postname%/' ), 'pathinfo' => array( '/index.php/%postname%/' ), diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index e8e2cd5db20de..56a1b35b81a6e 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -89,7 +89,7 @@ private function get_stored_rewrite_rules(): array { } /** - * @dataProvider data_core_random_rewrite_rules_are_registered + * @dataProvider data_core_random_rewrite_rules * * @param string $regex Expected rewrite rule regex. * @param string $query Expected rewrite rule query. @@ -106,7 +106,7 @@ public function test_core_random_rewrite_rules_are_registered( $regex, $query ): * * @return array */ - public static function data_core_random_rewrite_rules_are_registered(): array { + public static function data_core_random_rewrite_rules(): array { return array( 'any post' => array( 'random/?$', 'index.php?random=1' ), 'category' => array( 'random/category/(.+?)/?$', 'index.php?category_name=$matches[1]&random=1' ), @@ -715,7 +715,7 @@ public static function data_random_rewrite_rules_precede_other_matching_rules(): } /** - * @dataProvider data_random_urls_resolve_to_random_query_vars + * @dataProvider data_random_urls * * @global WP $wp Current WordPress environment instance. * @@ -755,7 +755,7 @@ public function test_random_urls_resolve_to_random_query_vars( $path, $expected_ * * @return array, 2?: string}> */ - public static function data_random_urls_resolve_to_random_query_vars(): array { + public static function data_random_urls(): array { return array( 'random' => array( '/random/', array( 'random' => '1' ) ), 'random without slash' => array( '/random', array( 'random' => '1' ) ), @@ -827,7 +827,7 @@ public function test_random_root_rule_is_omitted_when_a_page_named_random_exists } /** - * @dataProvider data_random_root_rule_is_kept_for_pages_that_do_not_conflict + * @dataProvider data_pages_that_do_not_conflict * * @param array $args Page arguments. A `post_parent` is replaced with a parent page ID. */ @@ -846,7 +846,7 @@ public function test_random_root_rule_is_kept_for_pages_that_do_not_conflict( $a * * @return array}> */ - public static function data_random_root_rule_is_kept_for_pages_that_do_not_conflict(): array { + public static function data_pages_that_do_not_conflict(): array { return array( 'slug starting with random' => array( array( 'post_name' => 'random-is-just-patterns-we-cant-decipher' ) ), 'draft page named random' => array( @@ -1000,7 +1000,7 @@ public static function data_post_permalinks_share_random_base(): array { /** * @covers WP_Rewrite::post_permalinks_share_random_base * - * @dataProvider data_random_root_rule_is_omitted_when_a_post_named_random_exists + * @dataProvider data_post_name_at_the_root_structures * * @param string $structure Permalink structure. */ @@ -1022,7 +1022,7 @@ public function test_random_root_rule_is_omitted_when_a_post_named_random_exists * * @return array */ - public static function data_random_root_rule_is_omitted_when_a_post_named_random_exists(): array { + public static function data_post_name_at_the_root_structures(): array { return array( 'post name' => array( '/%postname%/' ), 'pathinfo' => array( '/index.php/%postname%/' ), @@ -1030,7 +1030,7 @@ public static function data_random_root_rule_is_omitted_when_a_post_named_random } /** - * @dataProvider data_random_root_rule_is_kept_for_posts_that_do_not_conflict + * @dataProvider data_posts_that_do_not_conflict * * @param string $structure Permalink structure. * @param array $args Post arguments. @@ -1049,7 +1049,7 @@ public function test_random_root_rule_is_kept_for_posts_that_do_not_conflict( st * * @return array}> */ - public static function data_random_root_rule_is_kept_for_posts_that_do_not_conflict(): array { + public static function data_posts_that_do_not_conflict(): array { return array( 'blog prefix' => array( '/blog/%postname%/', array() ), 'date and post name' => array( '/%year%/%monthnum%/%postname%/', array() ), diff --git a/tests/phpunit/tests/wp/handleRandom.php b/tests/phpunit/tests/wp/handleRandom.php index e73b5f5a7f0e9..8fe206cb6b15e 100644 --- a/tests/phpunit/tests/wp/handleRandom.php +++ b/tests/phpunit/tests/wp/handleRandom.php @@ -72,7 +72,7 @@ protected function handle_random( array $query_vars ): bool { * * @ticket 64498 * - * @dataProvider data_requests_without_the_random_query_var_are_not_handled + * @dataProvider data_requests_without_the_random_query_var * * @param array $query_vars Query variables for the request. */ @@ -89,7 +89,7 @@ public function test_requests_without_the_random_query_var_are_not_handled( arra * * @return array }> */ - public static function data_requests_without_the_random_query_var_are_not_handled(): array { + public static function data_requests_without_the_random_query_var(): array { return array( 'no query vars' => array( array() ), 'other query vars' => array( array( 'post_type' => 'post' ) ), @@ -140,7 +140,7 @@ public function test_random_content_queries_are_not_cached(): void { * * @ticket 64498 * - * @dataProvider data_main_query_runs_unchanged_for_requests_that_are_not_random_content_requests + * @dataProvider data_requests_that_are_not_random_content_requests * * @param array $query_vars Query variables for the request. * @param string $request_method HTTP request method. @@ -164,7 +164,7 @@ public function test_main_query_runs_unchanged_for_requests_that_are_not_random_ * * @return array, 1: non-falsy-string }> */ - public static function data_main_query_runs_unchanged_for_requests_that_are_not_random_content_requests(): array { + public static function data_requests_that_are_not_random_content_requests(): array { return array( 'post request' => array( array( 'random' => '' ), 'POST' ), 'non-randomable without posts' => array( @@ -182,7 +182,7 @@ public static function data_main_query_runs_unchanged_for_requests_that_are_not_ * * @ticket 64498 * - * @dataProvider data_request_continues_with_the_selected_post_when_it_is_not_redirected + * @dataProvider data_permalinks_that_cannot_be_redirected_to * * @param string $permalink Permalink of the selected post. */ @@ -207,7 +207,7 @@ static function () use ( $permalink ): string { * * @return array */ - public static function data_request_continues_with_the_selected_post_when_it_is_not_redirected(): array { + public static function data_permalinks_that_cannot_be_redirected_to(): array { return array( 'empty permalink' => array( '' ), 'off-site permalink' => array( 'https://elsewhere.example/post/' ), From 85cb495245b1fe048dc566ac0e2bdb8978bc86fe Mon Sep 17 00:00:00 2001 From: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:14:00 +1100 Subject: [PATCH 12/20] Expanded test description. Co-authored-by: Aaron Jorbin --- tests/phpunit/tests/query/randomContentRedirect.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index 0dc763dd3d66a..6b41d902826ac 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -2872,6 +2872,10 @@ public function test_random_archives_redirect_when_a_post_type_archive_is_at_the /** * Ensure the random base is not redirected to random content when an existing rule takes it. * + * The dataProvider supplies a top-priority rewrite rule, each one a different regex spelling that + * matches the path `/random` and points to a specific `page`. This ensures that rewrite rule + * takes priority over the random ones. + * * @dataProvider data_random_base_is_not_redirected_when_an_existing_rule_takes_it * * @param string $regex Rewrite rule regex. From c9d6608437f139c0af2c91718842d153b0081e90 Mon Sep 17 00:00:00 2001 From: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:15:01 +1100 Subject: [PATCH 13/20] Fix docblock type. Co-authored-by: Aaron Jorbin --- src/wp-includes/class-wp-rewrite.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/class-wp-rewrite.php b/src/wp-includes/class-wp-rewrite.php index 3f41761340c16..9ed414def49b8 100644 --- a/src/wp-includes/class-wp-rewrite.php +++ b/src/wp-includes/class-wp-rewrite.php @@ -1565,11 +1565,11 @@ public function rewrite_rules() { * * @see wp_is_random_content_redirect_enabled() * - * @param array|null $existing_rules Optional. Existing rewrite rules to check for rules using the - * random base, keyed by their regex pattern. Generic catch-all - * rules, such as the page and post rules, should not be included - * as they match any path. Default null, the extra rules added - * via WP_Rewrite::add_rule() and WP_Rewrite::add_permastruct(). + * @param string[]|null $existing_rules Optional. Existing rewrite rules to check for rules using the + * random base, keyed by their regex pattern. Generic catch-all + * rules, such as the page and post rules, should not be included + * as they match any path. Default null, the extra rules added + * via WP_Rewrite::add_rule() and WP_Rewrite::add_permastruct(). * @return array Array of rewrite rules for random content redirects, keyed by their regex pattern. * * @phpstan-return array From b692b4ba59124ff917d16113677a9d7615534b85 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Tue, 10 Nov 2026 14:44:55 +1100 Subject: [PATCH 14/20] Is @aaronjorbin trolling? --- tests/phpunit/tests/query/randomContentRedirect.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index 6b41d902826ac..4118ba495d3d4 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -2872,8 +2872,8 @@ public function test_random_archives_redirect_when_a_post_type_archive_is_at_the /** * Ensure the random base is not redirected to random content when an existing rule takes it. * - * The dataProvider supplies a top-priority rewrite rule, each one a different regex spelling that - * matches the path `/random` and points to a specific `page`. This ensures that rewrite rule + * The dataProvider supplies a top-priority rewrite rule, each one a different regex spelling that + * matches the path `/random` and points to a specific `page`. This ensures that rewrite rule * takes priority over the random ones. * * @dataProvider data_random_base_is_not_redirected_when_an_existing_rule_takes_it From 28be967e3787d80871a0b3c2d3c9e6d0f476a7e2 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Tue, 10 Nov 2026 14:47:20 +1100 Subject: [PATCH 15/20] Exclude bottom rules. --- src/wp-includes/class-wp-rewrite.php | 17 ++++++++--- .../tests/query/randomContentRedirect.php | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/class-wp-rewrite.php b/src/wp-includes/class-wp-rewrite.php index 9ed414def49b8..3ccb9b31f61c6 100644 --- a/src/wp-includes/class-wp-rewrite.php +++ b/src/wp-includes/class-wp-rewrite.php @@ -1476,9 +1476,14 @@ public function rewrite_rules() { * random base. The page and post rules are generic catch-all rules matching * any path, so are not checked. Pages and posts at the random base are * accounted for separately by WP_Rewrite::random_rewrite_rules(). + * + * Rules added to the bottom are not checked either. The page and post rules + * precede them, so they are never matched for the random base. A bottom rule + * using the same regex as the random root rule replaces its query instead, + * taking its position ahead of the page and post rules. */ $random_rewrite = $this->random_rewrite_rules( - array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite, $this->extra_rules ) + array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite ) ); /** @@ -1568,8 +1573,9 @@ public function rewrite_rules() { * @param string[]|null $existing_rules Optional. Existing rewrite rules to check for rules using the * random base, keyed by their regex pattern. Generic catch-all * rules, such as the page and post rules, should not be included - * as they match any path. Default null, the extra rules added - * via WP_Rewrite::add_rule() and WP_Rewrite::add_permastruct(). + * as they match any path. Nor should rules added to the bottom, + * as the page and post rules precede them. Default null, the + * rules added to the top via WP_Rewrite::add_rule(). * @return array Array of rewrite rules for random content redirects, keyed by their regex pattern. * * @phpstan-return array @@ -1608,8 +1614,11 @@ public function random_rewrite_rules( ?array $existing_rules = null ): array { * The generic catch-all page and post rules match any path, including the * random base, so are excluded from the existing rules. Pages and posts at * the random base, which these rules serve, are accounted for above. + * + * Rules added to the bottom are excluded as the page and post rules precede + * them, see WP_Rewrite::rewrite_rules(). */ - $existing_rules ??= array_merge( $this->extra_rules_top, $this->extra_rules ); + $existing_rules ??= $this->extra_rules_top; foreach ( $existing_rules as $regex => $query ) { /* diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index 4118ba495d3d4..dc09d48f92297 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -2956,9 +2956,38 @@ public static function data_random_base_redirects_when_existing_rules_do_not_tak 'requires a sub path' => array( '^random/(.*)?', 'top' ), 'catch-all' => array( '(.+?)/?$', 'bottom' ), 'single segment catch-all' => array( '([^/]+)/?$', 'bottom' ), + 'bottom anchored match' => array( '^random/?$', 'bottom' ), + 'bottom alternation' => array( '(random|pens|pencils)/?$', 'bottom' ), + 'bottom unterminated' => array( 'random', 'bottom' ), ); } + /** + * Ensure a bottom rule using the random root rule's regex takes the random base. + * + * Rules added to the bottom are preceded by the generic page and post rules, so are + * never matched for the random base. A bottom rule using the same regex as the random + * root rule replaces the root rule's query while keeping its position ahead of the + * page and post rules, so takes the random base. + */ + public function test_bottom_rule_using_the_random_root_rule_regex_takes_the_random_base(): void { + $page = $this->create_post( + array( + 'post_type' => 'page', + 'post_name' => 'wptests-taken', + ) + ); + + add_rewrite_rule( 'random/?$', 'index.php?pagename=wptests-taken', 'bottom' ); + flush_rewrite_rules(); + + $this->go_to( home_url( '/random/' ) ); + + $this->assertNotRedirected( 'A bottom rule using the root rule regex should not redirect to random content.' ); + $this->assertQueryTrue( 'is_page', 'is_singular' ); + $this->assertSame( $page, get_queried_object_id(), 'The page served by the bottom rule using the root rule regex should be queried.' ); + } + /** * Ensure a category at the random base with the category base removed is not redirected to random content. * From f10e2daf5b4735019f742b02f028a0c12d70e693 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Tue, 10 Nov 2026 15:15:56 +1100 Subject: [PATCH 16/20] KISS. --- src/wp-includes/class-wp-rewrite.php | 110 ++---- .../tests/rewrite/randomRewriteRules.php | 344 ++++-------------- 2 files changed, 87 insertions(+), 367 deletions(-) diff --git a/src/wp-includes/class-wp-rewrite.php b/src/wp-includes/class-wp-rewrite.php index 3ccb9b31f61c6..ae0d20710ba6c 100644 --- a/src/wp-includes/class-wp-rewrite.php +++ b/src/wp-includes/class-wp-rewrite.php @@ -1469,22 +1469,20 @@ public function rewrite_rules() { $this->extra_rules_top = array_merge( $this->extra_rules_top, $rules ); } + // Random content rewrite rules. + $random_rewrite = $this->random_rewrite_rules(); + /* - * Random content rewrite rules. - * - * The random content rules check whether an existing rule already uses the - * random base. The page and post rules are generic catch-all rules matching - * any path, so are not checked. Pages and posts at the random base are - * accounted for separately by WP_Rewrite::random_rewrite_rules(). + * Rules added to the top, including those for post types and taxonomies, take + * precedence over random content rules using the same regex, for example a post + * type archive at `/random/`. * - * Rules added to the bottom are not checked either. The page and post rules - * precede them, so they are never matched for the random base. A bottom rule - * using the same regex as the random root rule replaces its query instead, - * taking its position ahead of the page and post rules. + * Rules added to the top are merged ahead of the random content rules, so without + * this, the random content rule's query would replace the query of a rule using the + * same regex. Rules added to the top using a different regex for the same path, + * such as `^random/?$`, already take precedence as they are matched first. */ - $random_rewrite = $this->random_rewrite_rules( - array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite ) - ); + $random_rewrite = array_diff_key( $random_rewrite, $this->extra_rules_top ); /** * Filters rewrite rules used for random content redirects. @@ -1549,19 +1547,21 @@ public function rewrite_rules() { * the URL, see {@see WP_Rewrite::post_permalinks_share_random_base()}. The rules are flushed * when such a page or post changes, see {@see wp_random_content_flush_rewrite_rules_for_post()}. * - * The `/random/` rule is also omitted if an existing rewrite rule matches the random base, - * for example a post type archive at `/random/`. Generic catch-all rules matching any path, - * such as the page and post rules, are ignored. See the {@see 'wp_rewrite_random_base_is_taken'} + * Whether the `/random/` rule is omitted can be changed with the {@see 'wp_rewrite_random_base_is_taken'} * filter. * * Rules added to the top of the rewrite rules, including those for post types and taxonomies, - * take precedence over the random content rules. If a post type or taxonomy uses the random - * base as its rewrite slug, its rules may match random archive URLs before the random content - * rules do. For example, the attachment rules of a post type with the rewrite slug `random` - * match `/random/category/news/`, and the rules of a hierarchical post type or taxonomy match - * every URL beginning with `/random/`. Such random archive URLs display the post type or - * taxonomy content instead. To use random archives on these sites, change the random base on - * the Permalinks screen. + * take precedence over the random content rules. When the rewrite rules are generated, random + * content rules using the same regex as a rule added to the top are omitted, see + * {@see WP_Rewrite::rewrite_rules()}. For example, a post type archive at `/random/` replaces + * the `/random/` rule. + * + * If a post type or taxonomy uses the random base as its rewrite slug, its rules may match + * random archive URLs before the random content rules do. For example, the attachment rules + * of a post type with the rewrite slug `random` match `/random/category/news/`, and the rules + * of a hierarchical post type or taxonomy match every URL beginning with `/random/`. Such + * random archive URLs display the post type or taxonomy content instead. To use random + * archives on these sites, change the random base on the Permalinks screen. * * The `random` query variable can be added to any other archive or search URL, for example * `/2026/?random`, to request a random post from that archive. @@ -1570,24 +1570,17 @@ public function rewrite_rules() { * * @see wp_is_random_content_redirect_enabled() * - * @param string[]|null $existing_rules Optional. Existing rewrite rules to check for rules using the - * random base, keyed by their regex pattern. Generic catch-all - * rules, such as the page and post rules, should not be included - * as they match any path. Nor should rules added to the bottom, - * as the page and post rules precede them. Default null, the - * rules added to the top via WP_Rewrite::add_rule(). * @return array Array of rewrite rules for random content redirects, keyed by their regex pattern. * * @phpstan-return array */ - public function random_rewrite_rules( ?array $existing_rules = null ): array { + public function random_rewrite_rules(): array { if ( ! wp_is_random_content_redirect_enabled() ) { return array(); } - $random_slug = $this->get_random_base(); - $random_base = $this->root . $random_slug; - $catch_all_test_base = "{$this->root}catchall{$random_slug}"; // No hyphens to account for [A-z0-9] rules. + $random_slug = $this->get_random_base(); + $random_base = $this->root . $random_slug; $rules = array(); @@ -1602,57 +1595,6 @@ public function random_rewrite_rules( ?array $existing_rules = null ): array { fn( $random_base_post ) => $random_base_post instanceof WP_Post && is_post_publicly_viewable( $random_base_post ) ); - if ( ! $random_base_is_taken ) { - /* - * WP::parse_request() trims slashes from the requested path before matching - * it against the rules, so the path is matched without a trailing slash - * regardless of the permalink structure. - */ - $random_base_path = untrailingslashit( $random_base ); - - /* - * The generic catch-all page and post rules match any path, including the - * random base, so are excluded from the existing rules. Pages and posts at - * the random base, which these rules serve, are accounted for above. - * - * Rules added to the bottom are excluded as the page and post rules precede - * them, see WP_Rewrite::rewrite_rules(). - */ - $existing_rules ??= $this->extra_rules_top; - - foreach ( $existing_rules as $regex => $query ) { - /* - * Match the same way WP::parse_request() does, so the regex is anchored to - * the start of the path whether or not it includes a leading caret. - * - * Matching the regex rather than comparing strings accounts for rules - * written in any form, such as `random/?$`, `^random/?$`, `(random|pens)/?$`, - * `(rand)om/?$` or `[r]andom/?$`, while rules for similarly named paths, - * such as `randomful/?$` or `random-ful/?$`, do not match. - * - * A rule requiring a trailing slash, such as `^random/(.*)?`, never matches - * a request for the random base, so it does not take it. - */ - $pattern = "#^{$regex}#"; - if ( ! preg_match( $pattern, $random_base_path ) ) { - continue; - } - - /* - * Generic catch-all rules, such as `(.+?)/?$` or `([^/]+)/?$`, match any - * path including the random base. A rule also matching a path other than - * the random base, for example `notrandom`, is a catch-all rule so does - * not take the random base. - */ - if ( preg_match( $pattern, $catch_all_test_base ) ) { - continue; - } - - $random_base_is_taken = true; - break; - } - } - /** * Filters whether the random content redirect base is taken. * diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index 56a1b35b81a6e..904846b57c70c 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -1067,82 +1067,24 @@ public static function data_posts_that_do_not_conflict(): array { } /** - * Ensure the root rule is kept when the rules are regenerated. + * Ensure random rules using the same regex as a rule added to the top are omitted. * - * Once generated, the root rule is in the existing rules and must not be treated - * as taking the random base itself. + * Rules added to the top are merged ahead of the random rules, so a random rule using + * the same regex would otherwise replace the top rule's query. * - * @global WP_Rewrite $wp_rewrite WordPress rewrite component. - */ - public function test_random_root_rule_is_kept_when_the_rules_are_regenerated(): void { - global $wp_rewrite; - - $this->get_random_rewrite_rules(); - $this->assertArrayHasKey( 'random/?$', $wp_rewrite->rules, 'The root rule should be in the generated rules.' ); - - $rules = $wp_rewrite->random_rewrite_rules(); - - $this->assertArrayHasKey( 'random/?$', $rules, 'The root rule should be kept when the rules are regenerated.' ); - } - - /** - * Ensure the root rule is omitted when an existing rule takes the random base. + * @dataProvider data_random_rules_using_the_regex_of_a_top_rule_are_omitted * - * @dataProvider data_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base - * - * @param string $regex Rewrite rule regex. + * @param string $regex Rewrite rule regex. + * @param string $random_rule A random rule using a different regex, expected to be kept. */ - public function test_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base( string $regex ): void { + public function test_random_rules_using_the_regex_of_a_top_rule_are_omitted( string $regex, string $random_rule ): void { add_rewrite_rule( $regex, 'index.php?pagename=wptests-taken', 'top' ); - $rules = $this->get_random_rewrite_rules(); - - $this->assertArrayNotHasKey( 'random/?$', $rules, "The root rule should be omitted when the rule {$regex} takes the random base." ); - $this->assertArrayHasKey( 'random/category/(.+?)/?$', $rules, 'Random archive rules should be kept when the random base is taken.' ); - $this->assertArrayNotHasKey( 'random/?$', $this->filter_random_rules( $this->get_stored_rewrite_rules() ), 'The stored rules should not include the root rule.' ); - } - - /** - * Data provider. - * - * @return array - */ - public static function data_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base(): array { - return array( - 'exact match' => array( 'random/?$' ), - 'anchored exact match' => array( '^random/?$' ), - 'unterminated' => array( 'random' ), - 'optional suffix' => array( 'random(ful)?/?$' ), - 'optional non-capturing suffix' => array( 'random(?:-ful)?/?$' ), - 'anchored optional suffix' => array( '^random(?:-ful)?/?$' ), - 'alternation, first' => array( '(random|pens|pencils)/?$' ), - 'alternation, last' => array( '(pens|pencils|random)/?$' ), - 'anchored alternation' => array( '^(random|pens|pencils)/?$' ), - 'non-capturing alternation' => array( '(?:pens|random)/?$' ), - 'partial group' => array( '(rand)om/?$' ), - 'anchored partial group' => array( '^(rand)om/?$' ), - 'group in the middle' => array( 'r(and)om/?$' ), - 'character class' => array( '[r]andom/?$' ), - 'character class range' => array( '[a-z]andom/?$' ), - 'wildcard' => array( '.andom/?$' ), - 'optional final character' => array( 'random?/?$' ), - ); - } - - /** - * Ensure the root rule is kept when existing rules do not take the random base. - * - * @dataProvider data_random_root_rule_is_kept_when_existing_rules_do_not_take_the_random_base - * - * @param string $regex Rewrite rule regex. - */ - public function test_random_root_rule_is_kept_when_existing_rules_do_not_take_the_random_base( string $regex ): void { - add_rewrite_rule( $regex, 'index.php?pagename=wptests-not-taken', 'top' ); - - $rules = $this->get_random_rewrite_rules(); + $this->get_random_rewrite_rules(); + $rules = $this->get_stored_rewrite_rules(); - $this->assertArrayHasKey( 'random/?$', $rules, "The root rule should be kept when the rule {$regex} does not take the random base." ); - $this->assertSame( 'index.php?random=1', $rules['random/?$'], 'The root rule should set the random query variable.' ); + $this->assertSame( 'index.php?pagename=wptests-taken', $rules[ $regex ] ?? null, "The top rule {$regex} should take precedence over the random rule using the same regex." ); + $this->assertStringContainsString( 'random=1', $rules[ $random_rule ] ?? '', "The random rule {$random_rule} should be kept when the top rule {$regex} uses a different regex." ); } /** @@ -1150,147 +1092,40 @@ public function test_random_root_rule_is_kept_when_existing_rules_do_not_take_th * * @return array */ - public static function data_random_root_rule_is_kept_when_existing_rules_do_not_take_the_random_base(): array { + public static function data_random_rules_using_the_regex_of_a_top_rule_are_omitted(): array { return array( - 'suffixed slug' => array( 'randomful/?$' ), - 'hyphenated suffixed slug' => array( 'random-ful/?$' ), - 'anchored suffixed slug' => array( '^randomful/?$' ), - 'anchored hyphenated slug' => array( '^random-ful/?$' ), - 'suffixed slug with sub path' => array( '^randomful/(.*)?' ), - 'hyphenated slug feed' => array( 'random-ful/feed/(feed|rdf|rss|rss2|atom)/?$' ), - 'required suffix' => array( 'random(ful|ness)/?$' ), - 'prefixed slug' => array( 'not-random/?$' ), - 'anchored prefixed slug' => array( '^not-random/?$' ), - 'sub path of the random base' => array( 'random/wptests/?$' ), - 'catch-all' => array( '(.+?)/?$' ), - 'anchored catch-all' => array( '^(.+?)/?$' ), - 'single segment catch-all' => array( '([^/]+)/?$' ), - 'letters only catch-all' => array( '([a-z]+)/?$' ), - 'page catch-all' => array( '(.?.+?)(?:/([0-9]+))?/?$' ), - 'alternation without random' => array( '(pens|pencils)/?$' ), - 'alternation of similar slugs' => array( '(randomful|random-ful)/?$' ), - 'partial group, suffixed' => array( '(rand)omful/?$' ), - 'character class, suffixed' => array( '[r]andomful/?$' ), - ); - } - - /** - * Ensure a request for the random base is a random content request when existing rules do not take the random base. - * - * @dataProvider data_random_base_request_matches_the_root_rule_when_existing_rules_do_not_take_the_random_base - * - * @global WP $wp Current WordPress environment instance. - * @global WP_Rewrite $wp_rewrite WordPress rewrite component. - * - * @param string $regex Rewrite rule regex. - * @param string $position Position of the rewrite rule, either 'top' or 'bottom'. - * @param string $structure Permalink structure. - * @param string $home_path Path of the site's home URL, for sites installed in a subdirectory. - */ - public function test_random_base_request_matches_the_root_rule_when_existing_rules_do_not_take_the_random_base( string $regex, string $position, string $structure, string $home_path ): void { - global $wp, $wp_rewrite; - - if ( '' !== $home_path ) { - update_option( 'home', untrailingslashit( home_url() ) . $home_path ); - } - - $this->set_permalink_structure( $structure ); - create_initial_taxonomies(); - - add_rewrite_rule( $regex, 'index.php?pagename=wptests-not-taken', $position ); - flush_rewrite_rules(); - - $this->go_to( home_url( user_trailingslashit( $wp_rewrite->root . 'random' ) ) ); - - $this->assertSame( $wp_rewrite->root . 'random/?$', $wp->matched_rule, "A request for the random base should match the root rule rather than {$wp->matched_rule}." ); - $this->assertSame( '1', $wp->query_vars['random'] ?? null, 'The random query variable should be set for a request for the random base.' ); - } - - /** - * Data provider. - * - * Each rule is tested with each permalink structure. For PATHINFO permalink structures, - * each rule is also tested prefixed with `index.php/`, as rules for such sites usually are. - * - * Catch-all rules are added to the bottom of the rules. A catch-all rule added to the top - * takes precedence over all of the rules WordPress generates, including the random content - * rules, so matches every request, as intended by the 'top' position. - * - * @return array - */ - public static function data_random_base_request_matches_the_root_rule_when_existing_rules_do_not_take_the_random_base(): array { - $structures = array( - 'post name' => array( '/%postname%/', '' ), - 'no trailing slash' => array( '/%postname%', '' ), - 'verbose page rules' => array( '/%category%/%postname%/', '' ), - 'day and name' => array( '/%year%/%monthnum%/%day%/%postname%/', '' ), - 'numeric' => array( '/archives/%post_id%', '' ), - 'subdirectory install' => array( '/%postname%/', '/wptests-subdirectory' ), - 'pathinfo post name' => array( '/index.php/%postname%/', '' ), - 'pathinfo day and name' => array( '/index.php/%year%/%monthnum%/%day%/%postname%/', '' ), - 'pathinfo no trailing' => array( '/index.php/%postname%', '' ), + 'root rule' => array( 'random/?$', 'random/category/(.+?)/?$' ), + 'category archive rule' => array( 'random/category/(.+?)/?$', 'random/?$' ), ); - - $data = array(); - foreach ( self::data_random_root_rule_is_kept_when_existing_rules_do_not_take_the_random_base() as $rule_name => $rule ) { - $position = str_contains( $rule_name, 'catch-all' ) ? 'bottom' : 'top'; - - foreach ( $structures as $structure_name => $structure ) { - $data[ "{$rule_name}, {$structure_name}" ] = array( $rule[0], $position, $structure[0], $structure[1] ); - - if ( str_starts_with( $structure[0], '/index.php/' ) ) { - $prefixed_rule = ( str_starts_with( $rule[0], '^' ) ? '^' : '' ) . 'index.php/' . ltrim( $rule[0], '^' ); - - $data[ "{$rule_name} prefixed with index.php, {$structure_name}" ] = array( $prefixed_rule, $position, $structure[0], $structure[1] ); - } - } - } - - return $data; } /** - * Ensure a rule requiring a trailing slash does not take the random base. - * - * This applies regardless of whether the permalink structure uses a trailing slash. - * WP::parse_request() trims slashes from the requested path, so such a rule never - * matches a request for the random base. + * Ensure random rules are kept when a rule added to the top uses a different regex for the random base. * - * @dataProvider data_rules_requiring_a_trailing_slash_do_not_take_the_random_base + * Such rules are matched before the random rules, so take precedence without the random rules being omitted. * * @global WP $wp Current WordPress environment instance. - * - * @param string $structure Permalink structure. - * @param string $path Request path for the random base. */ - public function test_rules_requiring_a_trailing_slash_do_not_take_the_random_base( string $structure, string $path ): void { + public function test_random_root_rule_is_kept_when_a_top_rule_uses_a_different_regex_for_the_random_base(): void { global $wp; - $this->set_permalink_structure( $structure ); - create_initial_taxonomies(); - - add_rewrite_rule( '^random/(.*)?', 'index.php?pagename=wptests-not-taken', 'top' ); + $page = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_name' => 'wptests-taken', + ) + ); - $rules = $this->get_random_rewrite_rules(); + add_rewrite_rule( '^random/?$', 'index.php?pagename=wptests-taken', 'top' ); - $this->assertArrayHasKey( 'random/?$', $rules, 'The root rule should be kept for a rule requiring a trailing slash.' ); + $this->get_random_rewrite_rules(); - $this->go_to( home_url( $path ) ); + $this->assertSame( 'index.php?random=1', $this->get_stored_rewrite_rules()['random/?$'] ?? null, 'The root rule should be kept when a top rule uses a different regex.' ); - $this->assertSame( 'random/?$', $wp->matched_rule, 'The random base should be matched by the root rule.' ); - $this->assertSame( '1', $wp->query_vars['random'] ?? null, 'The random query variable should be set.' ); - } + $this->go_to( home_url( '/random/' ) ); - /** - * Data provider. - * - * @return array - */ - public static function data_rules_requiring_a_trailing_slash_do_not_take_the_random_base(): array { - return array( - 'trailing slash' => array( '/%postname%/', '/random/' ), - 'no trailing slash' => array( '/%postname%', '/random' ), - ); + $this->assertSame( '^random/?$', $wp->matched_rule, 'The top rule should be matched before the root rule.' ); + $this->assertSame( $page, get_queried_object_id(), 'The page served by the top rule should be queried.' ); } /** @@ -1304,9 +1139,8 @@ public static function data_rules_requiring_a_trailing_slash_do_not_take_the_ran public function test_random_root_rule_is_omitted_for_a_post_type_archive_at_the_random_base( string $post_type, array $args ): void { register_post_type( $post_type, $args ); - $rules = $this->get_random_rewrite_rules(); + $this->get_random_rewrite_rules(); - $this->assertArrayNotHasKey( 'random/?$', $rules, 'The root rule should be omitted for a post type archive at the random base.' ); $this->assertSame( "index.php?post_type={$post_type}", $this->get_stored_rewrite_rules()['random/?$'] ?? null, 'The post type archive should be reachable at the random base.' ); } @@ -1329,7 +1163,6 @@ public static function data_random_root_rule_is_omitted_for_a_post_type_archive_ array( 'public' => true, 'has_archive' => 'random', - 'rewrite' => array( 'slug' => 'random' ), ), ), 'rewrite slug random' => array( @@ -1359,9 +1192,9 @@ public function test_random_root_rule_is_kept_for_a_post_type_archive_with_a_sim ) ); - $rules = $this->get_random_rewrite_rules(); + $this->get_random_rewrite_rules(); - $this->assertArrayHasKey( 'random/?$', $rules, "The root rule should be kept for a post type archive at {$slug}." ); + $this->assertSame( 'index.php?random=1', $this->get_stored_rewrite_rules()['random/?$'] ?? null, "The root rule should be kept for a post type archive at {$slug}." ); } /** @@ -1378,97 +1211,49 @@ public static function data_random_root_rule_is_kept_for_a_post_type_archive_wit } /** - * Ensure the root rule is omitted when an existing rule takes a custom random base. + * Ensure the root rule is omitted when a top rule uses its regex with a custom random base. * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. */ - public function test_random_root_rule_is_omitted_when_an_existing_rule_takes_a_custom_random_base(): void { + public function test_random_root_rule_is_omitted_when_a_top_rule_uses_its_regex_with_a_custom_random_base(): void { global $wp_rewrite; $wp_rewrite->random_base = 'surprise-me'; - add_rewrite_rule( '^surprise-me/?$', 'index.php?pagename=wptests-taken', 'top' ); + add_rewrite_rule( 'surprise-me/?$', 'index.php?pagename=wptests-taken', 'top' ); - $rules = $this->get_random_rewrite_rules(); + $this->get_random_rewrite_rules(); - $this->assertArrayNotHasKey( 'surprise-me/?$', $rules, 'The root rule should be omitted when a rule takes the custom random base.' ); + $this->assertSame( 'index.php?pagename=wptests-taken', $this->get_stored_rewrite_rules()['surprise-me/?$'] ?? null, 'The top rule should take precedence over the root rule for the custom random base.' ); } /** - * Ensure a rule at the default random base does not take a custom random base. + * Ensure a top rule at the default random base does not replace the root rule for a custom random base. * * @global WP_Rewrite $wp_rewrite WordPress rewrite component. */ - public function test_random_root_rule_is_kept_when_an_existing_rule_uses_the_default_base_with_a_custom_random_base(): void { + public function test_random_root_rule_is_kept_when_a_top_rule_uses_the_default_base_with_a_custom_random_base(): void { global $wp_rewrite; $wp_rewrite->random_base = 'surprise-me'; - add_rewrite_rule( '^random/?$', 'index.php?pagename=wptests-taken', 'top' ); - - $rules = $this->get_random_rewrite_rules(); - - $this->assertArrayHasKey( 'surprise-me/?$', $rules, 'The root rule should be kept when a rule only uses the default random base.' ); - } + add_rewrite_rule( 'random/?$', 'index.php?pagename=wptests-taken', 'top' ); - /** - * Ensure the root rule is omitted when an existing rule takes the random base with PATHINFO permalinks. - * - * @dataProvider data_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base_with_pathinfo_permalinks - * - * @param string $regex Rewrite rule regex. - */ - public function test_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base_with_pathinfo_permalinks( string $regex ): void { - $this->set_permalink_structure( '/index.php/%postname%/' ); - create_initial_taxonomies(); - - add_rewrite_rule( $regex, 'index.php?pagename=wptests-taken', 'top' ); - - $rules = $this->get_random_rewrite_rules(); - - $this->assertArrayNotHasKey( 'index.php/random/?$', $rules, "The root rule should be omitted when the rule {$regex} takes the random base." ); - } + $this->get_random_rewrite_rules(); - /** - * Data provider. - * - * @return array - */ - public static function data_random_root_rule_is_omitted_when_an_existing_rule_takes_the_random_base_with_pathinfo_permalinks(): array { - return array( - 'exact match' => array( 'index.php/random/?$' ), - 'anchored exact match' => array( '^index.php/random/?$' ), - ); + $this->assertSame( 'index.php?random=1', $this->get_stored_rewrite_rules()['surprise-me/?$'] ?? null, 'The root rule for the custom random base should be kept when a top rule only uses the default random base.' ); } /** - * Ensure the root rule is kept when an existing catch-all rule matches the random base with PATHINFO permalinks. - * - * @dataProvider data_random_root_rule_is_kept_when_an_existing_catch_all_rule_matches_the_random_base_with_pathinfo_permalinks - * - * @param string $regex Rewrite rule regex. + * Ensure the root rule is omitted when a top rule uses its regex with PATHINFO permalinks. */ - public function test_random_root_rule_is_kept_when_an_existing_catch_all_rule_matches_the_random_base_with_pathinfo_permalinks( string $regex ): void { + public function test_random_root_rule_is_omitted_when_a_top_rule_uses_its_regex_with_pathinfo_permalinks(): void { $this->set_permalink_structure( '/index.php/%postname%/' ); create_initial_taxonomies(); - add_rewrite_rule( $regex, 'index.php?pagename=wptests-not-taken', 'top' ); - - $rules = $this->get_random_rewrite_rules(); + add_rewrite_rule( 'index.php/random/?$', 'index.php?pagename=wptests-taken', 'top' ); - $this->assertArrayHasKey( 'index.php/random/?$', $rules, "The root rule should be kept when the catch-all rule {$regex} matches the random base." ); - } + $this->get_random_rewrite_rules(); - /** - * Data provider. - * - * @return array - */ - public static function data_random_root_rule_is_kept_when_an_existing_catch_all_rule_matches_the_random_base_with_pathinfo_permalinks(): array { - return array( - 'catch-all' => array( 'index.php/(.+?)/?$' ), - 'anchored catch-all' => array( '^index.php/(.+?)/?$' ), - 'single segment catch-all' => array( 'index.php/([^/]+)/?$' ), - 'page catch-all' => array( 'index.php/(.?.+?)(?:/([0-9]+))?/?$' ), - ); + $this->assertSame( 'index.php?pagename=wptests-taken', $this->get_stored_rewrite_rules()['index.php/random/?$'] ?? null, 'The top rule should take precedence over the root rule with PATHINFO permalinks.' ); } /** @@ -1484,22 +1269,32 @@ public function test_random_base_is_taken_filter_can_omit_the_root_rule(): void } /** - * Ensure the random base is taken filter can keep the root rule when a rule takes the random base. + * Ensure the random base is taken filter can keep the root rule when a page named "random" exists. */ public function test_random_base_is_taken_filter_can_keep_the_root_rule(): void { - add_rewrite_rule( '^random/?$', 'index.php?pagename=wptests-taken', 'top' ); + self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_name' => 'random', + ) + ); add_filter( 'wp_rewrite_random_base_is_taken', '__return_false' ); $rules = $this->get_random_rewrite_rules(); - $this->assertArrayHasKey( 'random/?$', $rules, 'The filter should be able to keep the root rule when a rule takes the random base.' ); + $this->assertArrayHasKey( 'random/?$', $rules, 'The filter should be able to keep the root rule when a page named random exists.' ); } /** * Ensure the random base is taken filter receives the detected value, the random base and the random slug. */ public function test_random_base_is_taken_filter_receives_the_detected_value_and_random_base(): void { - add_rewrite_rule( '^random/?$', 'index.php?pagename=wptests-taken', 'top' ); + self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_name' => 'random', + ) + ); $filter = new MockAction(); add_filter( 'wp_rewrite_random_base_is_taken', array( $filter, 'filter' ), 10, 3 ); @@ -1527,21 +1322,4 @@ public function test_random_base_is_taken_filter_receives_the_random_base_and_sl $this->assertNotEmpty( $args, 'The filter was not called with PATHINFO permalinks.' ); $this->assertSame( array( false, 'index.php/random', 'random' ), end( $args ), 'The filter should receive the random base with the PATHINFO prefix and the random slug without it.' ); } - - /** - * Filters rewrite rules to those setting the random query variable. - * - * @param array $rules Rewrite rules, keyed by their regex pattern. - * @return array Random content rewrite rules, keyed by their regex pattern. - */ - private function filter_random_rules( array $rules ): array { - return array_filter( - $rules, - static function ( $query ): bool { - parse_str( (string) wp_parse_url( (string) $query, PHP_URL_QUERY ), $query_vars ); - - return isset( $query_vars['random'] ); - } - ); - } } From fe560576409ff82440c3e0b0bbd3687e3f7a6c18 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Tue, 10 Nov 2026 15:25:56 +1100 Subject: [PATCH 17/20] Remove duplicate filter test. --- .../tests/query/randomContentRedirect.php | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index dc09d48f92297..2aa5e948254c6 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -3005,28 +3005,4 @@ public function test_category_at_the_random_base_without_a_category_base_is_not_ $this->assertQueryTrue( 'is_archive', 'is_category' ); $this->assertSame( $category, get_queried_object_id(), 'The category at the random base should be queried.' ); } - - /** - * Ensure the random base is not redirected to random content when filtered as taken. - */ - public function test_random_base_is_not_redirected_when_filtered_as_taken(): void { - add_filter( 'wp_rewrite_random_base_is_taken', '__return_true' ); - flush_rewrite_rules(); - - $this->go_to( home_url( '/random/' ) ); - - $this->assertNotRedirected( 'The random base should not redirect to random content when filtered as taken.' ); - } - - /** - * Ensure random archives redirect when the random base is filtered as taken. - */ - public function test_random_archives_redirect_when_the_random_base_is_filtered_as_taken(): void { - add_filter( 'wp_rewrite_random_base_is_taken', '__return_true' ); - flush_rewrite_rules(); - - $this->go_to( home_url( '/random/category/arts/' ) ); - - $this->assertSame( get_permalink( self::$arts_post ), $this->redirect_location, 'Random archives should redirect when the random base is filtered as taken.' ); - } } From d72e709ae214333c6fdc0b5547bf19a961979bc3 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 11 Nov 2026 09:21:27 +1100 Subject: [PATCH 18/20] Remove new filter in favour of existing `random_rewrite_rules`. --- src/wp-includes/class-wp-rewrite.php | 21 ------ .../tests/rewrite/randomRewriteRules.php | 67 ------------------- 2 files changed, 88 deletions(-) diff --git a/src/wp-includes/class-wp-rewrite.php b/src/wp-includes/class-wp-rewrite.php index ae0d20710ba6c..d9f7a61f2e1f7 100644 --- a/src/wp-includes/class-wp-rewrite.php +++ b/src/wp-includes/class-wp-rewrite.php @@ -1547,9 +1547,6 @@ public function rewrite_rules() { * the URL, see {@see WP_Rewrite::post_permalinks_share_random_base()}. The rules are flushed * when such a page or post changes, see {@see wp_random_content_flush_rewrite_rules_for_post()}. * - * Whether the `/random/` rule is omitted can be changed with the {@see 'wp_rewrite_random_base_is_taken'} - * filter. - * * Rules added to the top of the rewrite rules, including those for post types and taxonomies, * take precedence over the random content rules. When the rewrite rules are generated, random * content rules using the same regex as a rule added to the top are omitted, see @@ -1595,24 +1592,6 @@ public function random_rewrite_rules(): array { fn( $random_base_post ) => $random_base_post instanceof WP_Post && is_post_publicly_viewable( $random_base_post ) ); - /** - * Filters whether the random content redirect base is taken. - * - * When the random base is taken, the `/random/` rewrite rule is omitted. The random - * base may vary depending on the site's language or permalink settings, so always - * determine the value from the `$random_base` or `$random_slug` parameters. - * - * @since 7.2.0 - * - * @param bool $random_base_is_taken Whether the random base is known to be taken. - * @param string $random_base The random base for the site, including the PATHINFO - * prefix when using PATHINFO permalinks, for example - * `random` or `index.php/random`. - * @param string $random_slug The random base slug for the site, without the PATHINFO - * prefix, for example `random`. - */ - $random_base_is_taken = apply_filters( 'wp_rewrite_random_base_is_taken', $random_base_is_taken, $random_base, $random_slug ); - if ( ! $random_base_is_taken ) { $rules[ $random_base . '/?$' ] = $this->index . '?random=1'; } diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index 904846b57c70c..4d68afcb86b8c 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -1255,71 +1255,4 @@ public function test_random_root_rule_is_omitted_when_a_top_rule_uses_its_regex_ $this->assertSame( 'index.php?pagename=wptests-taken', $this->get_stored_rewrite_rules()['index.php/random/?$'] ?? null, 'The top rule should take precedence over the root rule with PATHINFO permalinks.' ); } - - /** - * Ensure the random base is taken filter can omit the root rule. - */ - public function test_random_base_is_taken_filter_can_omit_the_root_rule(): void { - add_filter( 'wp_rewrite_random_base_is_taken', '__return_true' ); - - $rules = $this->get_random_rewrite_rules(); - - $this->assertArrayNotHasKey( 'random/?$', $rules, 'The filter should be able to omit the root rule.' ); - $this->assertArrayHasKey( 'random/category/(.+?)/?$', $rules, 'Random archive rules should be kept when the filter omits the root rule.' ); - } - - /** - * Ensure the random base is taken filter can keep the root rule when a page named "random" exists. - */ - public function test_random_base_is_taken_filter_can_keep_the_root_rule(): void { - self::factory()->post->create( - array( - 'post_type' => 'page', - 'post_name' => 'random', - ) - ); - add_filter( 'wp_rewrite_random_base_is_taken', '__return_false' ); - - $rules = $this->get_random_rewrite_rules(); - - $this->assertArrayHasKey( 'random/?$', $rules, 'The filter should be able to keep the root rule when a page named random exists.' ); - } - - /** - * Ensure the random base is taken filter receives the detected value, the random base and the random slug. - */ - public function test_random_base_is_taken_filter_receives_the_detected_value_and_random_base(): void { - self::factory()->post->create( - array( - 'post_type' => 'page', - 'post_name' => 'random', - ) - ); - - $filter = new MockAction(); - add_filter( 'wp_rewrite_random_base_is_taken', array( $filter, 'filter' ), 10, 3 ); - - $this->get_random_rewrite_rules(); - - $args = $filter->get_args(); - $this->assertNotEmpty( $args, 'The filter was not called.' ); - $this->assertSame( array( true, 'random', 'random' ), end( $args ), 'The filter should receive whether the base is taken, the random base and the random slug.' ); - } - - /** - * Ensure the random base is taken filter receives the random base with the PATHINFO prefix and the random slug without it. - */ - public function test_random_base_is_taken_filter_receives_the_random_base_and_slug_with_pathinfo_permalinks(): void { - $this->set_permalink_structure( '/index.php/%postname%/' ); - create_initial_taxonomies(); - - $filter = new MockAction(); - add_filter( 'wp_rewrite_random_base_is_taken', array( $filter, 'filter' ), 10, 3 ); - - $this->get_random_rewrite_rules(); - - $args = $filter->get_args(); - $this->assertNotEmpty( $args, 'The filter was not called with PATHINFO permalinks.' ); - $this->assertSame( array( false, 'index.php/random', 'random' ), end( $args ), 'The filter should receive the random base with the PATHINFO prefix and the random slug without it.' ); - } } From d23af89e59683a0382446f8d2804428c2155a56c Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 11 Nov 2026 09:34:15 +1100 Subject: [PATCH 19/20] Revert source code changes. --- src/wp-includes/class-wp-rewrite.php | 62 ++++++++-------------------- 1 file changed, 17 insertions(+), 45 deletions(-) diff --git a/src/wp-includes/class-wp-rewrite.php b/src/wp-includes/class-wp-rewrite.php index d9f7a61f2e1f7..20967bafa0206 100644 --- a/src/wp-includes/class-wp-rewrite.php +++ b/src/wp-includes/class-wp-rewrite.php @@ -1423,6 +1423,22 @@ public function rewrite_rules() { */ $page_rewrite = apply_filters( 'page_rewrite_rules', $page_rewrite ); + // Random content rewrite rules. + $random_rewrite = $this->random_rewrite_rules(); + + /** + * Filters rewrite rules used for random content redirects. + * + * Likely random content rules would include `/random/`, as well as + * term, author and post type archives prefixed with the random base, + * for example `/random/category/news/`. + * + * @since 7.2.0 + * + * @param array $random_rewrite Array of rewrite rules for random content redirects, keyed by their regex pattern. + */ + $random_rewrite = apply_filters( 'random_rewrite_rules', $random_rewrite ); + // Extra permastructs. foreach ( $this->extra_permastructs as $permastructname => $struct ) { if ( is_array( $struct ) ) { @@ -1469,38 +1485,7 @@ public function rewrite_rules() { $this->extra_rules_top = array_merge( $this->extra_rules_top, $rules ); } - // Random content rewrite rules. - $random_rewrite = $this->random_rewrite_rules(); - - /* - * Rules added to the top, including those for post types and taxonomies, take - * precedence over random content rules using the same regex, for example a post - * type archive at `/random/`. - * - * Rules added to the top are merged ahead of the random content rules, so without - * this, the random content rule's query would replace the query of a rule using the - * same regex. Rules added to the top using a different regex for the same path, - * such as `^random/?$`, already take precedence as they are matched first. - */ - $random_rewrite = array_diff_key( $random_rewrite, $this->extra_rules_top ); - - /** - * Filters rewrite rules used for random content redirects. - * - * Likely random content rules would include `/random/`, as well as - * term, author and post type archives prefixed with the random base, - * for example `/random/category/news/`. - * - * @since 7.2.0 - * - * @param array $random_rewrite Array of rewrite rules for random content redirects, keyed by their regex pattern. - */ - $random_rewrite = apply_filters( 'random_rewrite_rules', $random_rewrite ); - - /* - * Put them together, with the random content rules ahead of the generic rules - * such as the page and post rules which would otherwise match them first. - */ + // Put them together. if ( $this->use_verbose_page_rules ) { $this->rules = array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $random_rewrite, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite, $page_rewrite, $post_rewrite, $this->extra_rules ); } else { @@ -1547,19 +1532,6 @@ public function rewrite_rules() { * the URL, see {@see WP_Rewrite::post_permalinks_share_random_base()}. The rules are flushed * when such a page or post changes, see {@see wp_random_content_flush_rewrite_rules_for_post()}. * - * Rules added to the top of the rewrite rules, including those for post types and taxonomies, - * take precedence over the random content rules. When the rewrite rules are generated, random - * content rules using the same regex as a rule added to the top are omitted, see - * {@see WP_Rewrite::rewrite_rules()}. For example, a post type archive at `/random/` replaces - * the `/random/` rule. - * - * If a post type or taxonomy uses the random base as its rewrite slug, its rules may match - * random archive URLs before the random content rules do. For example, the attachment rules - * of a post type with the rewrite slug `random` match `/random/category/news/`, and the rules - * of a hierarchical post type or taxonomy match every URL beginning with `/random/`. Such - * random archive URLs display the post type or taxonomy content instead. To use random - * archives on these sites, change the random base on the Permalinks screen. - * * The `random` query variable can be added to any other archive or search URL, for example * `/2026/?random`, to request a random post from that archive. * From 8916b12f13571e5f54b5ca882b30cf1c4af60b39 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 11 Nov 2026 09:49:49 +1100 Subject: [PATCH 20/20] Revert "Revert source code changes." This reverts commit d23af89e59683a0382446f8d2804428c2155a56c. --- src/wp-includes/class-wp-rewrite.php | 62 ++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/class-wp-rewrite.php b/src/wp-includes/class-wp-rewrite.php index 20967bafa0206..d9f7a61f2e1f7 100644 --- a/src/wp-includes/class-wp-rewrite.php +++ b/src/wp-includes/class-wp-rewrite.php @@ -1423,22 +1423,6 @@ public function rewrite_rules() { */ $page_rewrite = apply_filters( 'page_rewrite_rules', $page_rewrite ); - // Random content rewrite rules. - $random_rewrite = $this->random_rewrite_rules(); - - /** - * Filters rewrite rules used for random content redirects. - * - * Likely random content rules would include `/random/`, as well as - * term, author and post type archives prefixed with the random base, - * for example `/random/category/news/`. - * - * @since 7.2.0 - * - * @param array $random_rewrite Array of rewrite rules for random content redirects, keyed by their regex pattern. - */ - $random_rewrite = apply_filters( 'random_rewrite_rules', $random_rewrite ); - // Extra permastructs. foreach ( $this->extra_permastructs as $permastructname => $struct ) { if ( is_array( $struct ) ) { @@ -1485,7 +1469,38 @@ public function rewrite_rules() { $this->extra_rules_top = array_merge( $this->extra_rules_top, $rules ); } - // Put them together. + // Random content rewrite rules. + $random_rewrite = $this->random_rewrite_rules(); + + /* + * Rules added to the top, including those for post types and taxonomies, take + * precedence over random content rules using the same regex, for example a post + * type archive at `/random/`. + * + * Rules added to the top are merged ahead of the random content rules, so without + * this, the random content rule's query would replace the query of a rule using the + * same regex. Rules added to the top using a different regex for the same path, + * such as `^random/?$`, already take precedence as they are matched first. + */ + $random_rewrite = array_diff_key( $random_rewrite, $this->extra_rules_top ); + + /** + * Filters rewrite rules used for random content redirects. + * + * Likely random content rules would include `/random/`, as well as + * term, author and post type archives prefixed with the random base, + * for example `/random/category/news/`. + * + * @since 7.2.0 + * + * @param array $random_rewrite Array of rewrite rules for random content redirects, keyed by their regex pattern. + */ + $random_rewrite = apply_filters( 'random_rewrite_rules', $random_rewrite ); + + /* + * Put them together, with the random content rules ahead of the generic rules + * such as the page and post rules which would otherwise match them first. + */ if ( $this->use_verbose_page_rules ) { $this->rules = array_merge( $this->extra_rules_top, $robots_rewrite, $favicon_rewrite, $sitemap_rewrite, $deprecated_files, $registration_pages, $random_rewrite, $root_rewrite, $comments_rewrite, $search_rewrite, $author_rewrite, $date_rewrite, $page_rewrite, $post_rewrite, $this->extra_rules ); } else { @@ -1532,6 +1547,19 @@ public function rewrite_rules() { * the URL, see {@see WP_Rewrite::post_permalinks_share_random_base()}. The rules are flushed * when such a page or post changes, see {@see wp_random_content_flush_rewrite_rules_for_post()}. * + * Rules added to the top of the rewrite rules, including those for post types and taxonomies, + * take precedence over the random content rules. When the rewrite rules are generated, random + * content rules using the same regex as a rule added to the top are omitted, see + * {@see WP_Rewrite::rewrite_rules()}. For example, a post type archive at `/random/` replaces + * the `/random/` rule. + * + * If a post type or taxonomy uses the random base as its rewrite slug, its rules may match + * random archive URLs before the random content rules do. For example, the attachment rules + * of a post type with the rewrite slug `random` match `/random/category/news/`, and the rules + * of a hierarchical post type or taxonomy match every URL beginning with `/random/`. Such + * random archive URLs display the post type or taxonomy content instead. To use random + * archives on these sites, change the random base on the Permalinks screen. + * * The `random` query variable can be added to any other archive or search URL, for example * `/2026/?random`, to request a random post from that archive. *