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
xspeed / includes / class-affected-pages.php

class-affected-pages.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.0, at includes/class-affected-pages.php

1,434 lines 54.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Affected_Pages — which cached pages one post change can make render
4 * differently.
5 *
6 * @package XSpeed
7 */
8
9 namespace XSpeed;
10
11 defined( 'ABSPATH' ) || exit;
12
13 /**
14 * The list a narrow purge clears: the post, the address it had before the
15 * save, and every list it joins or leaves, with all of their pages and feeds.
16 *
17 * The rules are the ones every cache plugin we compared uses (see
18 * xspeed-pro docs/research/2026-10-04-purge-dependency-competitor-review.md),
19 * with two additions none of them make: the OLD terms of a post that moved
20 * category, from `set_object_terms`, and the OLD address of a post whose slug
21 * or status changed, from the copy WordPress hands `wp_after_insert_post`.
22 * Without them the category a post left keeps listing it, and the old URL
23 * keeps serving it.
24 *
25 * Rules cannot see a post list a theme draws outside the main loop: a
26 * "Recent posts" widget, a Query Loop block in a template such as Twenty
27 * Twenty-Five's "More posts" under every post. When a change alters one,
28 * lists_changed_by() says so and the caller purges the whole site instead;
29 * an edit to an older post usually alters none. The same goes for lists of
30 * pages, menus, and category and tag lists. A page whose own content holds
31 * such a block, directly or in a synced pattern, is found and added to the
32 * list. Page builder and block plugin grids, and lists hard-coded in a
33 * classic theme's PHP, are not visible here; Listing_Pages records the
34 * pages that ran one, and the caller adds those.
35 *
36 * Everything returned is an absolute URL on this site. Pages are listed one
37 * by one rather than as prefixes because the flat cache tree has no
38 * directories to clear.
39 */
40 final class Affected_Pages {
41
42 /** Over this many URLs a save purges the whole site instead. */
43 public const LIMIT = 150;
44
45 /**
46 * Archives up to this many pages are listed in full even for a plain
47 * edit. Past it, a plain edit lists only the page that holds the post.
48 */
49 private const PAGED_IN_FULL = 5;
50
51 /** Most pages with list blocks in their content that are listed one by one. */
52 private const LIST_PAGE_LIMIT = 50;
53
54 /**
55 * Old term_taxonomy_ids per post, captured as a save changes them.
56 *
57 * @var array<int,array<int,int>>
58 */
59 private static $old_terms = array();
60
61 /**
62 * The same old term_taxonomy_ids, grouped by taxonomy, so a save can be
63 * compared with the terms it left in each taxonomy it set.
64 *
65 * @var array<int,array<string,array<int,int>>>
66 */
67 private static $old_terms_by_taxonomy = array();
68
69 /**
70 * Per-request answer of post_list_specs().
71 *
72 * @var array<int,array{kind:string,type?:string,n?:int,sticky?:bool,ids?:int[]}>|null
73 */
74 private static $post_list_specs = null;
75
76 /**
77 * Per-request parsed blocks of active block widgets and the theme's
78 * templates, flattened.
79 *
80 * @var array<int,array>|null
81 */
82 private static $theme_blocks = null;
83
84 /**
85 * Neighbours' permalinks per post, read before a save moved it.
86 *
87 * @var array<int,string[]>
88 */
89 private static $old_neighbours = array();
90
91 /**
92 * Posts stuck or unstuck in this request, keyed by ID. The block editor
93 * changes `sticky_posts` before the save's purge runs, so the option
94 * alone no longer says the post was sticky.
95 *
96 * @var array<int,bool>
97 */
98 private static $sticky_changed = array();
99
100 /** Per-request answer of site_lists_comments(). @var bool|null */
101 private static $site_lists_comments = null;
102
103 /** Transient holding list_block_post_ids() between saves. */
104 private const LIST_PAGES_TRANSIENT = 'xspeed_list_block_pages';
105
106 /** Block comments that mark a post list in post content. */
107 private const LIST_MARKERS = array( '<!-- wp:latest-posts', '<!-- wp:query ', '<!-- wp:archives' );
108
109 /** A synced pattern placed in post content, which may hold a list. */
110 private const SYNCED_MARKER = '<!-- wp:block {"ref":';
111
112 public static function boot(): void {
113 if ( ! function_exists( 'add_action' ) ) {
114 return;
115 }
116 add_action( 'set_object_terms', array( __CLASS__, 'remember_old_terms' ), 10, 6 );
117 // Before Cache's own save_post handler, so a page that just gained a
118 // list block is already known when the purge runs.
119 add_action( 'save_post', array( __CLASS__, 'forget_list_pages_on_save' ), 5, 2 );
120 }
121
122 /**
123 * Drop the cached list of pages with list blocks when a post that has one
124 * is saved, or one that places a synced pattern (which may hold a list).
125 * A page that loses its block stays listed until the cache expires, which
126 * costs one extra page purged per save, never a stale one.
127 *
128 * @param int $post_id Post ID.
129 * @param \WP_Post $post Post.
130 */
131 public static function forget_list_pages_on_save( $post_id, $post = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- hook signature.
132 $content = is_object( $post ) && isset( $post->post_content ) ? (string) $post->post_content : '';
133 foreach ( array_merge( self::LIST_MARKERS, array( self::SYNCED_MARKER ) ) as $marker ) {
134 if ( false !== strpos( $content, $marker ) ) {
135 delete_transient( self::LIST_PAGES_TRANSIENT );
136 return;
137 }
138 }
139 }
140
141 /**
142 * Keep the terms a post had before this save replaced them.
143 *
144 * Called once per taxonomy. The first call for a taxonomy carries what
145 * the post had before the save; later calls in the same request (the
146 * REST API sets terms after `save_post`, then again) carry what an
147 * earlier call already set, so the union is what matters.
148 *
149 * @param int $object_id Post ID.
150 * @param array $terms Terms passed in.
151 * @param array $tt_ids New term_taxonomy_ids.
152 * @param string $taxonomy Taxonomy.
153 * @param bool $append Whether terms were appended.
154 * @param array $old_tt_ids Term_taxonomy_ids before the change.
155 */
156 public static function remember_old_terms( $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed -- hook signature.
157 $object_id = (int) $object_id;
158 if ( $object_id < 1 || ! is_array( $old_tt_ids ) ) {
159 return;
160 }
161 self::$old_terms_by_taxonomy[ $object_id ][ (string) $taxonomy ] = self::$old_terms_by_taxonomy[ $object_id ][ (string) $taxonomy ] ?? array();
162 foreach ( $old_tt_ids as $tt_id ) {
163 self::$old_terms[ $object_id ][ (int) $tt_id ] = (int) $tt_id;
164 self::$old_terms_by_taxonomy[ $object_id ][ (string) $taxonomy ][ (int) $tt_id ] = (int) $tt_id;
165 }
166 }
167
168 /**
169 * The pages a change to this post can make render differently.
170 *
171 * @param \WP_Post $post The post as it is now.
172 * @param \WP_Post|null $before The post before this save, when known.
173 * @return string[] Absolute URLs, de-duplicated.
174 */
175 public static function for_post( \WP_Post $post, ?\WP_Post $before = null ): array {
176 $urls = array();
177
178 $now_public = self::is_public( $post );
179 $before_public = $before instanceof \WP_Post && self::is_public( $before );
180
181 if ( $now_public ) {
182 $link = get_permalink( $post );
183 if ( is_string( $link ) && '' !== $link ) {
184 $urls[] = $link;
185 $urls = array_merge( $urls, self::split_pages( $post, $link ), self::comment_pages( $post, $link ) );
186 }
187 }
188 // The address it had. Different after a slug or parent change; the
189 // only one there is after unpublishing or trashing.
190 if ( $before_public ) {
191 $old = get_permalink( $before );
192 if ( is_string( $old ) && '' !== $old ) {
193 $urls[] = $old;
194 }
195 }
196
197 // Nothing anonymous could see before or after: no list changed.
198 if ( ! $now_public && ! $before_public ) {
199 return array();
200 }
201
202 $type = (string) $post->post_type;
203
204 // An edit that cannot move the post within a date-ordered list. On a
205 // long list only the page holding the post changed, so position()
206 // finds it; every later page keeps the same posts.
207 $plain = self::is_plain_change( $post, $before );
208 $date = (string) ( $post->post_date ?? '' );
209
210 // Home and the posts page list `post`.
211 if ( 'post' === $type ) {
212 $count = self::published_count( 'post' );
213 $at = $plain ? static fn() => self::position( array( 'post' ), '', $date ) : null;
214 if ( 'page' === get_option( 'show_on_front' ) ) {
215 $urls[] = home_url( '/' );
216 $page_id = (int) get_option( 'page_for_posts' );
217 if ( $page_id > 0 ) {
218 $posts_page = get_permalink( $page_id );
219 if ( is_string( $posts_page ) && '' !== $posts_page ) {
220 $urls = array_merge( $urls, self::paged( $posts_page, $count, $at ) );
221 }
222 }
223 } else {
224 $urls = array_merge( $urls, self::paged( home_url( '/' ), $count, $at ) );
225 }
226 } else {
227 // A static front page can show anything: a block, a shortcode.
228 $urls[] = home_url( '/' );
229 }
230
231 // The post type's own archive.
232 if ( 'post' !== $type && 'page' !== $type ) {
233 $archive = get_post_type_archive_link( $type );
234 if ( is_string( $archive ) && '' !== $archive ) {
235 $at = $plain ? static fn() => self::position( array( $type ), '', $date ) : null;
236 $urls = array_merge( $urls, self::paged( $archive, self::published_count( $type ), $at ) );
237 $feed = get_post_type_archive_feed_link( $type );
238 if ( is_string( $feed ) && '' !== $feed ) {
239 $urls[] = $feed;
240 }
241 }
242 }
243
244 // Every public term it is in now, and every one it was in before.
245 $urls = array_merge( $urls, self::term_pages( $post, $plain ) );
246
247 // The author's archive.
248 if ( (int) ( $post->post_author ?? 0 ) > 0 && post_type_supports( $type, 'author' ) ) {
249 $author = get_author_posts_url( (int) $post->post_author );
250 if ( is_string( $author ) && '' !== $author ) {
251 $author_id = (int) $post->post_author;
252 $at = $plain ? static fn() => self::position( array( $type ), self::prepare( ' AND p.post_author = %d', $author_id ), $date ) : null;
253 $urls = array_merge( $urls, self::paged( $author, (int) count_user_posts( $author_id, $type, true ), $at ) );
254 $urls[] = get_author_feed_link( $author_id );
255 }
256 }
257
258 // Date archives list `post` only.
259 if ( 'post' === $type ) {
260 $urls = array_merge( $urls, self::date_pages( $post, $plain ) );
261 }
262
263 // A changed date or author leaves the old archives listing it.
264 if ( $before_public ) {
265 if ( 'post' === $type && (string) ( $before->post_date ?? '' ) !== (string) ( $post->post_date ?? '' ) ) {
266 $urls = array_merge( $urls, self::date_pages( $before ) );
267 }
268 if ( (int) ( $before->post_author ?? 0 ) > 0 && (int) $before->post_author !== (int) ( $post->post_author ?? 0 ) ) {
269 $old_author = get_author_posts_url( (int) $before->post_author );
270 if ( is_string( $old_author ) && '' !== $old_author ) {
271 $urls = array_merge( $urls, self::paged( $old_author, (int) count_user_posts( (int) $before->post_author, $type, true ) ) );
272 $urls[] = get_author_feed_link( (int) $before->post_author );
273 }
274 }
275 }
276
277 // Feeds.
278 $urls[] = get_feed_link();
279 $urls[] = get_feed_link( 'comments_' . get_default_feed() );
280 if ( $now_public ) {
281 $urls[] = get_post_comments_feed_link( $post->ID );
282 }
283
284 // Neighbours whose previous/next links now point somewhere else: the
285 // ones it has now, and the ones it had before a new date or category
286 // moved it away from them.
287 $urls = array_merge( $urls, self::adjacent_post_urls( $post ) );
288 if ( $before_public ) {
289 $urls = array_merge( $urls, self::$old_neighbours[ (int) $post->ID ] ?? array() );
290 }
291
292 // Parents, for hierarchical types that list their children.
293 foreach ( get_post_ancestors( $post ) as $ancestor_id ) {
294 $link = get_permalink( (int) $ancestor_id );
295 if ( is_string( $link ) && '' !== $link ) {
296 $urls[] = $link;
297 }
298 }
299
300 // Pages whose own content lists posts with a block.
301 $urls = array_merge( $urls, self::pages_with_list_blocks() );
302
303 /**
304 * Filter the URLs cleared when one post is purged.
305 *
306 * Used by both the automatic purge on save and the "Purge this post"
307 * admin action, so they always clear the same pages.
308 *
309 * @param string[] $urls Absolute URLs.
310 * @param \WP_Post $post The post being purged.
311 */
312 $urls = (array) apply_filters( 'xspeed_post_purge_urls', $urls, $post );
313
314 $urls = array_filter( $urls, static fn( $url ) => is_string( $url ) && '' !== $url );
315
316 return array_values( array_unique( $urls ) );
317 }
318
319 /**
320 * Whether this change alters a post list the theme draws outside the
321 * main loop, on pages the rules cannot name.
322 *
323 * Those lists can sit on every page, so when one changes the caller has
324 * to purge every page. Most edits change none of them:
325 *
326 * - A newest-first list of N posts (core's Recent Posts widget, a Latest
327 * Posts block, a Query Loop sorted by date with no other filter, such
328 * as Twenty Twenty-Five's "More posts" under every post) changes when
329 * the post is, or was, one of the N it shows: its date now or before
330 * is at least as new as the Nth newest. That covers a publish, a
331 * withdraw, a date move and an edit to a listed post, and leaves an old
332 * post's edit or withdrawal alone.
333 * - Core's Archives and Calendar change only on publish, withdraw or a
334 * date change.
335 * - A list of pages (the Page List block, a Navigation block with no menu
336 * of its own, which falls back to one, the Pages widget, or a classic
337 * theme's menu location with no menu, which falls back to
338 * wp_page_menu()) changes when a page is published or withdrawn, or
339 * its title, slug, parent or order changes. An edit to a page's text
340 * leaves it alone.
341 * - The Categories and Tag Cloud widgets and blocks change when a post
342 * is published or withdrawn, or moves between terms: they show counts,
343 * hide empty terms and size tags by count.
344 * - A classic menu draws each item with its post's current title and
345 * link, so it changes when a post in it is published or withdrawn, or
346 * its title, slug or parent changes.
347 * - A block Navigation menu stores each link's label and URL, and skips
348 * a link whose post is not published. It changes when a post it links
349 * is published or withdrawn.
350 * - A Query Loop that includes sticky posts (its default) puts them
351 * first whatever their date, so it also changes when the post is
352 * sticky, or was stuck or unstuck in this request.
353 * - Any list this cannot read (another order, a category or author
354 * filter) counts as changed.
355 *
356 * Found in active sidebars, in a block theme's templates with the
357 * template parts, patterns, synced patterns and navigation menus they
358 * use, and in the newest-N lists Listing_Pages saw pages run. A list that
359 * puts sticky posts first also changes when a sticky post is edited, or a
360 * post is made sticky or unsticky.
361 *
362 * @param \WP_Post $post Post as it is now.
363 * @param \WP_Post|null $before Post before the change, null when new.
364 */
365 public static function lists_changed_by( \WP_Post $post, ?\WP_Post $before ): bool {
366 $specs = self::post_list_specs();
367 if ( array() === $specs ) {
368 return false;
369 }
370 $was_public = $before instanceof \WP_Post && self::is_public( $before );
371 $status_changed = self::is_public( $post ) !== $was_public;
372 $date_changed = $before instanceof \WP_Post && (string) ( $before->post_date ?? '' ) !== (string) ( $post->post_date ?? '' );
373
374 foreach ( $specs as $spec ) {
375 if ( 'any' === $spec['kind'] ) {
376 return true;
377 }
378 if ( 'dated' === $spec['kind'] ) {
379 if ( $status_changed || $date_changed ) {
380 return true;
381 }
382 continue;
383 }
384 if ( 'pages' === $spec['kind'] ) {
385 if ( 'page' === (string) $post->post_type && ( $status_changed || self::listing_fields_changed( $post, $before ) ) ) {
386 return true;
387 }
388 continue;
389 }
390 if ( 'terms' === $spec['kind'] ) {
391 if ( $status_changed || self::terms_changed( $post ) ) {
392 return true;
393 }
394 continue;
395 }
396 if ( 'linked' === $spec['kind'] ) {
397 if ( $status_changed && in_array( (int) $post->ID, $spec['ids'], true ) ) {
398 return true;
399 }
400 continue;
401 }
402 if ( 'menu' === $spec['kind'] ) {
403 if ( ( $status_changed || self::listing_fields_changed( $post, $before ) ) && self::in_classic_menu( $post ) ) {
404 return true;
405 }
406 continue;
407 }
408 // 'recent': the newest N of a type, or of `any` type. A post that
409 // changed type is matched on the type it had as well.
410 $types = array( (string) $post->post_type );
411 if ( $before instanceof \WP_Post ) {
412 $types[] = (string) $before->post_type;
413 }
414 if ( 'any' !== $spec['type'] && ! in_array( $spec['type'], $types, true ) ) {
415 continue;
416 }
417 // A list that puts sticky posts first shows a sticky post
418 // whatever its date.
419 if ( ! empty( $spec['sticky'] ) && self::is_or_was_sticky( (int) $post->ID ) ) {
420 return true;
421 }
422 $dates = array();
423 if ( self::is_public( $post ) ) {
424 $dates[] = (string) ( $post->post_date ?? '' );
425 }
426 if ( $was_public ) {
427 $dates[] = (string) ( $before->post_date ?? '' );
428 }
429 $window = 'any' === $spec['type'] ? (string) $post->post_type : (string) $spec['type'];
430 if ( self::in_latest_window( $window, (int) $spec['n'], $dates ) ) {
431 return true;
432 }
433 }
434 return false;
435 }
436
437 /**
438 * Whether the site draws a list of recent comments on pages the rules
439 * cannot name: the Recent Comments widget or the Latest Comments block.
440 */
441 public static function site_lists_comments(): bool {
442 if ( null === self::$site_lists_comments ) {
443 $found = false;
444 foreach ( self::active_widget_ids() as $widget_id ) {
445 if ( 0 === strpos( $widget_id, 'recent-comments-' ) ) {
446 $found = true;
447 break;
448 }
449 }
450 if ( ! $found ) {
451 foreach ( self::theme_and_widget_blocks() as $block ) {
452 if ( 'core/latest-comments' === $block['blockName'] ) {
453 $found = true;
454 break;
455 }
456 }
457 }
458 self::$site_lists_comments = $found;
459 }
460 return self::$site_lists_comments;
461 }
462
463 /**
464 * Note the posts a change to `sticky_posts` stuck or unstuck.
465 *
466 * @param mixed $old_value Sticky post IDs before.
467 * @param mixed $value Sticky post IDs after.
468 * @return int[] The IDs that changed.
469 */
470 public static function remember_sticky_change( $old_value, $value ): array {
471 $old = array_map( 'intval', is_array( $old_value ) ? $old_value : array() );
472 $new = array_map( 'intval', is_array( $value ) ? $value : array() );
473 $changed = array_values( array_unique( array_merge( array_diff( $old, $new ), array_diff( $new, $old ) ) ) );
474 foreach ( $changed as $id ) {
475 self::$sticky_changed[ $id ] = true;
476 }
477 return $changed;
478 }
479
480 /**
481 * Whether the theme draws a list of this post type that puts sticky
482 * posts first, on pages the rules cannot name. A list of any kind
483 * counts too, and so does a recorded newest-N list of `any` type
484 * (Listing_Pages::recent_specs()).
485 *
486 * @param string $type Post type.
487 */
488 public static function lists_show_sticky( string $type ): bool {
489 foreach ( self::post_list_specs() as $spec ) {
490 if ( 'any' === $spec['kind'] ) {
491 return true;
492 }
493 if ( 'recent' === $spec['kind'] && ! empty( $spec['sticky'] ) && ( 'any' === $spec['type'] || $spec['type'] === $type ) ) {
494 return true;
495 }
496 }
497 return false;
498 }
499
500 /**
501 * Page 1 of the list WordPress's main query draws for `post`: the home
502 * page, or the posts page when the front page is static. '' when the
503 * front page is static and no posts page is set.
504 */
505 public static function posts_page_url(): string {
506 if ( 'page' !== get_option( 'show_on_front' ) ) {
507 return (string) home_url( '/' );
508 }
509 $page_id = (int) get_option( 'page_for_posts' );
510 if ( $page_id < 1 ) {
511 return '';
512 }
513 $url = get_permalink( $page_id );
514 return is_string( $url ) ? $url : '';
515 }
516
517 /** Whether the post is sticky, or was stuck or unstuck in this request. */
518 private static function is_or_was_sticky( int $post_id ): bool {
519 if ( isset( self::$sticky_changed[ $post_id ] ) ) {
520 return true;
521 }
522 $sticky = get_option( 'sticky_posts', array() );
523 return is_array( $sticky ) && in_array( $post_id, array_map( 'intval', $sticky ), true );
524 }
525
526 /**
527 * The pages one comment change can make render differently: the post,
528 * its comment pages and the comment feeds.
529 *
530 * @param \WP_Post $post Post the comment is on.
531 * @return string[]
532 */
533 public static function for_comment( \WP_Post $post ): array {
534 if ( ! self::is_public( $post ) ) {
535 return array();
536 }
537 $link = get_permalink( $post );
538 if ( ! is_string( $link ) || '' === $link ) {
539 return array();
540 }
541 $urls = array_merge( array( $link ), self::comment_pages( $post, $link ) );
542 $urls[] = get_post_comments_feed_link( $post->ID );
543 $urls[] = get_feed_link( 'comments_' . get_default_feed() );
544 $urls = array_filter( $urls, static fn( $url ) => is_string( $url ) && '' !== $url );
545 return array_values( array_unique( $urls ) );
546 }
547
548 /**
549 * Whether a save changed what a list of pages or a menu shows for the
550 * post: its title, its slug, its parent or its order.
551 *
552 * @param \WP_Post $post Post as it is now.
553 * @param \WP_Post|null $before Post before the change, null when new.
554 */
555 private static function listing_fields_changed( \WP_Post $post, ?\WP_Post $before ): bool {
556 if ( ! $before instanceof \WP_Post ) {
557 return true;
558 }
559 foreach ( array( 'post_title', 'post_name', 'post_parent', 'menu_order' ) as $field ) {
560 if ( (string) ( $before->$field ?? '' ) !== (string) ( $post->$field ?? '' ) ) {
561 return true;
562 }
563 }
564 return false;
565 }
566
567 /**
568 * Whether this save moved the post between public terms: the terms it
569 * had before (remember_old_terms()) differ from the ones it has now.
570 * A save that set no terms changed none.
571 */
572 private static function terms_changed( \WP_Post $post ): bool {
573 foreach ( self::$old_terms_by_taxonomy[ (int) $post->ID ] ?? array() as $taxonomy => $old ) {
574 $now = array();
575 $current = get_the_terms( $post, (string) $taxonomy );
576 if ( is_array( $current ) ) {
577 foreach ( $current as $term ) {
578 $now[] = (int) $term->term_taxonomy_id;
579 }
580 }
581 $old = array_values( $old );
582 sort( $old );
583 sort( $now );
584 if ( $old !== $now ) {
585 return true;
586 }
587 }
588 return false;
589 }
590
591 /** Whether a classic menu has an item that draws this post. */
592 private static function in_classic_menu( \WP_Post $post ): bool {
593 if ( ! function_exists( 'wp_get_associated_nav_menu_items' ) ) {
594 return false;
595 }
596 $items = wp_get_associated_nav_menu_items( (int) $post->ID, 'post_type', (string) $post->post_type );
597 return is_array( $items ) && array() !== $items;
598 }
599
600 /**
601 * Keep the post's neighbours as they are before a save, which can move
602 * it to another date or category and leave them linking to it.
603 *
604 * @param \WP_Post $post Post as it is before the save.
605 */
606 public static function remember_old_neighbours( \WP_Post $post ): void {
607 self::$old_neighbours[ (int) $post->ID ] = self::adjacent_post_urls( $post );
608 }
609
610 /** Test seam: forget per-request state. */
611 public static function reset(): void {
612 self::$old_terms = array();
613 self::$old_terms_by_taxonomy = array();
614 self::$post_list_specs = null;
615 self::$theme_blocks = null;
616 self::$site_lists_comments = null;
617 self::$sticky_changed = array();
618 self::$old_neighbours = array();
619 }
620
621 /**
622 * Forget a post's captured terms once its purge has run.
623 *
624 * @param int $post_id Post ID.
625 */
626 public static function forget( int $post_id ): void {
627 unset( self::$old_terms[ $post_id ], self::$old_terms_by_taxonomy[ $post_id ], self::$sticky_changed[ $post_id ], self::$old_neighbours[ $post_id ] );
628 }
629
630 /**
631 * Whether an anonymous visitor can see this post: published, of a type
632 * with a front end.
633 */
634 public static function is_public( \WP_Post $post ): bool {
635 return 'publish' === $post->post_status && is_post_type_viewable( $post->post_type );
636 }
637
638 // ---------------------------------------------------------------------
639
640 /**
641 * A list page and its later pages, through the one that will exist after
642 * this change plus one.
643 *
644 * The +1 covers a list that just got shorter: the page that dropped off
645 * the end still holds a cached copy.
646 *
647 * With `$at`, for a plain edit (is_plain_edit()), a list longer than
648 * PAGED_IN_FULL pages names only its first page and the page that holds
649 * the post. `$at` returns how many posts in the list are newer than the
650 * post and how many are as new or newer, which bound its place when
651 * other posts share its date. The first page stays in, for a sticky post
652 * shown there as well.
653 *
654 * @param string $base First page.
655 * @param int $count Items in the list now.
656 * @param callable|null $at Returns array{0:int,1:int} or null.
657 * @return string[]
658 */
659 private static function paged( string $base, int $count, ?callable $at = null ): array {
660 $urls = array( $base );
661 $per_page = max( 1, (int) get_option( 'posts_per_page', 10 ) );
662 $pages = (int) ceil( max( 0, $count ) / $per_page ) + 1;
663 $first = 2;
664 if ( null !== $at && $pages > self::PAGED_IN_FULL ) {
665 $place = $at();
666 if ( is_array( $place ) ) {
667 $first = intdiv( max( 0, (int) $place[0] ), $per_page ) + 1;
668 $pages = max( $first, intdiv( max( 0, (int) $place[1] - 1 ), $per_page ) + 1 );
669 $first = max( 2, $first );
670 }
671 }
672 for ( $i = $first; $i <= $pages; $i++ ) {
673 $urls[] = self::page_url( $base, $i );
674 }
675 return $urls;
676 }
677
678 /** Page `$i` of the list at `$base`. */
679 private static function page_url( string $base, int $i ): string {
680 global $wp_rewrite;
681 $pretty = is_object( $wp_rewrite ) && method_exists( $wp_rewrite, 'using_permalinks' ) && $wp_rewrite->using_permalinks();
682 $base_n = is_object( $wp_rewrite ) && ! empty( $wp_rewrite->pagination_base ) ? $wp_rewrite->pagination_base : 'page';
683 return $pretty
684 ? trailingslashit( $base ) . user_trailingslashit( $base_n . '/' . $i, 'paged' )
685 : add_query_arg( 'paged', $i, $base );
686 }
687
688 /**
689 * Whether this change is a plain edit (is_plain_edit()) of a post that
690 * was public before and is public now. Asked before forget(), since it
691 * compares the terms the save replaced.
692 *
693 * @param \WP_Post $post Post as it is now.
694 * @param \WP_Post|null $before Post before the change, null when new.
695 */
696 public static function is_plain_change( \WP_Post $post, ?\WP_Post $before ): bool {
697 return $before instanceof \WP_Post
698 && self::is_public( $post )
699 && self::is_public( $before )
700 && self::is_plain_edit( $post, $before );
701 }
702
703 /**
704 * Whether a save cannot move the post within its lists: it was public
705 * before and after, and kept its type, date, author, parent, terms,
706 * title and menu order. A text, excerpt or slug edit is plain. A publish,
707 * a withdrawal, a new date or a move between terms shifts every later
708 * page and is not. Title and menu order are kept too, for an archive a
709 * theme sorts by either instead of by date. An archive sorted by
710 * something else again (a custom field) is not detected.
711 *
712 * @param \WP_Post $post Post as it is now.
713 * @param \WP_Post $before Post before the change.
714 */
715 private static function is_plain_edit( \WP_Post $post, \WP_Post $before ): bool {
716 foreach ( array( 'post_type', 'post_date', 'post_author', 'post_parent', 'post_title', 'menu_order' ) as $field ) {
717 if ( (string) ( $before->$field ?? '' ) !== (string) ( $post->$field ?? '' ) ) {
718 return false;
719 }
720 }
721 return ! self::terms_changed( $post );
722 }
723
724 /**
725 * How many published posts of these types, in the list `$where` narrows
726 * to, are newer than `$date`, and how many are as new or newer.
727 *
728 * @param string[] $types Post types.
729 * @param string $where Prepared `AND ...` on `p`, or ''.
730 * @param string $date The post's `post_date`.
731 * @return array{0:int,1:int}|null Null when it cannot be read.
732 */
733 private static function position( array $types, string $where, string $date ): ?array {
734 global $wpdb;
735 if ( ! is_object( $wpdb ) || '' === $date || array() === $types ) {
736 return null;
737 }
738 // $where is prepared by the caller.
739 $sql = $wpdb->prepare(
740 "SELECT SUM(p.post_date > %s) AS newer, SUM(p.post_date >= %s) AS upto FROM {$wpdb->posts} p WHERE p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')',
741 array_merge( array( $date, $date ), $types )
742 ) . $where;
743 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- prepared above; a count per long list on a plain edit.
744 $row = $wpdb->get_row( $sql );
745 if ( ! is_object( $row ) || ! isset( $row->newer, $row->upto ) ) {
746 return null;
747 }
748 return array( (int) $row->newer, (int) $row->upto );
749 }
750
751 /**
752 * `$wpdb->prepare()` for a WHERE fragment, or '' without a database.
753 *
754 * @param string $sql Fragment with placeholders.
755 * @param mixed ...$args Values.
756 */
757 private static function prepare( string $sql, ...$args ): string {
758 global $wpdb;
759 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- this is the prepare call.
760 return is_object( $wpdb ) ? (string) $wpdb->prepare( $sql, ...$args ) : '';
761 }
762
763 /**
764 * Pages of a post split with `<!--nextpage-->`.
765 *
766 * @return string[]
767 */
768 private static function split_pages( \WP_Post $post, string $link ): array {
769 $parts = substr_count( (string) ( $post->post_content ?? '' ), '<!--nextpage-->' ) + 1;
770 $urls = array();
771 global $wp_rewrite;
772 $pretty = is_object( $wp_rewrite ) && method_exists( $wp_rewrite, 'using_permalinks' ) && $wp_rewrite->using_permalinks();
773 for ( $i = 2; $i <= $parts; $i++ ) {
774 $urls[] = $pretty
775 ? trailingslashit( $link ) . user_trailingslashit( (string) $i, 'single_paged' )
776 : add_query_arg( 'page', $i, $link );
777 }
778 return $urls;
779 }
780
781 /**
782 * Comment pages, when comments are split into pages.
783 *
784 * @return string[]
785 */
786 private static function comment_pages( \WP_Post $post, string $link ): array {
787 if ( ! get_option( 'page_comments' ) ) {
788 return array();
789 }
790 $per_page = max( 1, (int) get_option( 'comments_per_page', 50 ) );
791 $pages = (int) ceil( (int) ( $post->comment_count ?? 0 ) / $per_page ) + 1;
792 $urls = array();
793 global $wp_rewrite;
794 $pretty = is_object( $wp_rewrite ) && method_exists( $wp_rewrite, 'using_permalinks' ) && $wp_rewrite->using_permalinks();
795 $base_n = is_object( $wp_rewrite ) && ! empty( $wp_rewrite->comments_pagination_base ) ? $wp_rewrite->comments_pagination_base : 'comment-page';
796 for ( $i = 1; $i <= $pages; $i++ ) {
797 $urls[] = $pretty
798 ? trailingslashit( $link ) . user_trailingslashit( $base_n . '-' . $i, 'commentpaged' )
799 : add_query_arg( 'cpage', $i, $link );
800 }
801 return $urls;
802 }
803
804 /**
805 * Every public term the post is in, and was in before this save, with
806 * parent terms, all of their pages and their feeds.
807 *
808 * @return string[]
809 */
810 private static function term_pages( \WP_Post $post, bool $plain = false ): array {
811 $terms = array();
812 foreach ( get_object_taxonomies( $post->post_type, 'objects' ) as $taxonomy ) {
813 if ( empty( $taxonomy->public ) || empty( $taxonomy->rewrite ) && ! $taxonomy->query_var ) {
814 continue;
815 }
816 $current = get_the_terms( $post, $taxonomy->name );
817 if ( is_array( $current ) ) {
818 foreach ( $current as $term ) {
819 $terms[ (int) $term->term_taxonomy_id ] = $term;
820 }
821 }
822 }
823 foreach ( self::$old_terms[ (int) $post->ID ] ?? array() as $tt_id ) {
824 if ( isset( $terms[ $tt_id ] ) ) {
825 continue;
826 }
827 $term = get_term_by( 'term_taxonomy_id', $tt_id );
828 if ( $term instanceof \WP_Term ) {
829 $taxonomy = get_taxonomy( $term->taxonomy );
830 if ( $taxonomy && ! empty( $taxonomy->public ) ) {
831 $terms[ $tt_id ] = $term;
832 }
833 }
834 }
835
836 // Parent terms list their children's posts too.
837 foreach ( $terms as $term ) {
838 if ( ! is_taxonomy_hierarchical( $term->taxonomy ) ) {
839 continue;
840 }
841 foreach ( get_ancestors( (int) $term->term_id, $term->taxonomy, 'taxonomy' ) as $ancestor_id ) {
842 $ancestor = get_term( (int) $ancestor_id, $term->taxonomy );
843 if ( $ancestor instanceof \WP_Term ) {
844 $terms[ (int) $ancestor->term_taxonomy_id ] = $ancestor;
845 }
846 }
847 }
848
849 $urls = array();
850 foreach ( $terms as $term ) {
851 $link = get_term_link( $term );
852 if ( ! is_string( $link ) || '' === $link ) {
853 continue;
854 }
855 $at = $plain ? static fn() => self::position_in_term( $post, $term ) : null;
856 $urls = array_merge( $urls, self::paged( $link, self::term_post_count( $post, $term ), $at ) );
857 $feed = get_term_feed_link( (int) $term->term_id, $term->taxonomy );
858 if ( is_string( $feed ) && '' !== $feed ) {
859 $urls[] = $feed;
860 }
861 }
862 return $urls;
863 }
864
865 /**
866 * The post's place in a term's archive, which also lists the posts of
867 * the term's children in a hierarchical taxonomy.
868 *
869 * @return array{0:int,1:int}|null
870 */
871 private static function position_in_term( \WP_Post $post, \WP_Term $term ): ?array {
872 global $wpdb;
873 if ( ! is_object( $wpdb ) ) {
874 return null;
875 }
876 $in = implode( ',', array_map( 'intval', array_keys( self::term_tree( $term ) ) ) );
877 return self::position( self::term_post_types( $post, $term ), " AND p.ID IN (SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id IN ($in))", (string) ( $post->post_date ?? '' ) );
878 }
879
880 /**
881 * How many published posts a term's archive lists. In a hierarchical
882 * taxonomy that includes the posts of every child term, which the
883 * term's own `count` leaves out: a parent category whose posts all sit
884 * in a child counts 0, so only its first page was named and its later
885 * pages kept the old copy.
886 *
887 * Without a database to ask, the counts of the term and its children
888 * are added up. A post in both counts twice, which names a page past
889 * the end, never one too few.
890 */
891 private static function term_post_count( \WP_Post $post, \WP_Term $term ): int {
892 $tree = self::term_tree( $term );
893 if ( count( $tree ) < 2 ) {
894 return (int) $term->count;
895 }
896 global $wpdb;
897 if ( ! is_object( $wpdb ) ) {
898 $sum = 0;
899 foreach ( $tree as $member ) {
900 $sum += (int) $member->count;
901 }
902 return $sum;
903 }
904 $types = self::term_post_types( $post, $term );
905 $in = implode( ',', array_map( 'intval', array_keys( $tree ) ) );
906 $list = implode( ',', array_fill( 0, count( $types ), '%s' ) );
907 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $list is placeholders and $in is integers.
908 $sql = $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p WHERE p.post_status = 'publish' AND p.post_type IN ($list) AND p.ID IN (SELECT object_id FROM {$wpdb->term_relationships} WHERE term_taxonomy_id IN ($in))", $types );
909 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- prepared above; one count per parent term per save.
910 return (int) $wpdb->get_var( $sql );
911 }
912
913 /**
914 * The term and, in a hierarchical taxonomy, every term below it, keyed
915 * by term_taxonomy_id: the terms whose posts its archive lists.
916 *
917 * @return array<int,\WP_Term>
918 */
919 private static function term_tree( \WP_Term $term ): array {
920 $tree = array( (int) $term->term_taxonomy_id => $term );
921 if ( is_taxonomy_hierarchical( $term->taxonomy ) && function_exists( 'get_term_children' ) ) {
922 $children = get_term_children( (int) $term->term_id, $term->taxonomy );
923 foreach ( is_array( $children ) ? $children : array() as $child_id ) {
924 $child = get_term( (int) $child_id, $term->taxonomy );
925 if ( $child instanceof \WP_Term ) {
926 $tree[ (int) $child->term_taxonomy_id ] = $child;
927 }
928 }
929 }
930 return $tree;
931 }
932
933 /**
934 * Post types a term's archive lists.
935 *
936 * @return string[]
937 */
938 private static function term_post_types( \WP_Post $post, \WP_Term $term ): array {
939 $taxonomy = get_taxonomy( $term->taxonomy );
940 return $taxonomy && ! empty( $taxonomy->object_type ) ? array_map( 'strval', (array) $taxonomy->object_type ) : array( (string) $post->post_type );
941 }
942
943 /**
944 * The year, month and day archives the post appears on, each with all of
945 * its pages, or for a plain edit the page that holds the post.
946 *
947 * @return string[]
948 */
949 private static function date_pages( \WP_Post $post, bool $plain = false ): array {
950 $time = strtotime( (string) ( $post->post_date ?? '' ) );
951 if ( false === $time ) {
952 return array();
953 }
954 $y = (int) gmdate( 'Y', $time );
955 $m = (int) gmdate( 'm', $time );
956 $d = (int) gmdate( 'd', $time );
957
958 $date = (string) $post->post_date;
959 $in = static function ( string $where ) use ( $plain, $date ): ?callable {
960 return $plain ? static fn() => self::position( array( 'post' ), $where, $date ) : null;
961 };
962
963 $urls = array();
964 $urls = array_merge( $urls, self::paged( get_year_link( $y ), self::count_in( $y ), $in( self::prepare( ' AND YEAR(p.post_date) = %d', $y ) ) ) );
965 $urls = array_merge( $urls, self::paged( get_month_link( $y, $m ), self::count_in( $y, $m ), $in( self::prepare( ' AND YEAR(p.post_date) = %d AND MONTH(p.post_date) = %d', $y, $m ) ) ) );
966 $urls = array_merge( $urls, self::paged( get_day_link( $y, $m, $d ), self::count_in( $y, $m, $d ), $in( self::prepare( ' AND YEAR(p.post_date) = %d AND MONTH(p.post_date) = %d AND DAYOFMONTH(p.post_date) = %d', $y, $m, $d ) ) ) );
967 return $urls;
968 }
969
970 /**
971 * Published posts in a year, month or day.
972 */
973 private static function count_in( int $y, int $m = 0, int $d = 0 ): int {
974 global $wpdb;
975 if ( ! is_object( $wpdb ) ) {
976 return 0;
977 }
978 $where = $wpdb->prepare( 'YEAR(post_date) = %d', $y );
979 if ( $m > 0 ) {
980 $where .= $wpdb->prepare( ' AND MONTH(post_date) = %d', $m );
981 }
982 if ( $d > 0 ) {
983 $where .= $wpdb->prepare( ' AND DAYOFMONTH(post_date) = %d', $d );
984 }
985 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- $where is built from prepare() above; a count per save.
986 return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND {$where}" );
987 }
988
989 private static function published_count( string $type ): int {
990 $counts = wp_count_posts( $type );
991 return is_object( $counts ) && isset( $counts->publish ) ? (int) $counts->publish : 0;
992 }
993
994 /**
995 * Permalinks of the four posts adjacent to this one: previous and next,
996 * each in the whole timeline and within a shared term.
997 *
998 * @return string[]
999 */
1000 public static function adjacent_post_urls( \WP_Post $post ): array {
1001 $urls = array();
1002
1003 // get_adjacent_post() reads the global $post. Swap it for the one
1004 // being purged and put it back, or on an edit screen we would collect
1005 // the neighbours of whatever WordPress happened to have loaded.
1006 $previous_global = $GLOBALS['post'] ?? null;
1007 $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below.
1008
1009 try {
1010 foreach ( array( array( false, true ), array( true, true ), array( false, false ), array( true, false ) ) as $args ) {
1011 list( $same_term, $previous ) = $args;
1012 $adjacent = get_adjacent_post( $same_term, '', $previous );
1013 if ( $adjacent instanceof \WP_Post && 'publish' === $adjacent->post_status ) {
1014 $link = get_permalink( $adjacent );
1015 if ( is_string( $link ) && '' !== $link ) {
1016 $urls[] = $link;
1017 }
1018 }
1019 }
1020 } finally {
1021 if ( null === $previous_global ) {
1022 unset( $GLOBALS['post'] );
1023 } else {
1024 $GLOBALS['post'] = $previous_global; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring.
1025 }
1026 }
1027
1028 return $urls;
1029 }
1030
1031 /**
1032 * Published pages and posts whose own content lists posts with a block.
1033 *
1034 * Those pages list the post wherever they are, so they go in the list by
1035 * name. More than LIST_PAGE_LIMIT of them and the caller is better off
1036 * purging the site: post_list_specs() treats that as a list of any kind.
1037 *
1038 * @return string[]
1039 */
1040 private static function pages_with_list_blocks(): array {
1041 $ids = self::list_block_post_ids();
1042 if ( count( $ids ) > self::LIST_PAGE_LIMIT ) {
1043 return array();
1044 }
1045 $urls = array();
1046 foreach ( $ids as $id ) {
1047 $link = get_permalink( (int) $id );
1048 if ( is_string( $link ) && '' !== $link ) {
1049 $urls[] = $link;
1050 }
1051 }
1052 return $urls;
1053 }
1054
1055 /**
1056 * IDs of published posts whose content has a list block, directly or
1057 * through a synced pattern that holds one.
1058 *
1059 * A LIKE scan over every post's content, so it is cached for an hour and
1060 * dropped when a post with a list block or a synced pattern is saved
1061 * (forget_list_pages_on_save()). Synced patterns are followed one level:
1062 * a pattern placed inside another pattern is not.
1063 *
1064 * @return array<int,int>
1065 */
1066 private static function list_block_post_ids(): array {
1067 $cached = get_transient( self::LIST_PAGES_TRANSIENT );
1068 if ( is_array( $cached ) ) {
1069 return array_map( 'intval', $cached );
1070 }
1071 global $wpdb;
1072 if ( ! is_object( $wpdb ) ) {
1073 return array();
1074 }
1075 $like = array_map( static fn( $marker ) => '%' . $wpdb->esc_like( $marker ) . '%', self::LIST_MARKERS );
1076 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one indexed-status query per save.
1077 $rows = $wpdb->get_col(
1078 $wpdb->prepare(
1079 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type NOT IN ('revision','wp_block','wp_template','wp_template_part','wp_navigation') AND (post_content LIKE %s OR post_content LIKE %s OR post_content LIKE %s) LIMIT %d",
1080 $like[0],
1081 $like[1],
1082 $like[2],
1083 self::LIST_PAGE_LIMIT + 1
1084 )
1085 );
1086 $ids = array_map( 'intval', is_array( $rows ) ? $rows : array() );
1087 if ( count( $ids ) <= self::LIST_PAGE_LIMIT ) {
1088 $ids = array_values( array_unique( array_merge( $ids, self::posts_placing_list_patterns( $like ) ) ) );
1089 }
1090 set_transient( self::LIST_PAGES_TRANSIENT, $ids, HOUR_IN_SECONDS );
1091 return $ids;
1092 }
1093
1094 /**
1095 * IDs of published posts that place a synced pattern holding a list.
1096 *
1097 * More such patterns than LIST_PAGE_LIMIT come back as the pattern IDs
1098 * themselves: the caller only needs to see the list is too long to name.
1099 *
1100 * @param string[] $like The three LIKE patterns for LIST_MARKERS.
1101 * @return array<int,int>
1102 */
1103 private static function posts_placing_list_patterns( array $like ): array {
1104 global $wpdb;
1105 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- cached with the scan above.
1106 $patterns = $wpdb->get_col(
1107 $wpdb->prepare(
1108 "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'wp_block' AND post_status = 'publish' AND (post_content LIKE %s OR post_content LIKE %s OR post_content LIKE %s) LIMIT %d",
1109 $like[0],
1110 $like[1],
1111 $like[2],
1112 self::LIST_PAGE_LIMIT + 1
1113 )
1114 );
1115 $patterns = array_map( 'intval', is_array( $patterns ) ? $patterns : array() );
1116 if ( count( $patterns ) > self::LIST_PAGE_LIMIT ) {
1117 return $patterns;
1118 }
1119 $ids = array();
1120 foreach ( $patterns as $pattern_id ) {
1121 // `{"ref":12}` or `{"ref":12,...}`, so pattern 12 does not match 123.
1122 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- cached with the scan above.
1123 $rows = $wpdb->get_col(
1124 $wpdb->prepare(
1125 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type NOT IN ('revision','wp_block','wp_template','wp_template_part','wp_navigation') AND (post_content LIKE %s OR post_content LIKE %s) LIMIT %d",
1126 '%' . $wpdb->esc_like( self::SYNCED_MARKER . $pattern_id . '}' ) . '%',
1127 '%' . $wpdb->esc_like( self::SYNCED_MARKER . $pattern_id . ',' ) . '%',
1128 self::LIST_PAGE_LIMIT + 1
1129 )
1130 );
1131 $ids = array_merge( $ids, array_map( 'intval', is_array( $rows ) ? $rows : array() ) );
1132 if ( count( $ids ) > self::LIST_PAGE_LIMIT ) {
1133 break;
1134 }
1135 }
1136 return $ids;
1137 }
1138
1139 /**
1140 * Post lists drawn outside the main loop, as specs lists_changed_by()
1141 * can test a change against.
1142 *
1143 * @return array<int,array{kind:string,type?:string,n?:int,sticky?:bool,ids?:int[]}>
1144 */
1145 private static function post_list_specs(): array {
1146 if ( null !== self::$post_list_specs ) {
1147 return self::$post_list_specs;
1148 }
1149 $specs = array();
1150
1151 foreach ( self::active_widget_ids() as $widget_id ) {
1152 if ( 0 === strpos( $widget_id, 'recent-posts-' ) ) {
1153 $settings = get_option( 'widget_recent-posts', array() );
1154 $n = (int) substr( $widget_id, strlen( 'recent-posts-' ) );
1155 $number = is_array( $settings ) && isset( $settings[ $n ]['number'] ) ? (int) $settings[ $n ]['number'] : 5;
1156 $specs[] = array( 'kind' => 'recent', 'type' => 'post', 'n' => max( 1, $number ) );
1157 } elseif ( 0 === strpos( $widget_id, 'archives-' ) || 0 === strpos( $widget_id, 'calendar-' ) ) {
1158 $specs[] = array( 'kind' => 'dated' );
1159 } elseif ( 0 === strpos( $widget_id, 'pages-' ) ) {
1160 $specs[] = array( 'kind' => 'pages' );
1161 } elseif ( 0 === strpos( $widget_id, 'categories-' ) || 0 === strpos( $widget_id, 'tag_cloud-' ) ) {
1162 $specs[] = array( 'kind' => 'terms' );
1163 }
1164 }
1165
1166 // A classic theme draws its menu locations with wp_nav_menu(). One
1167 // with no menu falls back to wp_page_menu(), a list of every page;
1168 // one with a menu draws each item's current title and link.
1169 if ( ! ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() )
1170 && function_exists( 'get_registered_nav_menus' ) && function_exists( 'get_nav_menu_locations' )
1171 ) {
1172 $registered = get_registered_nav_menus();
1173 $assigned = get_nav_menu_locations();
1174 $assigned = is_array( $assigned ) ? $assigned : array();
1175 foreach ( is_array( $registered ) ? array_keys( $registered ) : array() as $location ) {
1176 $specs[] = empty( $assigned[ $location ] ) ? array( 'kind' => 'pages' ) : array( 'kind' => 'menu' );
1177 }
1178 }
1179
1180 foreach ( self::theme_and_widget_blocks() as $block ) {
1181 $spec = self::spec_for_block( $block );
1182 if ( null !== $spec ) {
1183 $specs[] = $spec;
1184 }
1185 }
1186
1187 // Too many pages with list blocks in their own content to name.
1188 if ( count( self::list_block_post_ids() ) > self::LIST_PAGE_LIMIT ) {
1189 $specs[] = array( 'kind' => 'any' );
1190 }
1191
1192 // Newest-N lists seen at render, from page builders, block plugins
1193 // and theme PHP (Listing_Pages). They show the same posts on every
1194 // page, so they are judged here like a widget.
1195 if ( Listing_Pages::enabled() ) {
1196 $specs = array_merge( $specs, Listing_Pages::recent_specs() );
1197 }
1198
1199 self::$post_list_specs = $specs;
1200 return $specs;
1201 }
1202
1203 /**
1204 * The spec for one block, or null when it draws no post list of its own.
1205 *
1206 * @param array $block A parsed block.
1207 * @return array{kind:string,type?:string,n?:int,sticky?:bool,ids?:int[]}|null
1208 */
1209 private static function spec_for_block( array $block ): ?array {
1210 $name = (string) ( $block['blockName'] ?? '' );
1211 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
1212
1213 if ( 'core/archives' === $name || 'core/calendar' === $name ) {
1214 return array( 'kind' => 'dated' );
1215 }
1216
1217 if ( 'core/page-list' === $name ) {
1218 return array( 'kind' => 'pages' );
1219 }
1220
1221 // With no menu of its own, a Navigation block falls back to the
1222 // newest navigation menu or, without one, to a list of every page.
1223 if ( 'core/navigation' === $name && empty( $attrs['ref'] ) && empty( $block['innerBlocks'] ) ) {
1224 return array( 'kind' => 'pages' );
1225 }
1226
1227 // A link to a post or page. It is drawn only while that post is
1228 // published; label and URL are stored in the block. Core treats a
1229 // link of type post or page as one even without `kind`.
1230 if ( 'core/navigation-link' === $name || 'core/navigation-submenu' === $name ) {
1231 $to_post = 'post-type' === ( $attrs['kind'] ?? '' ) || in_array( $attrs['type'] ?? '', array( 'post', 'page' ), true );
1232 return $to_post && isset( $attrs['id'] ) && is_numeric( $attrs['id'] )
1233 ? array( 'kind' => 'linked', 'ids' => array( (int) $attrs['id'] ) )
1234 : null;
1235 }
1236
1237 if ( 'core/categories' === $name || 'core/tag-cloud' === $name ) {
1238 return array( 'kind' => 'terms' );
1239 }
1240
1241 if ( 'core/latest-posts' === $name ) {
1242 $plain = empty( $attrs['categories'] ) && empty( $attrs['selectedAuthor'] )
1243 && 'date' === ( $attrs['orderBy'] ?? 'date' ) && 'desc' === strtolower( (string) ( $attrs['order'] ?? 'desc' ) );
1244 return $plain
1245 ? array( 'kind' => 'recent', 'type' => 'post', 'n' => max( 1, (int) ( $attrs['postsToShow'] ?? 5 ) ) )
1246 : array( 'kind' => 'any' );
1247 }
1248
1249 if ( 'core/query' === $name ) {
1250 $query = isset( $attrs['query'] ) && is_array( $attrs['query'] ) ? $attrs['query'] : array();
1251 // `inherit` is the page's own main loop, which the rules cover.
1252 if ( ! empty( $query['inherit'] ) ) {
1253 return null;
1254 }
1255 $sticky = (string) ( $query['sticky'] ?? '' );
1256 $plain = 'date' === ( $query['orderBy'] ?? 'date' )
1257 && 'desc' === strtolower( (string) ( $query['order'] ?? 'desc' ) )
1258 && empty( $query['author'] ) && empty( $query['search'] ) && empty( $query['taxQuery'] )
1259 && empty( $query['parents'] ) && empty( $query['exclude'] )
1260 && in_array( $sticky, array( '', 'exclude', 'ignore' ), true );
1261 return $plain
1262 ? array(
1263 'kind' => 'recent',
1264 'type' => (string) ( $query['postType'] ?? 'post' ),
1265 'n' => max( 1, (int) ( $query['perPage'] ?? get_option( 'posts_per_page', 10 ) ) + (int) ( $query['offset'] ?? 0 ) ),
1266 // '' (the default) puts sticky posts first, whatever their date.
1267 'sticky' => '' === $sticky,
1268 )
1269 : array( 'kind' => 'any' );
1270 }
1271
1272 return null;
1273 }
1274
1275 /**
1276 * Every block drawn by the active block widgets and, on a block theme,
1277 * by its templates and the template parts and patterns they use,
1278 * flattened into one list.
1279 *
1280 * @return array<int,array>
1281 */
1282 private static function theme_and_widget_blocks(): array {
1283 if ( null !== self::$theme_blocks ) {
1284 return self::$theme_blocks;
1285 }
1286 $out = array();
1287
1288 $block_widgets = get_option( 'widget_block', array() );
1289 foreach ( self::active_widget_ids() as $widget_id ) {
1290 if ( 0 === strpos( $widget_id, 'block-' ) && is_array( $block_widgets ) ) {
1291 $n = (int) substr( $widget_id, 6 );
1292 $content = isset( $block_widgets[ $n ]['content'] ) ? (string) $block_widgets[ $n ]['content'] : '';
1293 if ( '' !== $content ) {
1294 self::flatten( parse_blocks( $content ), $out, 0 );
1295 }
1296 }
1297 }
1298
1299 if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() && function_exists( 'get_block_templates' ) ) {
1300 foreach ( (array) get_block_templates( array(), 'wp_template' ) as $template ) {
1301 $content = is_object( $template ) && isset( $template->content ) ? (string) $template->content : '';
1302 if ( '' !== $content ) {
1303 self::flatten( parse_blocks( $content ), $out, 0 );
1304 }
1305 }
1306 }
1307
1308 self::$theme_blocks = $out;
1309 return $out;
1310 }
1311
1312 /**
1313 * Append every block in a tree to $out, following template parts,
1314 * patterns and synced patterns a few levels down.
1315 *
1316 * @param array $blocks parse_blocks() output.
1317 * @param array<int,array> $out Collected blocks.
1318 * @param int $depth Nesting depth through references.
1319 */
1320 private static function flatten( array $blocks, array &$out, int $depth ): void {
1321 if ( $depth > 4 ) {
1322 return;
1323 }
1324 foreach ( $blocks as $block ) {
1325 if ( ! is_array( $block ) ) {
1326 continue;
1327 }
1328 $block['blockName'] = (string) ( $block['blockName'] ?? '' );
1329 $out[] = $block;
1330 $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
1331 $content = '';
1332
1333 if ( 'core/template-part' === $block['blockName'] && ! empty( $attrs['slug'] ) && function_exists( 'get_block_template' ) ) {
1334 $theme = ! empty( $attrs['theme'] ) ? (string) $attrs['theme'] : ( function_exists( 'get_stylesheet' ) ? get_stylesheet() : '' );
1335 $part = get_block_template( $theme . '//' . $attrs['slug'], 'wp_template_part' );
1336 $content = is_object( $part ) && isset( $part->content ) ? (string) $part->content : '';
1337 } elseif ( 'core/pattern' === $block['blockName'] && ! empty( $attrs['slug'] ) && class_exists( '\\WP_Block_Patterns_Registry' ) ) {
1338 $pattern = \WP_Block_Patterns_Registry::get_instance()->get_registered( (string) $attrs['slug'] );
1339 $content = is_array( $pattern ) && ! empty( $pattern['content'] ) ? (string) $pattern['content'] : '';
1340 } elseif ( ( 'core/block' === $block['blockName'] || 'core/navigation' === $block['blockName'] ) && ! empty( $attrs['ref'] ) ) {
1341 // A synced pattern, or the wp_navigation menu a Navigation
1342 // block draws.
1343 $synced = get_post( (int) $attrs['ref'] );
1344 $content = $synced instanceof \WP_Post ? (string) ( $synced->post_content ?? '' ) : '';
1345 }
1346 if ( '' !== $content ) {
1347 self::flatten( parse_blocks( $content ), $out, $depth + 1 );
1348 }
1349 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
1350 self::flatten( $block['innerBlocks'], $out, $depth );
1351 }
1352 }
1353 }
1354
1355 /**
1356 * Widget IDs in every sidebar the current theme draws.
1357 *
1358 * A sidebar the theme does not register is never shown: a block theme
1359 * registers none, yet a fresh install still stores core's default
1360 * Recent Posts and Archives widgets under `sidebar-1`. Before sidebars
1361 * are registered (too early to know), every stored sidebar counts.
1362 *
1363 * @return string[]
1364 */
1365 private static function active_widget_ids(): array {
1366 global $wp_registered_sidebars;
1367 $ids = array();
1368 $sidebars = get_option( 'sidebars_widgets', array() );
1369 if ( ! is_array( $sidebars ) ) {
1370 return $ids;
1371 }
1372 $known = function_exists( 'did_action' ) && did_action( 'widgets_init' ) && is_array( $wp_registered_sidebars )
1373 ? $wp_registered_sidebars
1374 : null;
1375 foreach ( $sidebars as $sidebar => $widgets ) {
1376 if ( 'wp_inactive_widgets' === $sidebar || ! is_array( $widgets ) ) {
1377 continue;
1378 }
1379 if ( null !== $known && ! isset( $known[ $sidebar ] ) ) {
1380 continue;
1381 }
1382 foreach ( $widgets as $widget_id ) {
1383 $ids[] = (string) $widget_id;
1384 }
1385 }
1386 return $ids;
1387 }
1388
1389 /**
1390 * Whether any of these post dates falls within the N newest published
1391 * posts of a type: at least as new as the Nth newest, or the list is not
1392 * full yet.
1393 *
1394 * Read after the save, so a post that just left the list is judged by
1395 * its old date against the list as it is now, which then holds an older
1396 * Nth post: a post that was listed is always at least as new as that.
1397 *
1398 * @param string $type Post type.
1399 * @param int $n List length.
1400 * @param string[] $dates `post_date` values of the post now and before.
1401 */
1402 private static function in_latest_window( string $type, int $n, array $dates ): bool {
1403 $dates = array_filter( $dates, static fn( $d ) => '' !== $d );
1404 if ( array() === $dates ) {
1405 return false;
1406 }
1407 // The date of the Nth newest published post, read straight from the
1408 // table as the other counts here are: what the posts table holds,
1409 // with no other plugin's query filters applied. No row means the list
1410 // is not full yet, so any post is in it.
1411 global $wpdb;
1412 if ( ! is_object( $wpdb ) ) {
1413 return true;
1414 }
1415 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one indexed read per list per save; nothing to cache across saves.
1416 $threshold = $wpdb->get_var(
1417 $wpdb->prepare(
1418 "SELECT post_date FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' ORDER BY post_date DESC LIMIT %d, 1",
1419 $type,
1420 max( 0, $n - 1 )
1421 )
1422 );
1423 if ( ! is_string( $threshold ) || '' === $threshold ) {
1424 return true;
1425 }
1426 foreach ( $dates as $date ) {
1427 if ( $date >= $threshold ) {
1428 return true;
1429 }
1430 }
1431 return false;
1432 }
1433 }
1434