-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Sitemaps: Don't return a 404 status for valid sitemap requests when the main query has no posts #13247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Sitemaps: Don't return a 404 status for valid sitemap requests when the main query has no posts #13247
Changes from 17 commits
b660f39
84dd716
76abdad
e6c7975
d59668d
5984ce7
3bfc7a8
aeaee3d
55a454c
0521241
ad11169
0a0a0b0
a619899
ea09172
b056717
b581d89
65fd9fd
f3130f7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -157,30 +157,45 @@ public function register_rewrites() { | |
| * Renders sitemap templates based on rewrite rules. | ||
| * | ||
| * @since 5.5.0 | ||
| * | ||
| * @global WP_Query $wp_query WordPress Query object. | ||
| */ | ||
| public function render_sitemaps() { | ||
| global $wp_query; | ||
| /* | ||
| * Bail early if this isn't a sitemap or stylesheet route. | ||
| * | ||
| * This runs on every front-end request, so it comes before any | ||
| * sanitizing. The raw query vars are tested here, matching | ||
| * WP::handle_404(), which exempts sitemap requests from its own 404 on | ||
| * the same basis. Testing the sanitized values instead would let a | ||
| * request that handle_404() exempted fall through both, leaving it a 200. | ||
| */ | ||
| if ( ! get_query_var( 'sitemap' ) && ! get_query_var( 'sitemap-stylesheet' ) ) { | ||
| return; | ||
| } | ||
|
|
||
| $sitemap = sanitize_text_field( get_query_var( 'sitemap' ) ); | ||
| $object_subtype = sanitize_text_field( get_query_var( 'sitemap-subtype' ) ); | ||
| $stylesheet_type = sanitize_text_field( get_query_var( 'sitemap-stylesheet' ) ); | ||
| $sitemap = $this->get_sanitized_query_var( 'sitemap' ); | ||
| $object_subtype = $this->get_sanitized_query_var( 'sitemap-subtype' ); | ||
| $stylesheet_type = $this->get_sanitized_query_var( 'sitemap-stylesheet' ); | ||
| $paged = absint( get_query_var( 'paged' ) ); | ||
|
|
||
| // Bail early if this isn't a sitemap or stylesheet route. | ||
| // Force a 404 and bail early if the route did not survive sanitizing. | ||
| if ( ! ( $sitemap || $stylesheet_type ) ) { | ||
| $this->send_404(); | ||
| return; | ||
| } | ||
|
|
||
| if ( ! $this->sitemaps_enabled() ) { | ||
| $wp_query->set_404(); | ||
| status_header( 404 ); | ||
| $this->send_404(); | ||
| return; | ||
| } | ||
|
|
||
| // Render stylesheet if this is stylesheet route. | ||
| if ( $stylesheet_type ) { | ||
| // Force a 404 and bail early if the stylesheet type is not recognized. | ||
| if ( ! in_array( $stylesheet_type, array( 'sitemap', 'index' ), true ) ) { | ||
| $this->send_404(); | ||
| return; | ||
| } | ||
|
|
||
| $stylesheet = new WP_Sitemaps_Stylesheet(); | ||
|
|
||
| $stylesheet->render_stylesheet( $stylesheet_type ); | ||
|
|
@@ -197,7 +212,9 @@ public function render_sitemaps() { | |
|
|
||
| $provider = $this->registry->get_provider( $sitemap ); | ||
|
|
||
| // Force a 404 and bail early if the requested provider is not registered. | ||
| if ( ! $provider ) { | ||
| $this->send_404(); | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -209,15 +226,56 @@ public function render_sitemaps() { | |
|
|
||
| // Force a 404 and bail early if no URLs are present. | ||
| if ( empty( $url_list ) ) { | ||
| $wp_query->set_404(); | ||
| status_header( 404 ); | ||
| $this->send_404(); | ||
| return; | ||
|
westonruter marked this conversation as resolved.
Outdated
Comment on lines
228
to
230
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note, when testing this, it raised a question for me:
🤖 Claude Opus 5 analysis: Three separate answers, all verified. 1. No — an empty <xsd:sequence>
<xsd:any namespace="##other" minOccurs="0" maxOccurs="unbounded" processContents="strict"/>
<xsd:element name="url" type="tUrl" maxOccurs="unbounded"/>
</xsd:sequence>
2. Your instinct is nonetheless the consensus — and it's already an accepted ticket. Core-61293, "Display an empty sitemap instead of 404," milestone 7.2, has-patch, reported by Sybre Waaijer of The SEO Framework. Their argument is exactly yours, with the schema caveat folded in: an empty sitemap makes Google Search Console say "Sitemap can be read, but has errors" rather than "Sitemap could not be read" — errors because the empty urlset is invalid, but "readable with errors" is a far more actionable signal for a site owner than a hard 404. So the proposal knowingly trades schema validity for diagnosability. That's why this PR deliberately leaves the empty-URL-list branch alone, and why the description says so — it's 61293's call to make, in 7.2, where the milestone already is. 3. The specific case you describe doesn't come from core alone. I checked: with zero posts of every type, That one is listed because of the For the index to advertise a sitemap that then 404s, something has to make |
||
| } | ||
|
|
||
| $this->renderer->render_sitemap( $url_list ); | ||
| exit; | ||
| } | ||
|
|
||
| /** | ||
| * Reads a sitemap query var as a sanitized string. | ||
| * | ||
| * A public query var can hold any type — an array, for instance, when the | ||
| * request supplies one — so anything that is not a scalar becomes an empty | ||
| * string, which the caller treats as a route it cannot serve. | ||
| * | ||
| * @since 7.1.1 | ||
| * | ||
| * @param string $query_var Query variable name. | ||
| * @return string Sanitized value, or an empty string. | ||
| */ | ||
| private function get_sanitized_query_var( string $query_var ): string { | ||
| $value = get_query_var( $query_var ); | ||
|
|
||
| if ( ! is_scalar( $value ) ) { | ||
| return ''; | ||
| } | ||
|
|
||
| return sanitize_text_field( (string) $value ); | ||
| } | ||
|
|
||
| /** | ||
| * Sends a 404 for a sitemap route that cannot be served. | ||
| * | ||
| * WP::handle_404() exempts sitemap requests, so every sitemap 404 is issued | ||
| * here instead. That includes the no-cache headers handle_404() sends with | ||
| * its own 404, so an intermediary does not retain a 404 for a route that | ||
| * becomes valid once the site has more content. | ||
| * | ||
| * @since 7.1.1 | ||
| * | ||
| * @global WP_Query $wp_query WordPress Query object. | ||
| */ | ||
| private function send_404(): void { | ||
| global $wp_query; | ||
|
|
||
| $wp_query->set_404(); | ||
| status_header( 404 ); | ||
| nocache_headers(); | ||
| } | ||
|
|
||
| /** | ||
| * Redirects a URL to the wp-sitemap.xml | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * @group wp | ||
| * @group sitemaps | ||
| * | ||
| * @covers WP::handle_404 | ||
| */ | ||
| class Tests_WP_Handle404 extends WP_UnitTestCase { | ||
|
|
||
| public function set_up() { | ||
| parent::set_up(); | ||
|
|
||
| $this->set_permalink_structure( '/%postname%/' ); | ||
|
|
||
| /* | ||
| * Priming the server re-registers the sitemap query vars. tear_down() | ||
| * replaces the $wp global with a fresh WP instance, which carries only | ||
| * the built-in public query vars, and nulls $GLOBALS['wp_sitemaps'] so | ||
| * that this call re-runs WP_Sitemaps::init() and adds them back. | ||
| */ | ||
| wp_sitemaps_get_server(); | ||
| } | ||
|
|
||
| /** | ||
| * A sitemap request must not be turned into a 404 by an empty main query. | ||
| * | ||
| * Whether the sitemap exists is decided later by WP_Sitemaps::render_sitemaps(), | ||
| * so some of these URLs still 404 in a full request, just not from here. | ||
| * | ||
| * @ticket 65945 | ||
| * | ||
| * @dataProvider data_sitemap_requests | ||
| * | ||
| * @param non-falsy-string $url Sitemap URL to request. | ||
| */ | ||
| public function test_sitemap_requests_should_not_be_404ed_by_an_empty_main_query( string $url ) { | ||
| $this->go_to( home_url( $url ) ); | ||
|
|
||
| $this->assertTrue( is_sitemap(), 'The request should be recognized as a sitemap request.' ); | ||
| $this->assertFalse( is_404(), 'WP::handle_404() should not have set a 404.' ); | ||
| } | ||
|
|
||
| /** | ||
| * Data provider. | ||
| * | ||
| * @return array<non-falsy-string, array{ non-falsy-string }> | ||
| */ | ||
| public function data_sitemap_requests(): array { | ||
| return array( | ||
| 'index' => array( '/?sitemap=index' ), | ||
| 'posts provider' => array( '/?sitemap=posts&sitemap-subtype=post' ), | ||
| 'posts provider, paged' => array( '/?sitemap=posts&sitemap-subtype=post&paged=2' ), | ||
| 'pages provider, paged' => array( '/?sitemap=posts&sitemap-subtype=page&paged=2' ), | ||
| 'taxonomies provider' => array( '/?sitemap=taxonomies&sitemap-subtype=category' ), | ||
| 'taxonomies provider,paged' => array( '/?sitemap=taxonomies&sitemap-subtype=category&paged=3' ), | ||
| 'users provider, paged' => array( '/?sitemap=users&paged=2' ), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * The sitemap stylesheet routes must not be 404ed either. | ||
| * | ||
| * Covered separately because is_sitemap() only reflects the `sitemap` query var. | ||
| * | ||
| * @ticket 65945 | ||
| * | ||
| * @dataProvider data_sitemap_stylesheet_requests | ||
| * | ||
| * @param non-falsy-string $url Stylesheet URL to request. | ||
| */ | ||
| public function test_sitemap_stylesheet_requests_should_not_be_404ed_by_an_empty_main_query( string $url ) { | ||
| $this->go_to( home_url( $url ) ); | ||
|
|
||
| $this->assertFalse( is_404(), 'WP::handle_404() should not have set a 404.' ); | ||
| } | ||
|
westonruter marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Data provider. | ||
| * | ||
| * @return array<non-falsy-string, array{ non-falsy-string }> | ||
| */ | ||
| public function data_sitemap_stylesheet_requests(): array { | ||
| return array( | ||
| 'sitemap stylesheet' => array( '/?sitemap-stylesheet=sitemap' ), | ||
| 'index stylesheet' => array( '/?sitemap-stylesheet=index' ), | ||
| // Not a real route, but the only stylesheet case is_home() doesn't already cover. | ||
| 'sitemap stylesheet, paged' => array( '/?sitemap-stylesheet=sitemap&paged=2' ), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * A genuinely unknown URL must still 404. | ||
| * | ||
| * @ticket 65945 | ||
| */ | ||
| public function test_non_sitemap_request_should_still_404() { | ||
| $this->go_to( home_url( '/this-page-does-not-exist/' ) ); | ||
|
|
||
| $this->assertFalse( is_sitemap(), 'The request should not be a sitemap request.' ); | ||
| $this->assertTrue( is_404(), 'An unknown URL should still be a 404.' ); | ||
| } | ||
|
|
||
| /** | ||
| * An unregistered sitemap provider must not be turned into a 404 here. | ||
| * | ||
| * render_sitemaps() sends that status itself, covered in Tests_Sitemaps_Sitemaps. | ||
| * | ||
| * @ticket 65945 | ||
| */ | ||
| public function test_unregistered_sitemap_provider_should_not_404_in_handle_404() { | ||
| $this->go_to( home_url( '/?sitemap=this-provider-does-not-exist' ) ); | ||
|
|
||
| $this->assertTrue( is_sitemap(), 'The request should be recognized as a sitemap request.' ); | ||
| $this->assertFalse( is_404(), 'WP::handle_404() should not have set a 404.' ); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.