> */ private static $old_terms = array(); /** * The same old term_taxonomy_ids, grouped by taxonomy, so a save can be * compared with the terms it left in each taxonomy it set. * * @var array>> */ private static $old_terms_by_taxonomy = array(); /** * Per-request answer of post_list_specs(). * * @var array|null */ private static $post_list_specs = null; /** * Per-request parsed blocks of active block widgets and the theme's * templates, flattened. * * @var array|null */ private static $theme_blocks = null; /** * Neighbours' permalinks per post, read before a save moved it. * * @var array */ private static $old_neighbours = array(); /** * Posts stuck or unstuck in this request, keyed by ID. The block editor * changes `sticky_posts` before the save's purge runs, so the option * alone no longer says the post was sticky. * * @var array */ private static $sticky_changed = array(); /** Per-request answer of site_lists_comments(). @var bool|null */ private static $site_lists_comments = null; /** Transient holding list_block_post_ids() between saves. */ private const LIST_PAGES_TRANSIENT = 'xspeed_list_block_pages'; /** Block comments that mark a post list in post content. */ private const LIST_MARKERS = array( '`. * * @return string[] */ private static function split_pages( \WP_Post $post, string $link ): array { $parts = substr_count( (string) ( $post->post_content ?? '' ), '' ) + 1; $urls = array(); global $wp_rewrite; $pretty = is_object( $wp_rewrite ) && method_exists( $wp_rewrite, 'using_permalinks' ) && $wp_rewrite->using_permalinks(); for ( $i = 2; $i <= $parts; $i++ ) { $urls[] = $pretty ? trailingslashit( $link ) . user_trailingslashit( (string) $i, 'single_paged' ) : add_query_arg( 'page', $i, $link ); } return $urls; } /** * Comment pages, when comments are split into pages. * * @return string[] */ private static function comment_pages( \WP_Post $post, string $link ): array { if ( ! get_option( 'page_comments' ) ) { return array(); } $per_page = max( 1, (int) get_option( 'comments_per_page', 50 ) ); $pages = (int) ceil( (int) ( $post->comment_count ?? 0 ) / $per_page ) + 1; $urls = array(); global $wp_rewrite; $pretty = is_object( $wp_rewrite ) && method_exists( $wp_rewrite, 'using_permalinks' ) && $wp_rewrite->using_permalinks(); $base_n = is_object( $wp_rewrite ) && ! empty( $wp_rewrite->comments_pagination_base ) ? $wp_rewrite->comments_pagination_base : 'comment-page'; for ( $i = 1; $i <= $pages; $i++ ) { $urls[] = $pretty ? trailingslashit( $link ) . user_trailingslashit( $base_n . '-' . $i, 'commentpaged' ) : add_query_arg( 'cpage', $i, $link ); } return $urls; } /** * Every public term the post is in, and was in before this save, with * parent terms, all of their pages and their feeds. * * @return string[] */ private static function term_pages( \WP_Post $post, bool $plain = false ): array { $terms = array(); foreach ( get_object_taxonomies( $post->post_type, 'objects' ) as $taxonomy ) { if ( empty( $taxonomy->public ) || empty( $taxonomy->rewrite ) && ! $taxonomy->query_var ) { continue; } $current = get_the_terms( $post, $taxonomy->name ); if ( is_array( $current ) ) { foreach ( $current as $term ) { $terms[ (int) $term->term_taxonomy_id ] = $term; } } } foreach ( self::$old_terms[ (int) $post->ID ] ?? array() as $tt_id ) { if ( isset( $terms[ $tt_id ] ) ) { continue; } $term = get_term_by( 'term_taxonomy_id', $tt_id ); if ( $term instanceof \WP_Term ) { $taxonomy = get_taxonomy( $term->taxonomy ); if ( $taxonomy && ! empty( $taxonomy->public ) ) { $terms[ $tt_id ] = $term; } } } // Parent terms list their children's posts too. foreach ( $terms as $term ) { if ( ! is_taxonomy_hierarchical( $term->taxonomy ) ) { continue; } foreach ( get_ancestors( (int) $term->term_id, $term->taxonomy, 'taxonomy' ) as $ancestor_id ) { $ancestor = get_term( (int) $ancestor_id, $term->taxonomy ); if ( $ancestor instanceof \WP_Term ) { $terms[ (int) $ancestor->term_taxonomy_id ] = $ancestor; } } } $urls = array(); foreach ( $terms as $term ) { $link = get_term_link( $term ); if ( ! is_string( $link ) || '' === $link ) { continue; } $at = $plain ? static fn() => self::position_in_term( $post, $term ) : null; $urls = array_merge( $urls, self::paged( $link, self::term_post_count( $post, $term ), $at ) ); $feed = get_term_feed_link( (int) $term->term_id, $term->taxonomy ); if ( is_string( $feed ) && '' !== $feed ) { $urls[] = $feed; } } return $urls; } /** * The post's place in a term's archive, which also lists the posts of * the term's children in a hierarchical taxonomy. * * @return array{0:int,1:int}|null */ private static function position_in_term( \WP_Post $post, \WP_Term $term ): ?array { global $wpdb; if ( ! is_object( $wpdb ) ) { return null; } $in = implode( ',', array_map( 'intval', array_keys( self::term_tree( $term ) ) ) ); 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 ?? '' ) ); } /** * How many published posts a term's archive lists. In a hierarchical * taxonomy that includes the posts of every child term, which the * term's own `count` leaves out: a parent category whose posts all sit * in a child counts 0, so only its first page was named and its later * pages kept the old copy. * * Without a database to ask, the counts of the term and its children * are added up. A post in both counts twice, which names a page past * the end, never one too few. */ private static function term_post_count( \WP_Post $post, \WP_Term $term ): int { $tree = self::term_tree( $term ); if ( count( $tree ) < 2 ) { return (int) $term->count; } global $wpdb; if ( ! is_object( $wpdb ) ) { $sum = 0; foreach ( $tree as $member ) { $sum += (int) $member->count; } return $sum; } $types = self::term_post_types( $post, $term ); $in = implode( ',', array_map( 'intval', array_keys( $tree ) ) ); $list = implode( ',', array_fill( 0, count( $types ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $list is placeholders and $in is integers. $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 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- prepared above; one count per parent term per save. return (int) $wpdb->get_var( $sql ); } /** * The term and, in a hierarchical taxonomy, every term below it, keyed * by term_taxonomy_id: the terms whose posts its archive lists. * * @return array */ private static function term_tree( \WP_Term $term ): array { $tree = array( (int) $term->term_taxonomy_id => $term ); if ( is_taxonomy_hierarchical( $term->taxonomy ) && function_exists( 'get_term_children' ) ) { $children = get_term_children( (int) $term->term_id, $term->taxonomy ); foreach ( is_array( $children ) ? $children : array() as $child_id ) { $child = get_term( (int) $child_id, $term->taxonomy ); if ( $child instanceof \WP_Term ) { $tree[ (int) $child->term_taxonomy_id ] = $child; } } } return $tree; } /** * Post types a term's archive lists. * * @return string[] */ private static function term_post_types( \WP_Post $post, \WP_Term $term ): array { $taxonomy = get_taxonomy( $term->taxonomy ); return $taxonomy && ! empty( $taxonomy->object_type ) ? array_map( 'strval', (array) $taxonomy->object_type ) : array( (string) $post->post_type ); } /** * The year, month and day archives the post appears on, each with all of * its pages, or for a plain edit the page that holds the post. * * @return string[] */ private static function date_pages( \WP_Post $post, bool $plain = false ): array { $time = strtotime( (string) ( $post->post_date ?? '' ) ); if ( false === $time ) { return array(); } $y = (int) gmdate( 'Y', $time ); $m = (int) gmdate( 'm', $time ); $d = (int) gmdate( 'd', $time ); $date = (string) $post->post_date; $in = static function ( string $where ) use ( $plain, $date ): ?callable { return $plain ? static fn() => self::position( array( 'post' ), $where, $date ) : null; }; $urls = array(); $urls = array_merge( $urls, self::paged( get_year_link( $y ), self::count_in( $y ), $in( self::prepare( ' AND YEAR(p.post_date) = %d', $y ) ) ) ); $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 ) ) ) ); $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 ) ) ) ); return $urls; } /** * Published posts in a year, month or day. */ private static function count_in( int $y, int $m = 0, int $d = 0 ): int { global $wpdb; if ( ! is_object( $wpdb ) ) { return 0; } $where = $wpdb->prepare( 'YEAR(post_date) = %d', $y ); if ( $m > 0 ) { $where .= $wpdb->prepare( ' AND MONTH(post_date) = %d', $m ); } if ( $d > 0 ) { $where .= $wpdb->prepare( ' AND DAYOFMONTH(post_date) = %d', $d ); } // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- $where is built from prepare() above; a count per save. return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND {$where}" ); } private static function published_count( string $type ): int { $counts = wp_count_posts( $type ); return is_object( $counts ) && isset( $counts->publish ) ? (int) $counts->publish : 0; } /** * Permalinks of the four posts adjacent to this one: previous and next, * each in the whole timeline and within a shared term. * * @return string[] */ public static function adjacent_post_urls( \WP_Post $post ): array { $urls = array(); // get_adjacent_post() reads the global $post. Swap it for the one // being purged and put it back, or on an edit screen we would collect // the neighbours of whatever WordPress happened to have loaded. $previous_global = $GLOBALS['post'] ?? null; $GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below. try { foreach ( array( array( false, true ), array( true, true ), array( false, false ), array( true, false ) ) as $args ) { list( $same_term, $previous ) = $args; $adjacent = get_adjacent_post( $same_term, '', $previous ); if ( $adjacent instanceof \WP_Post && 'publish' === $adjacent->post_status ) { $link = get_permalink( $adjacent ); if ( is_string( $link ) && '' !== $link ) { $urls[] = $link; } } } } finally { if ( null === $previous_global ) { unset( $GLOBALS['post'] ); } else { $GLOBALS['post'] = $previous_global; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring. } } return $urls; } /** * Published pages and posts whose own content lists posts with a block. * * Those pages list the post wherever they are, so they go in the list by * name. More than LIST_PAGE_LIMIT of them and the caller is better off * purging the site: post_list_specs() treats that as a list of any kind. * * @return string[] */ private static function pages_with_list_blocks(): array { $ids = self::list_block_post_ids(); if ( count( $ids ) > self::LIST_PAGE_LIMIT ) { return array(); } $urls = array(); foreach ( $ids as $id ) { $link = get_permalink( (int) $id ); if ( is_string( $link ) && '' !== $link ) { $urls[] = $link; } } return $urls; } /** * IDs of published posts whose content has a list block, directly or * through a synced pattern that holds one. * * A LIKE scan over every post's content, so it is cached for an hour and * dropped when a post with a list block or a synced pattern is saved * (forget_list_pages_on_save()). Synced patterns are followed one level: * a pattern placed inside another pattern is not. * * @return array */ private static function list_block_post_ids(): array { $cached = get_transient( self::LIST_PAGES_TRANSIENT ); if ( is_array( $cached ) ) { return array_map( 'intval', $cached ); } global $wpdb; if ( ! is_object( $wpdb ) ) { return array(); } $like = array_map( static fn( $marker ) => '%' . $wpdb->esc_like( $marker ) . '%', self::LIST_MARKERS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one indexed-status query per save. $rows = $wpdb->get_col( $wpdb->prepare( "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", $like[0], $like[1], $like[2], self::LIST_PAGE_LIMIT + 1 ) ); $ids = array_map( 'intval', is_array( $rows ) ? $rows : array() ); if ( count( $ids ) <= self::LIST_PAGE_LIMIT ) { $ids = array_values( array_unique( array_merge( $ids, self::posts_placing_list_patterns( $like ) ) ) ); } set_transient( self::LIST_PAGES_TRANSIENT, $ids, HOUR_IN_SECONDS ); return $ids; } /** * IDs of published posts that place a synced pattern holding a list. * * More such patterns than LIST_PAGE_LIMIT come back as the pattern IDs * themselves: the caller only needs to see the list is too long to name. * * @param string[] $like The three LIKE patterns for LIST_MARKERS. * @return array */ private static function posts_placing_list_patterns( array $like ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- cached with the scan above. $patterns = $wpdb->get_col( $wpdb->prepare( "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", $like[0], $like[1], $like[2], self::LIST_PAGE_LIMIT + 1 ) ); $patterns = array_map( 'intval', is_array( $patterns ) ? $patterns : array() ); if ( count( $patterns ) > self::LIST_PAGE_LIMIT ) { return $patterns; } $ids = array(); foreach ( $patterns as $pattern_id ) { // `{"ref":12}` or `{"ref":12,...}`, so pattern 12 does not match 123. // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- cached with the scan above. $rows = $wpdb->get_col( $wpdb->prepare( "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", '%' . $wpdb->esc_like( self::SYNCED_MARKER . $pattern_id . '}' ) . '%', '%' . $wpdb->esc_like( self::SYNCED_MARKER . $pattern_id . ',' ) . '%', self::LIST_PAGE_LIMIT + 1 ) ); $ids = array_merge( $ids, array_map( 'intval', is_array( $rows ) ? $rows : array() ) ); if ( count( $ids ) > self::LIST_PAGE_LIMIT ) { break; } } return $ids; } /** * Post lists drawn outside the main loop, as specs lists_changed_by() * can test a change against. * * @return array */ private static function post_list_specs(): array { if ( null !== self::$post_list_specs ) { return self::$post_list_specs; } $specs = array(); foreach ( self::active_widget_ids() as $widget_id ) { if ( 0 === strpos( $widget_id, 'recent-posts-' ) ) { $settings = get_option( 'widget_recent-posts', array() ); $n = (int) substr( $widget_id, strlen( 'recent-posts-' ) ); $number = is_array( $settings ) && isset( $settings[ $n ]['number'] ) ? (int) $settings[ $n ]['number'] : 5; $specs[] = array( 'kind' => 'recent', 'type' => 'post', 'n' => max( 1, $number ) ); } elseif ( 0 === strpos( $widget_id, 'archives-' ) || 0 === strpos( $widget_id, 'calendar-' ) ) { $specs[] = array( 'kind' => 'dated' ); } elseif ( 0 === strpos( $widget_id, 'pages-' ) ) { $specs[] = array( 'kind' => 'pages' ); } elseif ( 0 === strpos( $widget_id, 'categories-' ) || 0 === strpos( $widget_id, 'tag_cloud-' ) ) { $specs[] = array( 'kind' => 'terms' ); } } // A classic theme draws its menu locations with wp_nav_menu(). One // with no menu falls back to wp_page_menu(), a list of every page; // one with a menu draws each item's current title and link. if ( ! ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) && function_exists( 'get_registered_nav_menus' ) && function_exists( 'get_nav_menu_locations' ) ) { $registered = get_registered_nav_menus(); $assigned = get_nav_menu_locations(); $assigned = is_array( $assigned ) ? $assigned : array(); foreach ( is_array( $registered ) ? array_keys( $registered ) : array() as $location ) { $specs[] = empty( $assigned[ $location ] ) ? array( 'kind' => 'pages' ) : array( 'kind' => 'menu' ); } } foreach ( self::theme_and_widget_blocks() as $block ) { $spec = self::spec_for_block( $block ); if ( null !== $spec ) { $specs[] = $spec; } } // Too many pages with list blocks in their own content to name. if ( count( self::list_block_post_ids() ) > self::LIST_PAGE_LIMIT ) { $specs[] = array( 'kind' => 'any' ); } // Newest-N lists seen at render, from page builders, block plugins // and theme PHP (Listing_Pages). They show the same posts on every // page, so they are judged here like a widget. if ( Listing_Pages::enabled() ) { $specs = array_merge( $specs, Listing_Pages::recent_specs() ); } self::$post_list_specs = $specs; return $specs; } /** * The spec for one block, or null when it draws no post list of its own. * * @param array $block A parsed block. * @return array{kind:string,type?:string,n?:int,sticky?:bool,ids?:int[]}|null */ private static function spec_for_block( array $block ): ?array { $name = (string) ( $block['blockName'] ?? '' ); $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); if ( 'core/archives' === $name || 'core/calendar' === $name ) { return array( 'kind' => 'dated' ); } if ( 'core/page-list' === $name ) { return array( 'kind' => 'pages' ); } // With no menu of its own, a Navigation block falls back to the // newest navigation menu or, without one, to a list of every page. if ( 'core/navigation' === $name && empty( $attrs['ref'] ) && empty( $block['innerBlocks'] ) ) { return array( 'kind' => 'pages' ); } // A link to a post or page. It is drawn only while that post is // published; label and URL are stored in the block. Core treats a // link of type post or page as one even without `kind`. if ( 'core/navigation-link' === $name || 'core/navigation-submenu' === $name ) { $to_post = 'post-type' === ( $attrs['kind'] ?? '' ) || in_array( $attrs['type'] ?? '', array( 'post', 'page' ), true ); return $to_post && isset( $attrs['id'] ) && is_numeric( $attrs['id'] ) ? array( 'kind' => 'linked', 'ids' => array( (int) $attrs['id'] ) ) : null; } if ( 'core/categories' === $name || 'core/tag-cloud' === $name ) { return array( 'kind' => 'terms' ); } if ( 'core/latest-posts' === $name ) { $plain = empty( $attrs['categories'] ) && empty( $attrs['selectedAuthor'] ) && 'date' === ( $attrs['orderBy'] ?? 'date' ) && 'desc' === strtolower( (string) ( $attrs['order'] ?? 'desc' ) ); return $plain ? array( 'kind' => 'recent', 'type' => 'post', 'n' => max( 1, (int) ( $attrs['postsToShow'] ?? 5 ) ) ) : array( 'kind' => 'any' ); } if ( 'core/query' === $name ) { $query = isset( $attrs['query'] ) && is_array( $attrs['query'] ) ? $attrs['query'] : array(); // `inherit` is the page's own main loop, which the rules cover. if ( ! empty( $query['inherit'] ) ) { return null; } $sticky = (string) ( $query['sticky'] ?? '' ); $plain = 'date' === ( $query['orderBy'] ?? 'date' ) && 'desc' === strtolower( (string) ( $query['order'] ?? 'desc' ) ) && empty( $query['author'] ) && empty( $query['search'] ) && empty( $query['taxQuery'] ) && empty( $query['parents'] ) && empty( $query['exclude'] ) && in_array( $sticky, array( '', 'exclude', 'ignore' ), true ); return $plain ? array( 'kind' => 'recent', 'type' => (string) ( $query['postType'] ?? 'post' ), 'n' => max( 1, (int) ( $query['perPage'] ?? get_option( 'posts_per_page', 10 ) ) + (int) ( $query['offset'] ?? 0 ) ), // '' (the default) puts sticky posts first, whatever their date. 'sticky' => '' === $sticky, ) : array( 'kind' => 'any' ); } return null; } /** * Every block drawn by the active block widgets and, on a block theme, * by its templates and the template parts and patterns they use, * flattened into one list. * * @return array */ private static function theme_and_widget_blocks(): array { if ( null !== self::$theme_blocks ) { return self::$theme_blocks; } $out = array(); $block_widgets = get_option( 'widget_block', array() ); foreach ( self::active_widget_ids() as $widget_id ) { if ( 0 === strpos( $widget_id, 'block-' ) && is_array( $block_widgets ) ) { $n = (int) substr( $widget_id, 6 ); $content = isset( $block_widgets[ $n ]['content'] ) ? (string) $block_widgets[ $n ]['content'] : ''; if ( '' !== $content ) { self::flatten( parse_blocks( $content ), $out, 0 ); } } } if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() && function_exists( 'get_block_templates' ) ) { foreach ( (array) get_block_templates( array(), 'wp_template' ) as $template ) { $content = is_object( $template ) && isset( $template->content ) ? (string) $template->content : ''; if ( '' !== $content ) { self::flatten( parse_blocks( $content ), $out, 0 ); } } } self::$theme_blocks = $out; return $out; } /** * Append every block in a tree to $out, following template parts, * patterns and synced patterns a few levels down. * * @param array $blocks parse_blocks() output. * @param array $out Collected blocks. * @param int $depth Nesting depth through references. */ private static function flatten( array $blocks, array &$out, int $depth ): void { if ( $depth > 4 ) { return; } foreach ( $blocks as $block ) { if ( ! is_array( $block ) ) { continue; } $block['blockName'] = (string) ( $block['blockName'] ?? '' ); $out[] = $block; $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); $content = ''; if ( 'core/template-part' === $block['blockName'] && ! empty( $attrs['slug'] ) && function_exists( 'get_block_template' ) ) { $theme = ! empty( $attrs['theme'] ) ? (string) $attrs['theme'] : ( function_exists( 'get_stylesheet' ) ? get_stylesheet() : '' ); $part = get_block_template( $theme . '//' . $attrs['slug'], 'wp_template_part' ); $content = is_object( $part ) && isset( $part->content ) ? (string) $part->content : ''; } elseif ( 'core/pattern' === $block['blockName'] && ! empty( $attrs['slug'] ) && class_exists( '\\WP_Block_Patterns_Registry' ) ) { $pattern = \WP_Block_Patterns_Registry::get_instance()->get_registered( (string) $attrs['slug'] ); $content = is_array( $pattern ) && ! empty( $pattern['content'] ) ? (string) $pattern['content'] : ''; } elseif ( ( 'core/block' === $block['blockName'] || 'core/navigation' === $block['blockName'] ) && ! empty( $attrs['ref'] ) ) { // A synced pattern, or the wp_navigation menu a Navigation // block draws. $synced = get_post( (int) $attrs['ref'] ); $content = $synced instanceof \WP_Post ? (string) ( $synced->post_content ?? '' ) : ''; } if ( '' !== $content ) { self::flatten( parse_blocks( $content ), $out, $depth + 1 ); } if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { self::flatten( $block['innerBlocks'], $out, $depth ); } } } /** * Widget IDs in every sidebar the current theme draws. * * A sidebar the theme does not register is never shown: a block theme * registers none, yet a fresh install still stores core's default * Recent Posts and Archives widgets under `sidebar-1`. Before sidebars * are registered (too early to know), every stored sidebar counts. * * @return string[] */ private static function active_widget_ids(): array { global $wp_registered_sidebars; $ids = array(); $sidebars = get_option( 'sidebars_widgets', array() ); if ( ! is_array( $sidebars ) ) { return $ids; } $known = function_exists( 'did_action' ) && did_action( 'widgets_init' ) && is_array( $wp_registered_sidebars ) ? $wp_registered_sidebars : null; foreach ( $sidebars as $sidebar => $widgets ) { if ( 'wp_inactive_widgets' === $sidebar || ! is_array( $widgets ) ) { continue; } if ( null !== $known && ! isset( $known[ $sidebar ] ) ) { continue; } foreach ( $widgets as $widget_id ) { $ids[] = (string) $widget_id; } } return $ids; } /** * Whether any of these post dates falls within the N newest published * posts of a type: at least as new as the Nth newest, or the list is not * full yet. * * Read after the save, so a post that just left the list is judged by * its old date against the list as it is now, which then holds an older * Nth post: a post that was listed is always at least as new as that. * * @param string $type Post type. * @param int $n List length. * @param string[] $dates `post_date` values of the post now and before. */ private static function in_latest_window( string $type, int $n, array $dates ): bool { $dates = array_filter( $dates, static fn( $d ) => '' !== $d ); if ( array() === $dates ) { return false; } // The date of the Nth newest published post, read straight from the // table as the other counts here are: what the posts table holds, // with no other plugin's query filters applied. No row means the list // is not full yet, so any post is in it. global $wpdb; if ( ! is_object( $wpdb ) ) { return true; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one indexed read per list per save; nothing to cache across saves. $threshold = $wpdb->get_var( $wpdb->prepare( "SELECT post_date FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'publish' ORDER BY post_date DESC LIMIT %d, 1", $type, max( 0, $n - 1 ) ) ); if ( ! is_string( $threshold ) || '' === $threshold ) { return true; } foreach ( $dates as $date ) { if ( $date >= $threshold ) { return true; } } return false; } }