Make WordPress Core

Changeset 62036


Ignore:
Timestamp:
03/17/2026 01:33:03 AM (5 months ago)
Author:
peterwilsoncc
Message:

Permalinks: Follow rewrite rules for trailing slashes in paginate_links().

Modifies the default settings of paginate_links() to only include trailing slashes, eg example.org/page/2/, when the permalink settings include a trailing slash. When the permalink settings do not include a slash, neither do the generated links, eg example.org/page/2.

This prevents the generated links from hitting a URL that subsequently redirects when the permalink structure does not include a trailing slash.

Props adamsilverstein, ankitkumarshah, audrasjb, hmbashar, huzaifaalmesbah, joedolson, juanmaguitar, krupajnanda, mai21, ozgursar, peterwilsoncc, rahulsprajapati, sirlouen, welcher, westonruter.
Fixes #61393.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/general-template.php

    r61768 r62036  
    46704670        $current = get_query_var( 'paged' ) ? (int) get_query_var( 'paged' ) : 1;
    46714671
    4672         // Append the format placeholder to the base URL.
    4673         $pagenum_link = trailingslashit( $url_parts[0] ) . '%_%';
     4672        /*
     4673         * Ensures sites not using trailing slashes get links in the form
     4674         * `/page/2` rather than `/page/2/`. On these sites, linking to the
     4675         * URL with a trailing slash will result in a 301 redirect from the
     4676         * incorrect URL to the correctly formatted one. This presents an
     4677         * unnecessary performance hit.
     4678         */
     4679        if ( $wp_rewrite->using_permalinks() && ! $wp_rewrite->use_trailing_slashes ) {
     4680                $pagenum_link = untrailingslashit( $url_parts[0] );
     4681        } else {
     4682                $pagenum_link = trailingslashit( $url_parts[0] );
     4683        }
     4684        $pagenum_link .= '%_%';
    46744685
    46754686        // URL base depends on permalink settings.
    46764687        $format  = $wp_rewrite->using_index_permalinks() && ! strpos( $pagenum_link, 'index.php' ) ? 'index.php/' : '';
    46774688        $format .= $wp_rewrite->using_permalinks() ? user_trailingslashit( $wp_rewrite->pagination_base . '/%#%', 'paged' ) : '?paged=%#%';
     4689        if ( $wp_rewrite->using_permalinks() && ! $wp_rewrite->use_trailing_slashes ) {
     4690                $format = '/' . ltrim( $format, '/' );
     4691        }
    46784692
    46794693        $defaults = array(
  • trunk/tests/phpunit/tests/general/paginateLinks.php

    r60100 r62036  
    88class Tests_General_PaginateLinks extends WP_UnitTestCase {
    99
    10         private $i18n_count = 0;
     10        private int $i18n_count = 0;
     11
     12        /**
     13         * Set up shared fixtures.
     14         *
     15         * @param WP_UnitTest_Factory $factory Factory instance.
     16         */
     17        public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ): void {
     18                $category_id = $factory->term->create(
     19                        array(
     20                                'taxonomy' => 'category',
     21                                'name'     => 'Categorized',
     22                        )
     23                );
     24                self::assertIsInt( $category_id );
     25
     26                $post_ids = $factory->post->create_many( 10 );
     27                foreach ( $post_ids as $post_id ) {
     28                        self::assertIsInt( $post_id );
     29                        self::assertIsArray( wp_set_post_categories( $post_id, array( $category_id ) ) );
     30                }
     31        }
    1132
    1233        public function set_up() {
     
    384405                $this->assertContains( "<a class=\"page-numbers\" href=\"$page_2_url\">2</a>", $links );
    385406        }
     407
     408        /**
     409         * Ensures pagination links include trailing slashes when the permalink structure includes them.
     410         *
     411         * @ticket 61393
     412         */
     413        public function test_permalinks_with_trailing_slash_produce_links_with_trailing_slashes(): void {
     414                update_option( 'posts_per_page', 2 );
     415                $this->set_permalink_structure( '/%postname%/' );
     416
     417                $this->go_to( '/category/categorized/page/2/' );
     418
     419                // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above.
     420                $links = paginate_links( array( 'current' => 2 ) );
     421
     422                $processor   = new WP_HTML_Tag_Processor( $links );
     423                $found_links = 0;
     424                while ( $processor->next_tag( 'A' ) ) {
     425                        ++$found_links;
     426                        $href = (string) $processor->get_attribute( 'href' );
     427                        $this->assertStringEndsWith( '/', $href, "Pagination links should end with a trailing slash, found: $href" );
     428                }
     429                $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' );
     430        }
     431
     432        /**
     433         * Ensures pagination links do not include trailing slashes when the permalink structure doesn't include them.
     434         *
     435         * @ticket 61393
     436         */
     437        public function test_permalinks_without_trailing_slash_produce_links_without_trailing_slashes(): void {
     438                update_option( 'posts_per_page', 2 );
     439                $this->set_permalink_structure( '/%postname%' );
     440
     441                $this->go_to( '/category/categorized/page/2' );
     442
     443                // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above.
     444                $links = paginate_links( array( 'current' => 2 ) );
     445
     446                $processor   = new WP_HTML_Tag_Processor( $links );
     447                $found_links = 0;
     448                while ( $processor->next_tag( 'A' ) ) {
     449                        ++$found_links;
     450                        $href = (string) $processor->get_attribute( 'href' );
     451                        $this->assertStringEndsNotWith( '/', $href, "Pagination links should not end with a trailing slash, found: $href" );
     452                }
     453                $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' );
     454        }
     455
     456        /**
     457         * Ensures pagination links do not include trailing slashes when the permalink structure is plain.
     458         *
     459         * @ticket 61393
     460         */
     461        public function test_plain_permalinks_are_not_modified_with_trailing_slash(): void {
     462                update_option( 'posts_per_page', 2 );
     463                $this->set_permalink_structure( '' );
     464
     465                $term = get_category_by_slug( 'categorized' );
     466                $this->assertInstanceOf( WP_Term::class, $term );
     467                $category_id = $term->term_id;
     468                $this->go_to( "/?cat={$category_id}&paged=2" );
     469
     470                // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above.
     471                $links = paginate_links( array( 'current' => 2 ) );
     472
     473                $expected_links = array(
     474                        home_url( "?cat={$category_id}" ), // Previous
     475                        home_url( "?cat={$category_id}" ), // Page 1
     476                        home_url( "?paged=3&cat={$category_id}" ), // Page 3
     477                        home_url( "?paged=4&cat={$category_id}" ), // Page 4
     478                        home_url( "?paged=5&cat={$category_id}" ), // Page 5
     479                        home_url( "?paged=3&cat={$category_id}" ), // Next
     480                );
     481
     482                $processor   = new WP_HTML_Tag_Processor( $links );
     483                $found_links = 0;
     484                while ( $processor->next_tag( 'A' ) ) {
     485                        $expected_link = $expected_links[ $found_links ] ?? '';
     486                        ++$found_links;
     487                        $href = (string) $processor->get_attribute( 'href' );
     488                        $this->assertSame( $expected_link, $href, "Pagination links should include the category query string, found: $href" );
     489                }
     490                $this->assertSame( count( $expected_links ), $found_links, 'There should be this number of pagination links found.' );
     491        }
     492
     493        /**
     494         * Ensures the pagination links do not modify query strings (permalinks with trailing slash).
     495         *
     496         * @ticket 61393
     497         * @ticket 63123
     498         *
     499         * @dataProvider data_query_strings
     500         *
     501         * @param string $query_string Query string.
     502         */
     503        public function test_permalinks_with_trailing_slash_do_not_modify_query_strings( string $query_string ): void {
     504                update_option( 'posts_per_page', 2 );
     505                $this->set_permalink_structure( '/%postname%/' );
     506
     507                $this->go_to( "/page/2/?{$query_string}" );
     508
     509                // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above.
     510                $links = paginate_links( array( 'current' => 2 ) );
     511
     512                $processor   = new WP_HTML_Tag_Processor( $links );
     513                $found_links = 0;
     514                while ( $processor->next_tag( 'A' ) ) {
     515                        ++$found_links;
     516                        $href = (string) $processor->get_attribute( 'href' );
     517                        $this->assertStringEndsWith( "/?{$query_string}", $href, "Pagination links should not modify the query string, found: $href" );
     518                }
     519                $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' );
     520        }
     521
     522        /**
     523         * Ensures the pagination links do not modify query strings (permalinks without trailing slash).
     524         *
     525         * @ticket 61393
     526         * @ticket 63123
     527         *
     528         * @dataProvider data_query_strings
     529         *
     530         * @param string $query_string Query string.
     531         */
     532        public function test_permalinks_without_trailing_slash_do_not_modify_query_strings( string $query_string ): void {
     533                update_option( 'posts_per_page', 2 );
     534                $this->set_permalink_structure( '/%postname%' );
     535
     536                $this->go_to( "/page/2?{$query_string}" );
     537
     538                // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above.
     539                $links = paginate_links( array( 'current' => 2 ) );
     540
     541                $processor   = new WP_HTML_Tag_Processor( $links );
     542                $found_links = 0;
     543                while ( $processor->next_tag( 'A' ) ) {
     544                        ++$found_links;
     545                        $href = (string) $processor->get_attribute( 'href' );
     546                        $this->assertStringEndsWith( "?{$query_string}", $href, "Pagination links should not modify the query string, found: $href" );
     547                        $this->assertStringEndsNotWith( "/?{$query_string}", $href, "Pagination links should not be slashed before the query string, found: $href" );
     548                }
     549                $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' );
     550        }
     551
     552        /**
     553         * Data provider.
     554         *
     555         * @see self::test_permalinks_without_trailing_slash_do_not_modify_query_strings()
     556         * @see self::test_permalinks_with_trailing_slash_do_not_modify_query_strings()
     557         *
     558         * @return array<string, array{ 0: string }> Data provider.
     559         */
     560        public function data_query_strings(): array {
     561                return array(
     562                        'single query var' => array( 'foo=bar' ),
     563                        'multi query vars' => array( 'foo=bar&pen=pencil' ),
     564                );
     565        }
    386566}
Note: See TracChangeset for help on using the changeset viewer.