scanner = $scanner; $this->settings = $settings; } /** * Collects occurrences from every source. * * @return Occurrence[] */ public function collect(): array { $this->scanned = 0; $this->truncated = false; return array_merge( $this->from_posts(), $this->from_templates( 'wp_template' ), $this->from_templates( 'wp_template_part' ), $this->from_widgets() ); } /** * Number of content items inspected during the last collect(). * * @return int */ public function scanned(): int { return $this->scanned; } /** * Whether the last collect() stopped at the post limit. * * @return bool */ public function truncated(): bool { return $this->truncated; } /** * Scans posts, pages, custom post types and synced patterns. * * @return Occurrence[] */ private function from_posts(): array { global $wpdb; $post_types = $this->scannable_post_types(); if ( array() === $post_types ) { return array(); } /** * Filters the maximum number of posts inspected in a single scan. * * @param int $limit Maximum rows. */ $limit = (int) apply_filters( 'blocksieve_scan_limit', $this->settings->scan_limit() ); $limit = max( 1, $limit ); $type_placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) ); $status_placeholders = implode( ', ', array_fill( 0, count( self::STATUSES ), '%s' ) ); // Every value below is bound through prepare(); only the generated // placeholder runs is interpolated. One extra row is fetched so we can // tell "exactly at the limit" from "there is more". $values = array_merge( array( '%' . $wpdb->esc_like( BlockScanner::MARKER ) . '%' ), $post_types, self::STATUSES, array( $limit + 1 ) ); /* * A LIKE scan is unavoidable: the model lives inside post_content and no * index can serve it. Results are cached by ReportRepository, and the row * cap above keeps the worst case bounded. * * PreparedSQLPlaceholders is disabled because the sniff cannot count * placeholders that are generated at runtime, nor arguments passed by * spread — the counts are guaranteed equal by construction above. */ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_content LIKE %s AND post_type IN ( {$type_placeholders} ) AND post_status IN ( {$status_placeholders} ) ORDER BY post_modified DESC LIMIT %d", ...$values ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( count( $rows ) > $limit ) { $this->truncated = true; $rows = array_slice( $rows, 0, $limit ); } $found = array(); // Load the matching posts in one query instead of one get_post() per row. if ( function_exists( '_prime_post_caches' ) ) { _prime_post_caches( array_map( static fn( $row ): int => (int) $row->ID, $rows ), false, false ); } foreach ( $rows as $row ) { ++$this->scanned; $post = get_post( (int) $row->ID ); if ( ! $post instanceof \WP_Post ) { continue; } switch ( $post->post_type ) { case 'wp_block': $kind = Occurrence::SOURCE_PATTERN; break; case 'wp_navigation': $kind = Occurrence::SOURCE_NAVIGATION; break; default: $kind = Occurrence::SOURCE_POST; } // Patterns and menus render on many pages, so they carry no post identity. $is_shared = Occurrence::SOURCE_POST !== $kind; foreach ( $this->scanner->scan( $post->post_content ) as $occurrence ) { $found[] = $occurrence->with_source( $kind, (string) $post->ID, $this->post_title( $post ), $this->post_type_label( $post->post_type ), (string) get_edit_post_link( $post, 'raw' ), $is_shared ? 0 : (int) $post->ID, $is_shared ? '' : $post->post_type ); } } return $found; } /** * Scans site-editor templates or template parts. * * `get_block_templates()` merges theme-file templates with any customised in * the database, so templates the site owner never edited are covered too. * * @param string $template_type Either 'wp_template' or 'wp_template_part'. * @return Occurrence[] */ private function from_templates( string $template_type ): array { // Classic themes can opt in to editor-made templates and template parts; // either feature lets template parts render (via editor-made templates). $supported = wp_is_block_theme() || current_theme_supports( 'block-templates' ) || ( 'wp_template_part' === $template_type && current_theme_supports( 'block-template-parts' ) ); if ( ! function_exists( 'get_block_templates' ) || ! $supported ) { return array(); } $templates = get_block_templates( array(), $template_type ); $is_part = 'wp_template_part' === $template_type; $kind = $is_part ? Occurrence::SOURCE_TEMPLATE_PART : Occurrence::SOURCE_TEMPLATE; $label = $is_part ? __( 'Template part', 'blocksieve' ) : __( 'Template', 'blocksieve' ); $found = array(); foreach ( $templates as $template ) { ++$this->scanned; $title = '' !== $template->title ? $template->title : $template->slug; foreach ( $this->scanner->scan( $template->content ) as $occurrence ) { $found[] = $occurrence->with_source( $kind, '' !== $template->id ? $template->id : $title, $title, $label, $this->template_edit_url( $template_type, $template->id ) ); } } return $found; } /** * Scans block widgets stored in the `widget_block` option. * * @return Occurrence[] */ private function from_widgets(): array { $widgets = get_option( 'widget_block' ); if ( ! is_array( $widgets ) ) { return array(); } $sidebars = $this->widget_sidebar_map(); $found = array(); foreach ( $widgets as $number => $instance ) { if ( ! is_array( $instance ) || ! isset( $instance['content'] ) || ! is_string( $instance['content'] ) ) { continue; // Skips the `_multiwidget` bookkeeping key. } ++$this->scanned; $widget_id = 'block-' . $number; foreach ( $this->scanner->scan( $instance['content'] ) as $occurrence ) { $found[] = $occurrence->with_source( Occurrence::SOURCE_WIDGET, $widget_id, $sidebars[ $widget_id ] ?? __( 'Inactive widgets', 'blocksieve' ), __( 'Widget', 'blocksieve' ), admin_url( 'widgets.php' ) ); } } return $found; } /** * Maps each block widget id to the name of the sidebar holding it. * * @return array */ private function widget_sidebar_map(): array { global $wp_registered_sidebars; $map = array(); // Read the option directly: wp_get_sidebars_widgets() is marked private. $sidebars = (array) get_option( 'sidebars_widgets', array() ); foreach ( $sidebars as $sidebar_id => $widget_ids ) { // Inactive, orphaned and unregistered sidebars never render: leave // their widgets unmapped so they are labelled "Inactive widgets". if ( ! is_array( $widget_ids ) || ! isset( $wp_registered_sidebars[ $sidebar_id ] ) ) { continue; } $name = (string) ( $wp_registered_sidebars[ $sidebar_id ]['name'] ?? $sidebar_id ); foreach ( $widget_ids as $widget_id ) { $map[ (string) $widget_id ] = $name; } } return $map; } /** * Post types the post scan should never touch. * * `wp_template` and `wp_template_part` are deliberately excluded: they are * block-editor post types, so they would otherwise be picked up here *and* * by from_templates(), reporting every template block twice. from_templates() * owns them because it also sees theme-file templates that were never saved * to the database. */ private const EXCLUDED_POST_TYPES = array( 'revision', 'nav_menu_item', 'wp_global_styles', 'wp_template', 'wp_template_part', ); /** * Post types that can hold blocks and are worth reporting on. * * Uses the post types chosen in the settings, minus the excluded and * unregistered ones. Detects them instead when none are chosen, or when none * of the chosen ones is still usable (e.g. its plugin was deactivated), so a * stale selection never silently empties the report. * * @return string[] */ private function scannable_post_types(): array { $types = array_values( array_filter( $this->settings->scan_post_types(), fn( string $type ): bool => ! in_array( $type, self::EXCLUDED_POST_TYPES, true ) && post_type_exists( $type ) ) ); if ( array() === $types ) { $types = $this->detected_post_types(); } /** * Filters the post types inspected by the visibility scan. * * @param string[] $types Post type slugs. */ $types = (array) apply_filters( 'blocksieve_scan_post_types', $types ); // Re-index: string keys (e.g. a get_post_types() result) cannot be spread into prepare(). return array_values( array_filter( $types, 'is_string' ) ); } /** * Every block-editor post type, plus synced patterns and navigation menus. * * @return string[] */ private function detected_post_types(): array { $types = array(); foreach ( get_post_types( array(), 'names' ) as $type ) { if ( in_array( $type, self::EXCLUDED_POST_TYPES, true ) ) { continue; } if ( use_block_editor_for_post_type( $type ) ) { $types[] = $type; } } // Synced patterns and navigation menus are not block-editor-enabled in // the usual sense, but their blocks render (and are filtered) on the front. foreach ( array( 'wp_block', 'wp_navigation' ) as $shared ) { if ( ! in_array( $shared, $types, true ) && post_type_exists( $shared ) ) { $types[] = $shared; } } return $types; } /** * Resolves a display title for a post, falling back to its id. * * @param \WP_Post $post Post object. * @return string */ private function post_title( \WP_Post $post ): string { $title = get_the_title( $post ); if ( '' !== trim( $title ) ) { return $title; } /* translators: %d: post id. */ return sprintf( __( '(no title) #%d', 'blocksieve' ), $post->ID ); } /** * Resolves the singular label for a post type. * * @param string $post_type Post type slug. * @return string */ private function post_type_label( string $post_type ): string { $object = get_post_type_object( $post_type ); if ( $object && isset( $object->labels->singular_name ) ) { return (string) $object->labels->singular_name; } return $post_type; } /** * Builds the site-editor deep link for a template. * * @param string $template_type Template post type. * @param string $template_id Template identifier (theme//slug). * @return string */ private function template_edit_url( string $template_type, string $template_id ): string { if ( '' === $template_id ) { return admin_url( 'site-editor.php' ); } return add_query_arg( array( 'postType' => $template_type, 'postId' => rawurlencode( $template_id ), 'canvas' => 'edit', ), admin_url( 'site-editor.php' ) ); } }