keywords = $keywords ?? new ABDS_SEO_Keyword_Extractor(); } /** * Evaluates a ContentUnit sequence and returns per-unit findings. * * @param array $units Content units, in document order. * @param string $focus_keyword Focus keyword (or phrase); keyword-dependent checks are skipped when empty. * @return array */ public function evaluate( array $units, string $focus_keyword ): array { return array_merge( $this->check_headings( $units, $focus_keyword ), $this->check_first_paragraph( $units, $focus_keyword ), $this->check_paragraph_length( $units ), $this->check_images( $units ), $this->check_lists( $units ) ); } /** * Flags the first subheading (level >= 2) unit if none of the post's * subheadings contain the focus keyword. H1 is excluded — it's already * covered page-wide by ABDS_SEO_Content_Analyzer. * * @param array $units Content units. * @param string $focus_keyword Focus keyword (or phrase). * @return array */ protected function check_headings( array $units, string $focus_keyword ): array { if ( '' === trim( $focus_keyword ) ) { return array(); } $subheadings = array_filter( $units, fn( $unit ) => 'heading' === $unit['kind'] && (int) ( $unit['level'] ?? 2 ) >= 2 ); if ( empty( $subheadings ) ) { return array(); } foreach ( $subheadings as $unit ) { if ( $this->keywords->contains( $unit['text'], $focus_keyword ) ) { return array(); // At least one subheading already has the keyword. } } $first = reset( $subheadings ); return array( $this->finding( $first, 'heading_missing_keyword', self::SEVERITY_WARNING ) ); } /** * Flags the post's first paragraph unit if it doesn't contain the * focus keyword. * * @param array $units Content units. * @param string $focus_keyword Focus keyword (or phrase). * @return array */ protected function check_first_paragraph( array $units, string $focus_keyword ): array { if ( '' === trim( $focus_keyword ) ) { return array(); } foreach ( $units as $unit ) { if ( 'paragraph' !== $unit['kind'] ) { continue; } if ( $this->keywords->contains( $unit['text'], $focus_keyword ) ) { return array(); } return array( $this->finding( $unit, 'first_paragraph_missing_keyword', self::SEVERITY_WARNING ) ); } return array(); } /** * Flags every paragraph unit longer than * ABDS_SEO_Readability_Checker::LONG_PARAGRAPH_WORD_THRESHOLD words — * the same threshold the page-level readability score already uses. * * @param array $units Content units. * @return array */ protected function check_paragraph_length( array $units ): array { $findings = array(); foreach ( $units as $unit ) { if ( 'paragraph' !== $unit['kind'] ) { continue; } $word_count = $this->keywords->word_count( $unit['text'] ); if ( $word_count > ABDS_SEO_Readability_Checker::LONG_PARAGRAPH_WORD_THRESHOLD ) { $findings[] = $this->finding( $unit, 'paragraph_too_long', self::SEVERITY_WARNING ); } } return $findings; } /** * Flags every image unit missing an alt attribute. Alt is sourced from * the rendered markup (unit html), not a JSON attribute — the * same regex ABDS_SEO_Site_Scanner::check_images() uses page-wide. * * @param array $units Content units. * @return array */ protected function check_images( array $units ): array { $findings = array(); foreach ( $units as $unit ) { if ( 'image' !== $unit['kind'] ) { continue; } $has_alt = (bool) preg_match( '/\balt\s*=\s*["\'][^"\']+["\']/i', $unit['html'] ); if ( ! $has_alt ) { $findings[] = $this->finding( $unit, 'image_missing_alt', self::SEVERITY_WARNING ); } } return $findings; } /** * Flags list units with no actual text content. * * @param array $units Content units. * @return array */ protected function check_lists( array $units ): array { $findings = array(); foreach ( $units as $unit ) { if ( 'list' !== $unit['kind'] ) { continue; } if ( '' === $unit['text'] ) { $findings[] = $this->finding( $unit, 'list_empty', self::SEVERITY_INFO ); } } return $findings; } /** * Builds a single finding for a content unit. 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, same as before. * * @param array{kind:string, text:string, ref:mixed, source_type:string} $unit Content unit. * @param string $issue Machine-readable issue code. * @param string $severity One of the SEVERITY_* constants. * @return array{ref:mixed, kind:string, source_type:string, content_snippet:string, issue:string, severity:string} */ protected function finding( array $unit, string $issue, string $severity ): array { return array( 'ref' => $unit['ref'], 'kind' => $unit['kind'], 'source_type' => $unit['source_type'] ?? '', 'content_snippet' => $this->snippet( $unit['text'] ), 'issue' => $issue, 'severity' => $severity, ); } /** * Truncates text to the secondary-match snippet length used for * fallback matching on the editor side. * * @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' ); } }