PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/class-purge-ui.php +113 -114 1.3.2 → 1.4.0 View file →
@@ -288,9 +288,10 @@
288 288 if ( '' === $url || ! self::is_local_url( $url ) ) {
289 289 wp_die( esc_html__( 'That URL is not on this site.', 'xspeed' ), 400 );
290 290 }
291 291
292 - self::record_result( $url, Cache::purge_url( $url, __( 'admin', 'xspeed' ) ), 1 );
292 + $purge = Cache::purge_url_reported( $url, __( 'admin', 'xspeed' ) );
293 + self::record_result( $url, $purge['removed'], 1, false, $purge['forwarded'] );
293 294
294 295 wp_safe_redirect( self::redirect_target( wp_get_referer() ) );
295 296 exit;
296 297 }
@@ -334,9 +335,9 @@
334 335 * Keyed per user, so two admins purging at once don't read each other's
335 336 * result, and short-lived because it is only ever meant to survive one
336 337 * redirect.
337 338 */
338 - private static function record_result( string $url, int $count, int $urls = 1 ): void {
339 + private static function record_result( string $url, int $count, int $urls = 1, bool $whole_site = false, array $forwarded = array() ): void {
339 340 $user_id = get_current_user_id();
340 341 if ( $user_id <= 0 ) {
341 342 return;
342 343 }
@@ -342,11 +343,13 @@
342 343 }
343 344 set_transient(
344 345 self::NOTICE_KEY . $user_id,
345 346 array(
346 - 'url' => $url,
347 - 'count' => $count,
348 - 'urls' => $urls,
347 + 'url' => $url,
348 + 'count' => $count,
349 + 'urls' => $urls,
350 + 'whole_site' => $whole_site,
351 + 'forwarded' => array_values( array_map( 'strval', $forwarded ) ),
349 352 ),
350 353 MINUTE_IN_SECONDS
351 354 );
352 355 }
@@ -354,9 +357,9 @@
354 357 /**
355 358 * Read the pending result and clear it. Consumed once: whichever surface
356 359 * renders first owns it, and a reload afterwards shows nothing.
357 360 *
358 - * @return array{url:string,count:int,urls:int}|null
361 + * @return array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]}|null
359 362 */
360 363 private static function take_result(): ?array {
361 364 $result = self::peek_result();
362 365 if ( null === $result ) {
@@ -370,9 +373,9 @@
370 373 /**
371 374 * Read the pending result WITHOUT clearing it, so a caller that turns out
372 375 * not to be the right place to show it can leave it for the next screen.
373 376 *
374 - * @return array{url:string,count:int,urls:int}|null
377 + * @return array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]}|null
375 378 */
376 379 private static function peek_result(): ?array {
377 380 $user_id = get_current_user_id();
378 381 if ( $user_id <= 0 ) {
@@ -383,11 +386,13 @@
383 386 return null;
384 387 }
385 388
386 389 return array(
387 - 'url' => (string) $result['url'],
388 - 'count' => (int) ( $result['count'] ?? 0 ),
389 - 'urls' => max( 1, (int) ( $result['urls'] ?? 1 ) ),
390 + 'url' => (string) $result['url'],
391 + 'count' => (int) ( $result['count'] ?? 0 ),
392 + 'urls' => max( 1, (int) ( $result['urls'] ?? 1 ) ),
393 + 'whole_site' => ! empty( $result['whole_site'] ),
394 + 'forwarded' => isset( $result['forwarded'] ) && is_array( $result['forwarded'] ) ? array_values( array_map( 'strval', $result['forwarded'] ) ) : array(),
390 395 );
391 396 }
392 397
393 398 /**
@@ -398,14 +403,29 @@
398 403 * it means either the purge already happened or the page was never
399 404 * cacheable, and calling that "cleared" sends people looking for a bug
400 405 * in the wrong place.
401 406 *
402 - * @param array{url:string,count:int,urls:int} $result
407 + * @param array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]} $result
403 408 */
404 409 private static function message( array $result ): string {
405 410 $path = (string) wp_parse_url( $result['url'], PHP_URL_PATH );
406 411 $path = '' === $path ? '/' : $path;
407 412
413 + if ( ! empty( $result['whole_site'] ) ) {
414 + return sprintf(
415 + /* translators: 1: URL path of the post, 2: number of pages that list it, 3: number of files removed. */
416 + _n(
417 + 'xSpeed: %1$s is listed on %2$d pages, too many to clear one by one, so the whole site cache was cleared (%3$d file).',
418 + 'xSpeed: %1$s is listed on %2$d pages, too many to clear one by one, so the whole site cache was cleared (%3$d files).',
419 + $result['count'],
420 + 'xspeed'
421 + ),
422 + $path,
423 + $result['urls'],
424 + $result['count']
425 + );
426 + }
427 +
408 428 // A post purge also clears the pages that list it, so say so — a user
409 429 // who asked for one page and sees "12 files" should not have to guess
410 430 // whether something over-reached.
411 431 $scope = $result['urls'] > 1
@@ -421,8 +441,18 @@
421 441 $result['urls'] - 1
422 442 )
423 443 : $path;
424 444
445 + $forwarded = self::forwarded_of( $result );
446 + if ( $result['count'] < 1 && '' !== $forwarded ) {
447 + return sprintf(
448 + /* translators: 1: what was purged, 2: caches in front of the site, comma-separated. */
449 + __( 'xSpeed: sent the purge for %1$s to %2$s. xSpeed\'s own cache held no copy.', 'xspeed' ),
450 + $scope,
451 + $forwarded
452 + );
453 + }
454 +
425 455 if ( $result['count'] < 1 ) {
426 456 return sprintf(
427 457 /* translators: %s: what was purged. */
428 458 __( 'xSpeed: %s was not cached, so there was nothing to clear.', 'xspeed' ),
@@ -453,14 +483,34 @@
453 483 return;
454 484 }
455 485 printf(
456 486 '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
457 - $result['count'] > 0 ? 'success' : 'info',
487 + self::cleared_anything( $result ) ? 'success' : 'info',
458 488 esc_html( self::message( $result ) )
459 489 );
460 490 }
461 491
462 492 /**
493 + * Whether the purge cleared anything anywhere: a local file, or a
494 + * cache in front of the site that took it.
495 + *
496 + * @param array<string,mixed> $result
497 + */
498 + private static function cleared_anything( array $result ): bool {
499 + return $result['count'] > 0 || '' !== self::forwarded_of( $result );
500 + }
501 +
502 + /**
503 + * The caches a purge was sent to, comma-separated, or ''.
504 + *
505 + * @param array<string,mixed> $result
506 + */
507 + private static function forwarded_of( array $result ): string {
508 + $forwarded = isset( $result['forwarded'] ) && is_array( $result['forwarded'] ) ? $result['forwarded'] : array();
509 + return implode( ', ', array_filter( array_map( 'strval', $forwarded ) ) );
510 + }
511 +
512 + /**
463 513 * Front end: `admin_notices` never fires there, and the redirect lands on
464 514 * the purged page itself. Say it in the admin bar instead — the one piece
465 515 * of our UI already on screen, styled by core, needing no stylesheet and
466 516 * no script on a front-end page view.
@@ -492,9 +542,9 @@
492 542 $wp_admin_bar->add_node(
493 543 array(
494 544 'id' => 'xspeed-purge',
495 545 'title' => $node->title . ' · ' . (
496 - $result['count'] > 0
546 + self::cleared_anything( $result )
497 547 ? esc_html__( 'cleared', 'xspeed' )
498 548 : esc_html__( 'was not cached', 'xspeed' )
499 549 ),
500 550 'meta' => array( 'title' => self::message( $result ) ),
@@ -561,107 +611,62 @@
561 611
562 612 /**
563 613 * Every URL that goes stale when one post changes.
564 614 *
565 - * The set follows WP Rocket's `rocket_get_purge_urls()`, which is the
566 - * closest thing this problem has to a settled answer: the post itself,
567 - * the blog page or the post-type archive it appears on, the four
568 - * adjacent posts whose prev/next links now name a different neighbour,
569 - * the author archive, every ancestor, and the homepage.
615 + * The same list the automatic purge on save clears, from
616 + * Affected_Pages, so "Purge this post" and a save never disagree. It
617 + * covers the post, the home page and blog page, its post-type archive,
618 + * every public term it is in with parent terms, the author and date
619 + * archives, every page of each, the feeds, the four adjacent posts and
620 + * any ancestors. (An earlier version left terms out on the grounds that
621 + * WP Rocket does; current WP Rocket purges terms, parents and their
622 + * pagination too.)
570 623 *
571 - * Term archives are deliberately NOT in the set — Rocket leaves them out
572 - * too. A post can carry dozens of terms, and purging every one of them
573 - * turns a one-post edit back into the broad sweep this feature exists to
574 - * avoid.
624 + * The `xspeed_post_purge_urls` filter is applied inside the builder.
575 625 *
576 626 * @return string[] Absolute URLs, de-duplicated.
577 627 */
578 628 public static function post_purge_urls( \WP_Post $post ): array {
579 - $urls = array();
580 -
581 - $permalink = self::permalink_of( (int) $post->ID );
582 - if ( '' !== $permalink ) {
583 - $urls[] = $permalink;
584 - }
585 -
586 - // The blog page for posts; the post-type archive for anything else.
587 - if ( 'post' === $post->post_type ) {
588 - $page_for_posts = (int) get_option( 'page_for_posts' );
589 - if ( $page_for_posts > 0 ) {
590 - $urls[] = (string) get_permalink( $page_for_posts );
591 - }
592 - } else {
593 - $archive = get_post_type_archive_link( $post->post_type );
594 - if ( is_string( $archive ) && '' !== $archive ) {
595 - $urls[] = $archive;
596 - }
597 - }
598 -
599 - // The neighbours whose own prev/next links now point somewhere else.
600 - // Read in the post's own context: get_adjacent_post() works off the
601 - // global $post, which on an admin screen is not the one being purged.
602 - $urls = array_merge( $urls, self::adjacent_post_urls( $post ) );
603 -
604 - $author = get_author_posts_url( (int) $post->post_author );
605 - if ( is_string( $author ) && '' !== $author ) {
606 - $urls[] = $author;
607 - }
608 -
609 - foreach ( get_post_ancestors( $post ) as $ancestor_id ) {
610 - $link = self::permalink_of( (int) $ancestor_id );
611 - if ( '' !== $link ) {
612 - $urls[] = $link;
613 - }
614 - }
615 -
616 - $urls[] = home_url( '/' );
617 -
618 - /**
619 - * Filter the URLs cleared when one post is purged.
620 - *
621 - * @param string[] $urls Absolute URLs.
622 - * @param \WP_Post $post The post being purged.
623 - */
624 - $urls = (array) apply_filters( 'xspeed_post_purge_urls', $urls, $post );
625 -
626 - $urls = array_filter( $urls, static fn( $url ) => is_string( $url ) && '' !== $url );
627 -
628 - return array_values( array_unique( $urls ) );
629 + return Affected_Pages::for_post( $post );
629 630 }
630 631
631 632 /**
632 - * Permalinks of the four posts adjacent to this one: previous and next,
633 - * each in the whole timeline and within a shared term.
633 + * Clear one post's pages, or the whole site when they are more than a
634 + * save would name one by one (Affected_Pages::LIMIT).
634 635 *
635 - * @return string[]
636 + * The same limit as the automatic purge on save. Over it, every page
637 + * would go to each cache in front one at a time: on a 5,000-post blog
638 + * the list was 1,022 URLs, each a blocking request to Nginx Helper.
639 + *
640 + * @return array{count:int,urls:int,whole_site:bool} Files removed, pages named, and whether the whole site went instead.
636 641 */
637 - private static function adjacent_post_urls( \WP_Post $post ): array {
638 - $urls = array();
639 -
640 - // get_adjacent_post() reads the global $post. Swap it for the one
641 - // being purged and put it back, or on an edit screen we would collect
642 - // the neighbours of whatever WordPress happened to have loaded.
643 - $previous_global = $GLOBALS['post'] ?? null;
644 - $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below.
645 -
646 - foreach ( array( array( false, true ), array( true, true ), array( false, false ), array( true, false ) ) as $args ) {
647 - list( $same_term, $previous ) = $args;
648 - $adjacent = get_adjacent_post( $same_term, '', $previous );
649 - if ( $adjacent instanceof \WP_Post ) {
650 - $link = self::permalink_of( (int) $adjacent->ID );
651 - if ( '' !== $link ) {
652 - $urls[] = $link;
653 - }
654 - }
642 + public static function purge_post( \WP_Post $post ): array {
643 + // Count only what we actually act on. The set is filterable, so an
644 + // off-site URL added through xspeed_post_purge_urls is skipped here;
645 + // reporting it as cleared would inflate the notice.
646 + $local = array_values( array_filter( self::post_purge_urls( $post ), array( self::class, 'is_local_url' ) ) );
647 + if ( count( $local ) > Affected_Pages::LIMIT ) {
648 + $count = Cache::purge_all(
649 + __( 'admin', 'xspeed' ),
650 + null,
651 + array(
652 + 'scope' => 'site',
653 + 'intent' => 'content',
654 + 'urls' => array(),
655 + 'fallback' => Cache::FALLBACK_LIMIT,
656 + )
657 + );
658 + return array(
659 + 'count' => (int) $count,
660 + 'urls' => count( $local ),
661 + 'whole_site' => true,
662 + );
655 663 }
656 -
657 - if ( null === $previous_global ) {
658 - unset( $GLOBALS['post'] );
659 - } else {
660 - $GLOBALS['post'] = $previous_global; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring.
661 - }
662 -
663 - return $urls;
664 + return array(
665 + 'count' => array() === $local ? 0 : Cache::purge_urls( $local, __( 'admin', 'xspeed' ) ),
666 + 'urls' => count( $local ),
667 + 'whole_site' => false,
668 + );
664 669 }
665 670
666 671 /**
667 672 * Purge one post and everything that lists it.
@@ -683,22 +688,16 @@
683 688 if ( ! $post instanceof \WP_Post ) {
684 689 wp_die( esc_html__( 'That post does not exist.', 'xspeed' ), 400 );
685 690 }
686 691
687 - $count = 0;
688 - $cleared = 0;
689 - foreach ( self::post_purge_urls( $post ) as $url ) {
690 - // Count only what we actually acted on. The set is filterable, so
691 - // an off-site URL added through xspeed_post_purge_urls is skipped
692 - // here — reporting it as cleared would inflate the notice.
693 - if ( ! self::is_local_url( $url ) ) {
694 - continue;
692 + $run = Cache::report_forwarding(
693 + static function () use ( $post ): array {
694 + return self::purge_post( $post );
695 695 }
696 - ++$cleared;
697 - $count += Cache::purge_url( $url, __( 'admin', 'xspeed' ) );
698 - }
696 + );
697 + $result = $run['result'];
699 698
700 - self::record_result( self::permalink_of( $post_id ), $count, $cleared );
699 + self::record_result( self::permalink_of( $post_id ), $result['count'], $result['urls'], $result['whole_site'], $run['forwarded'] );
701 700
702 701 wp_safe_redirect( self::redirect_target( wp_get_referer() ) );
703 702 exit;
704 703 }
@@ -709,9 +708,9 @@
709 708 * Compared on path alone: the result was recorded against an absolute URL
710 709 * built from the request that purged it, and the host on the request
711 710 * showing the notice is the same one by construction.
712 711 *
713 - * @param array{url:string,count:int,urls:int} $result
712 + * @param array{url:string,count:int,urls:int,whole_site:bool,forwarded?:string[]} $result
714 713 */
715 714 private static function result_is_about_this_request( array $result ): bool {
716 715 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared, never output or stored.
717 716 if ( '' === $uri ) {