diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index f16d787132452..3dbf954f16d1c 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -4672,9 +4672,26 @@ function paginate_links( $args = '' ) { // Append the format placeholder to the base URL. $pagenum_link = trailingslashit( $url_parts[0] ) . '%_%'; + /* + * Ensures sites not using trailing slashes get links in the form + * `/page/2` rather than `/page/2/`. On these sites, linking to the + * URL with a trailing slash will results in a 301 redirect from the + * incorrect URL to the correctly formattted one. This presents an + * unnecessary performance hit. + */ + if ( $wp_rewrite->using_permalinks() && ! $wp_rewrite->use_trailing_slashes ) { + $pagenum_link = untrailingslashit( $url_parts[0] ); + } else { + $pagenum_link = trailingslashit( $url_parts[0] ); + } + $pagenum_link .= '%_%'; + // URL base depends on permalink settings. $format = $wp_rewrite->using_index_permalinks() && ! strpos( $pagenum_link, 'index.php' ) ? 'index.php/' : ''; $format .= $wp_rewrite->using_permalinks() ? user_trailingslashit( $wp_rewrite->pagination_base . '/%#%', 'paged' ) : '?paged=%#%'; + if ( $wp_rewrite->using_permalinks() && ! $wp_rewrite->use_trailing_slashes ) { + $format = '/' . ltrim( $format, '/' ); + } $defaults = array( 'base' => $pagenum_link, // http://example.com/all_posts.php%_% : %_% is replaced by format (below). diff --git a/tests/phpunit/tests/general/paginateLinks.php b/tests/phpunit/tests/general/paginateLinks.php index d9833c6245488..587e4639136ae 100644 --- a/tests/phpunit/tests/general/paginateLinks.php +++ b/tests/phpunit/tests/general/paginateLinks.php @@ -9,6 +9,39 @@ class Tests_General_PaginateLinks extends WP_UnitTestCase { private $i18n_count = 0; + /** + * Post IDs created for shared fixtures. + * + * @var int[] + */ + protected static $post_ids = array(); + + /** + * Category ID created for shared fixtures. + * + * @var int + */ + protected static $category_id = 0; + + /** + * Set up shared fixtures. + * + * @param WP_UnitTest_Factory $factory Factory instance. + */ + public static function wpSetUpBeforeClass( $factory ) { + self::$category_id = $factory->term->create( + array( + 'taxonomy' => 'category', + 'name' => 'Categorized', + ) + ); + + self::$post_ids = $factory->post->create_many( 10 ); + foreach ( self::$post_ids as $post_id ) { + wp_set_post_categories( $post_id, array( self::$category_id ) ); + } + } + public function set_up() { parent::set_up(); @@ -383,4 +416,127 @@ public function test_custom_base_query_arg_should_be_stripped_from_current_url_b $page_2_url = home_url() . '?foo=2'; $this->assertContains( "2", $links ); } + + /** + * Ensures pagination links include trailing slashes when the permalink structure includes them. + * + * @ticket 61393 + */ + public function test_permalinks_with_trailing_slash_produce_links_with_trailing_slashes() { + update_option( 'posts_per_page', 2 ); + $this->set_permalink_structure( '/%postname%/' ); + + $this->go_to( '/category/categorized/page/2/' ); + + // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above. + $links = paginate_links( array( 'current' => 2 ) ); + + $processor = new WP_HTML_Tag_Processor( $links ); + $found_links = 0; + while ( $processor->next_tag( 'A' ) ) { + ++$found_links; + $href = $processor->get_attribute( 'href' ); + $this->assertStringEndsWith( '/', $href, "Pagination links should end with a trailing slash, found: $href" ); + } + $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' ); + } + + /** + * Ensures pagination links do not include trailing slashes when the permalink structure doesn't include them. + * + * @ticket 61393 + */ + public function test_permalinks_without_trailing_slash_produce_links_without_trailing_slashes() { + update_option( 'posts_per_page', 2 ); + $this->set_permalink_structure( '/%postname%' ); + + $this->go_to( '/category/categorized/page/2' ); + + // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above. + $links = paginate_links( array( 'current' => 2 ) ); + + $processor = new WP_HTML_Tag_Processor( $links ); + $found_links = 0; + while ( $processor->next_tag( 'A' ) ) { + ++$found_links; + $href = $processor->get_attribute( 'href' ); + $this->assertStringEndsNotWith( '/', $href, "Pagination links should end with a trailing slash, found: $href" ); + } + $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' ); + } + + /** + * Ensures the pagination links do not modify query strings (permalinks with trailing slash). + * + * @ticket 61393 + * @ticket 63123 + * + * @dataProvider data_query_strings + * + * @param string $query_string Query string. + * @param string $unexpected Unexpected query string. + */ + public function test_permalinks_with_trailing_slash_do_not_modify_query_strings( string $query_string ) { + update_option( 'posts_per_page', 2 ); + $this->set_permalink_structure( '/%postname%/' ); + + $this->go_to( "/page/2/?{$query_string}" ); + + // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above. + $links = paginate_links( array( 'current' => 2 ) ); + + $processor = new WP_HTML_Tag_Processor( $links ); + $found_links = 0; + while ( $processor->next_tag( 'A' ) ) { + ++$found_links; + $href = $processor->get_attribute( 'href' ); + $this->assertStringEndsWith( "/?{$query_string}", $href, "Pagination links should not modify the query string, found: $href" ); + } + $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' ); + } + + /** + * Ensures the pagination links do not modify query strings (permalinks without trailing slash). + * + * @ticket 61393 + * @ticket 63123 + * + * @dataProvider data_query_strings + * + * @param string $query_string Query string. + * @param string $unexpected Unexpected query string. + */ + public function test_permalinks_without_trailing_slash_do_not_modify_query_strings( string $query_string ) { + update_option( 'posts_per_page', 2 ); + $this->set_permalink_structure( '/%postname%' ); + + $this->go_to( "/page/2?{$query_string}" ); + + // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above. + $links = paginate_links( array( 'current' => 2 ) ); + + $processor = new WP_HTML_Tag_Processor( $links ); + $found_links = 0; + while ( $processor->next_tag( 'A' ) ) { + ++$found_links; + $href = $processor->get_attribute( 'href' ); + $this->assertStringEndsWith( "?{$query_string}", $href, "Pagination links should not modify the query string, found: $href" ); + $this->assertStringEndsNotWith( "/?{$query_string}", $href, "Pagination links should not be slashed before the query string, found: $href" ); + } + $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' ); + } + + /** + * Data provider for + * - test_permalinks_without_trailing_slash_do_not_modify_query_strings + * - test_permalinks_with_trailing_slash_do_not_modify_query_strings + * + * @return array Data provider. + */ + public function data_query_strings(): array { + return array( + array( 'foo=bar' ), + array( 'foo=bar&pen=pencil' ), + ); + } }