keywords = $keywords ?? new ABDS_SEO_Keyword_Extractor(); } /** * Analyzes a parsed block tree and returns per-block recommendations. * * @param array $blocks parse_blocks() output. * @param string $focus_keyword Focus keyword (or phrase); keyword-dependent checks are skipped when empty. * @return array */ public function analyze( array $blocks, string $focus_keyword ): array { $flat = $this->flatten( $blocks ); return array_merge( $this->check_headings( $flat, $focus_keyword ), $this->check_first_paragraph( $flat, $focus_keyword ), $this->check_paragraph_length( $flat ), $this->check_images( $flat ), $this->check_lists( $flat ) ); } /** * Depth-first-flattens a block tree, pairing each real block (skipping * the null-blockName "freeform HTML between blocks" pseudo-entries * parse_blocks() inserts) with its block_path — the sequence of * per-level indices needed to re-locate it in the editor's own * wp.data.select('core/block-editor').getBlocks() tree. * * Path indices count only real blocks, not raw array position. * getBlocks() never contains the freeform-whitespace pseudo-entries in * the first place (that whitespace only exists as a side effect of * parse_blocks() parsing serialized HTML) — there's nothing on the * editor side to "skip" to line up with a raw-position index, so both * sides have to agree on "position among real blocks" instead. * * @param array $blocks Blocks at the current level. * @param int[] $prefix Path prefix accumulated from parent levels. * @return array */ protected function flatten( array $blocks, array $prefix = array() ): array { $flat = array(); $index = 0; foreach ( $blocks as $block ) { if ( empty( $block['blockName'] ) ) { continue; } $path = array_merge( $prefix, array( $index ) ); $flat[] = array( 'path' => $path, 'block' => $block, ); if ( ! empty( $block['innerBlocks'] ) ) { $flat = array_merge( $flat, $this->flatten( $block['innerBlocks'], $path ) ); } ++$index; } return $flat; } /** * Flags the first H2-H6 heading block if none of the post's headings * (at that level range) contain the focus keyword. H1 is excluded — * it's already covered page-wide by ABDS_SEO_Content_Analyzer. * * @param array $flat Flattened block list. * @param string $focus_keyword Focus keyword (or phrase). * @return array */ protected function check_headings( array $flat, string $focus_keyword ): array { if ( '' === trim( $focus_keyword ) ) { return array(); } $subheadings = array_filter( $flat, fn( $entry ) => 'core/heading' === ( $entry['block']['blockName'] ?? null ) && (int) ( $entry['block']['attrs']['level'] ?? 2 ) >= 2 ); if ( empty( $subheadings ) ) { return array(); } foreach ( $subheadings as $entry ) { if ( $this->keywords->contains( $this->block_text( $entry['block'] ), $focus_keyword ) ) { return array(); // At least one subheading already has the keyword. } } $first = reset( $subheadings ); return array( $this->recommendation( $first, 'heading_missing_keyword', self::SEVERITY_WARNING ), ); } /** * Flags the post's first paragraph block if it doesn't contain the * focus keyword. * * @param array $flat Flattened block list. * @param string $focus_keyword Focus keyword (or phrase). * @return array */ protected function check_first_paragraph( array $flat, string $focus_keyword ): array { if ( '' === trim( $focus_keyword ) ) { return array(); } foreach ( $flat as $entry ) { if ( 'core/paragraph' !== ( $entry['block']['blockName'] ?? null ) ) { continue; } if ( $this->keywords->contains( $this->block_text( $entry['block'] ), $focus_keyword ) ) { return array(); } return array( $this->recommendation( $entry, 'first_paragraph_missing_keyword', self::SEVERITY_WARNING ), ); } return array(); } /** * Flags every paragraph block longer than * ABDS_SEO_Readability_Checker::LONG_PARAGRAPH_WORD_THRESHOLD words — * the same threshold the page-level readability score already uses, * so both checkers agree on what "too long" means. * * @param array $flat Flattened block list. * @return array */ protected function check_paragraph_length( array $flat ): array { $recommendations = array(); foreach ( $flat as $entry ) { if ( 'core/paragraph' !== ( $entry['block']['blockName'] ?? null ) ) { continue; } $word_count = $this->keywords->word_count( $this->block_text( $entry['block'] ) ); if ( $word_count > ABDS_SEO_Readability_Checker::LONG_PARAGRAPH_WORD_THRESHOLD ) { $recommendations[] = $this->recommendation( $entry, 'paragraph_too_long', self::SEVERITY_WARNING ); } } return $recommendations; } /** * Flags every image block missing an alt attribute. core/image's alt * is sourced from the rendered markup, not the block comment's * JSON attrs, so innerHTML is checked directly — the same regex * ABDS_SEO_Site_Scanner::check_images() already uses page-wide. * * @param array $flat Flattened block list. * @return array */ protected function check_images( array $flat ): array { $recommendations = array(); foreach ( $flat as $entry ) { if ( 'core/image' !== ( $entry['block']['blockName'] ?? null ) ) { continue; } $has_alt = (bool) preg_match( '/\balt\s*=\s*["\'][^"\']+["\']/i', $entry['block']['innerHTML'] ?? '' ); if ( ! $has_alt ) { $recommendations[] = $this->recommendation( $entry, 'image_missing_alt', self::SEVERITY_WARNING ); } } return $recommendations; } /** * Flags list blocks with no actual text content. Not called out with * a specific rule in the brief beyond "list" being a relevant block * type — this is a minimal, low-risk interpretation (an empty list is * unambiguously a content-quality defect); revisit if a different * list-specific check is wanted. * * @param array $flat Flattened block list. * @return array */ protected function check_lists( array $flat ): array { $recommendations = array(); foreach ( $flat as $entry ) { if ( 'core/list' !== ( $entry['block']['blockName'] ?? null ) ) { continue; } if ( '' === $this->block_text( $entry['block'] ) ) { $recommendations[] = $this->recommendation( $entry, 'list_empty', self::SEVERITY_INFO ); } } return $recommendations; } /** * Builds a single recommendation entry for a flattened block entry. * Deliberately has no "suggestion" (translated message) field — this * class makes zero WordPress calls (no __()) so it stays unit * testable without bootstrapping WP; ABDS_SEO_Content_Analyzer maps * each issue code to a translated suggestion string afterwards. * * @param array{path:int[], block:array} $entry Flattened block entry. * @param string $issue Machine-readable issue code. * @param string $severity One of the SEVERITY_* constants. * @return array{block_path:int[], block_type:string, content_snippet:string, issue:string, severity:string} */ protected function recommendation( array $entry, string $issue, string $severity ): array { return array( 'block_path' => $entry['path'], 'block_type' => (string) ( $entry['block']['blockName'] ?? '' ), 'content_snippet' => $this->snippet( $this->block_text( $entry['block'] ) ), 'issue' => $issue, 'severity' => $severity, ); } /** * Extracts a block's plain-text content from its rendered innerHTML. * * @param array{innerHTML?:string} $block Parsed block. * @return string Plain, whitespace-normalized text. */ protected function block_text( array $block ): string { return $this->strip_html( (string) ( $block['innerHTML'] ?? '' ) ); } /** * Truncates text to the secondary-match snippet length used for * fallback block matching on the editor side (see block_path docblock). * * @param string $text Plain text. * @return string Text truncated to SNIPPET_LENGTH characters. */ protected function snippet( string $text ): string { return mb_substr( $text, 0, self::SNIPPET_LENGTH, 'UTF-8' ); } /** * Strips HTML down to plain, whitespace-normalized text. Duplicated * from ABDS_SEO_Keyword_Extractor/ABDS_SEO_Readability_Checker * (same rationale: tags replaced with a space, not deleted outright, * so adjacent block-level text doesn't glue together) rather than * shared, to keep this class's only dependency a public, already * WP-independent collaborator (ABDS_SEO_Keyword_Extractor), matching * the existing pattern where each pure-logic class carries its own copy. * * @param string $text Text that may contain HTML. * @return string Plain text, tags replaced with spaces, whitespace collapsed and trimmed. */ protected function strip_html( string $text ): string { $text = (string) preg_replace( '@<(script|style)[^>]*?>.*?@si', ' ', $text ); $text = (string) preg_replace( '/<[^>]*>/', ' ', $text ); return trim( (string) preg_replace( '/\s+/u', ' ', $text ) ); } }