| @@ -53,8 +53,16 @@ | ||
| 53 | 53 | */ |
| 54 | 54 | public const MAX_URLS_PER_SUBMISSION = 100; |
| 55 | 55 | |
| 56 | 56 | /** |
| 57 | + * Posts that transitioned to publish in this request and await the end of | |
| 58 | + * their save, keyed by ID, with what was true of them before it. | |
| 59 | + * | |
| 60 | + * @var array<int, array{was_published: bool, was_excluded: bool}> | |
| 61 | + */ | |
| 62 | + private array $pending_transitions = []; | |
| 63 | + | |
| 64 | + /** | |
| 57 | 65 | * Initialize the component |
| 58 | 66 | * |
| 59 | 67 | * @since 1.1.0 |
| 60 | 68 | * @return void |
| @@ -60,8 +68,9 @@ | ||
| 60 | 68 | * @return void |
| 61 | 69 | */ |
| 62 | 70 | public function init(): void { |
| 63 | 71 | add_action('transition_post_status', [$this, 'handle_post_transition'], 10, 3); |
| 72 | + add_action('wp_after_insert_post', [$this, 'handle_after_insert_post'], 10, 4); | |
| 64 | 73 | add_action('delete_post', [$this, 'handle_post_deletion'], 10, 2); |
| 65 | 74 | |
| 66 | 75 | // Serve the IndexNow key file from PHP when no physical file exists. |
| 67 | 76 | // The key is normally written to the WordPress root, but on managed and |
| @@ -150,9 +159,11 @@ | ||
| 150 | 159 | * @param string $api_key Verification key. |
| 151 | 160 | * @return string Absolute key file URL. |
| 152 | 161 | */ |
| 153 | 162 | public static function key_location(string $api_key): string { |
| 154 | - return home_url('/' . $api_key . '.txt'); | |
| 163 | + // Matches the scheme the submitted URLs go out with, so IndexNow is | |
| 164 | + // never told to verify ownership at an address on the other scheme. | |
| 165 | + return Url_Scheme::apply(home_url('/' . $api_key . '.txt')); | |
| 155 | 166 | } |
| 156 | 167 | |
| 157 | 168 | /** |
| 158 | 169 | * Actively verify that the advertised keyLocation is reachable and returns |
| @@ -247,9 +258,9 @@ | ||
| 247 | 258 | } |
| 248 | 259 | |
| 249 | 260 | return sprintf( |
| 250 | 261 | /* translators: %d: HTTP status code returned by the key URL. */ |
| 251 | - __('The key file could not be verified (HTTP %d). Open it in a browser — it should show the key and nothing else.', 'thinkrank'), | |
| 262 | + __('The key file could not be verified (HTTP %d). Open it in a browser: it should show the key and nothing else.', 'thinkrank'), | |
| 252 | 263 | $code |
| 253 | 264 | ); |
| 254 | 265 | } |
| 255 | 266 | |
| @@ -379,23 +390,84 @@ | ||
| 379 | 390 | if (!$this->should_process_post($post)) { |
| 380 | 391 | return; |
| 381 | 392 | } |
| 382 | 393 | |
| 383 | - // We only care if the new status is publish (created or updated) | |
| 384 | - // OR if we are unpublishing (publish -> something else), we might want to update (though IndexNow is mostly for crawling new/updated content) | |
| 385 | - // For now, let's focus on published content. | |
| 386 | - if ($new_status === 'publish') { | |
| 387 | - $url = get_permalink($post->ID); | |
| 394 | + // Only content that is (still) published is submitted. | |
| 395 | + if ($new_status !== 'publish') { | |
| 396 | + return; | |
| 397 | + } | |
| 388 | 398 | |
| 389 | - // Check for duplicate submission using short-lived cache (15 seconds) | |
| 390 | - if ($this->is_recently_submitted_cache($url)) { | |
| 391 | - return; | |
| 392 | - } | |
| 399 | + // Whether to submit is decided in handle_after_insert_post(), not | |
| 400 | + // here. This hook fires inside wp_insert_post() before the post's meta | |
| 401 | + // is written: the block editor saves the robots override through | |
| 402 | + // REST after the insert, the classic metabox on save_post. Deciding | |
| 403 | + // here read the previous robots value, so a post published with | |
| 404 | + // noindex was submitted (#911). | |
| 405 | + // | |
| 406 | + // What this hook can still see is the previous state, which the | |
| 407 | + // decision needs: a post that was an indexable destination and is no | |
| 408 | + // longer one is submitted once more, so engines recrawl it and drop it. | |
| 409 | + $this->pending_transitions[(int) $post->ID] = [ | |
| 410 | + 'was_published' => $old_status === 'publish', | |
| 411 | + 'was_excluded' => Indexability::is_post_noindexed($post) || Indexability::is_post_redirected($post), | |
| 412 | + ]; | |
| 413 | + } | |
| 393 | 414 | |
| 394 | - // Defer the outbound IndexNow call to WP-Cron so publishing doesn't | |
| 395 | - // block on a third-party HTTP request. | |
| 396 | - $this->schedule_url_submission([$url]); | |
| 415 | + /** | |
| 416 | + * Submit a published post once its save is complete. | |
| 417 | + * | |
| 418 | + * Runs on `wp_after_insert_post`, which fires after meta and terms are | |
| 419 | + * saved on every path (classic editor, REST, Quick Edit, WP-CLI, and the | |
| 420 | + * scheduled-post publish in wp_publish_post()). | |
| 421 | + * | |
| 422 | + * A post that is not an indexable destination (noindexed by its own | |
| 423 | + * override or its type, password-protected, or redirected) is skipped, | |
| 424 | + * unless it was one before this save: that transition is submitted so | |
| 425 | + * engines recrawl the page and drop it. | |
| 426 | + * | |
| 427 | + * @since 2.15.0 | |
| 428 | + * | |
| 429 | + * @param int $post_id Post ID. | |
| 430 | + * @param \WP_Post $post Post object after the save. | |
| 431 | + * @param bool $update Whether this was an update. | |
| 432 | + * @param \WP_Post|null $post_before Post object before the save, null for a new post. | |
| 433 | + * @return void | |
| 434 | + */ | |
| 435 | + public function handle_after_insert_post(int $post_id, \WP_Post $post, bool $update, ?\WP_Post $post_before): void { | |
| 436 | + if (!isset($this->pending_transitions[$post_id])) { | |
| 437 | + return; | |
| 397 | 438 | } |
| 439 | + | |
| 440 | + $before = $this->pending_transitions[$post_id]; | |
| 441 | + unset($this->pending_transitions[$post_id]); | |
| 442 | + | |
| 443 | + if ($post->post_status !== 'publish') { | |
| 444 | + return; | |
| 445 | + } | |
| 446 | + | |
| 447 | + $was_indexable = $before['was_published'] | |
| 448 | + && !$before['was_excluded'] | |
| 449 | + && !($post_before instanceof \WP_Post && Indexability::is_password_protected($post_before)); | |
| 450 | + | |
| 451 | + // Decided before the dedupe check below, which claims its 15-second | |
| 452 | + // slot as a side effect: a skipped post must not occupy it. | |
| 453 | + if (!Indexability::is_indexable_post($post) && !$was_indexable) { | |
| 454 | + return; | |
| 455 | + } | |
| 456 | + | |
| 457 | + $url = get_permalink($post->ID); | |
| 458 | + if (!$url) { | |
| 459 | + return; | |
| 460 | + } | |
| 461 | + | |
| 462 | + // Check for duplicate submission using short-lived cache (15 seconds) | |
| 463 | + if ($this->is_recently_submitted_cache($url)) { | |
| 464 | + return; | |
| 465 | + } | |
| 466 | + | |
| 467 | + // Defer the outbound IndexNow call to WP-Cron so publishing doesn't | |
| 468 | + // block on a third-party HTTP request. | |
| 469 | + $this->schedule_url_submission([$url]); | |
| 398 | 470 | } |
| 399 | 471 | |
| 400 | 472 | /** |
| 401 | 473 | * Check if URL was recently submitted using transient cache |
| @@ -467,9 +539,9 @@ | ||
| 467 | 539 | return false; |
| 468 | 540 | } |
| 469 | 541 | |
| 470 | 542 | // 3. Check autosave/revision |
| 471 | - if (wp_is_post_autosave($post) || wp_is_post_revision($post)) { | |
| 543 | + if (wp_is_post_autosave((int) $post->ID) || wp_is_post_revision((int) $post->ID)) { | |
| 472 | 544 | return false; |
| 473 | 545 | } |
| 474 | 546 | |
| 475 | 547 | return true; |
| @@ -523,8 +595,20 @@ | ||
| 523 | 595 | if (empty($urls)) { |
| 524 | 596 | return ['success' => false, 'message' => 'No URLs matched this site host', 'submitted_count' => 0]; |
| 525 | 597 | } |
| 526 | 598 | |
| 599 | + // Every submission path lands here, so this is where the site's scheme | |
| 600 | + // preference is applied (#638). Submitting http URLs for a site served | |
| 601 | + // over https asks search engines to index an address that redirects, | |
| 602 | + // and it is the canonical mismatch all over again in the one place a | |
| 603 | + // site owner cannot see it happening. | |
| 604 | + $urls = array_values(array_unique(array_map( | |
| 605 | + static function ($u): string { | |
| 606 | + return Url_Scheme::apply((string) $u); | |
| 607 | + }, | |
| 608 | + $urls | |
| 609 | + ))); | |
| 610 | + | |
| 527 | 611 | // Enforce the shared per-submission cap so every path (manual, bulk, MCP) |
| 528 | 612 | // behaves consistently. |
| 529 | 613 | if (count($urls) > self::MAX_URLS_PER_SUBMISSION) { |
| 530 | 614 | $urls = array_slice($urls, 0, self::MAX_URLS_PER_SUBMISSION); |
| @@ -573,9 +657,9 @@ | ||
| 573 | 657 | // actually means, and name the URL to check. |
| 574 | 658 | if ($response_code === 403) { |
| 575 | 659 | $response_message = sprintf( |
| 576 | 660 | /* translators: %s: public URL of the IndexNow key file. */ |
| 577 | - __('Key file could not be verified. Search engines must be able to read your key at %s — open it in a browser: it should show the key and nothing else.', 'thinkrank'), | |
| 661 | + __('Key file could not be verified. Search engines must be able to read your key at %s. Open it in a browser: it should show the key and nothing else.', 'thinkrank'), | |
| 578 | 662 | $key_location |
| 579 | 663 | ); |
| 580 | 664 | } |
| 581 | 665 | } |