inside an HTML comment from a real one, and every * Gutenberg block delimiter is an HTML comment, so "comment out the old * heading" quietly produced a false finding on ordinary sites. The same class * handles script and style raw text, unquoted attribute values, and a ">" that * lives inside a quoted attribute value, all of which the old patterns got * wrong. WP_HTML_Tag_Processor is WordPress core from 6.2, and this plugin * declares "Requires at least: 6.2", so the dependency costs nothing and no * fallback path is carried. * * @package AISEOGodMode */ namespace AISEOGodMode; if ( ! defined( 'ABSPATH' ) ) { exit; } class Content_Health { /** Scan state + results. */ const OPT = 'asgm_content_health'; /** * Bump when inspection semantics change. * * Findings are cached in an option. Without a version marker, a site can * keep showing a missing-description result produced by an older scanner * even after the scanner learns to resolve generated SEO descriptions. */ const SCAN_VERSION = '2'; /** * Posts parsed per batch call. * * Larger than Link_Health's 12 because nothing here leaves the server: this * is a get_post() plus one pass of the HTML tag processor per item, not an * HTTP round trip. The batching exists for the same reason regardless, so a * 500-post site on shared hosting never sits inside one long request. */ const BATCH = 25; /** * Content Health is intentionally uncapped. * * The work is still performed in BATCH-sized requests, but the queue must * contain every eligible published item. A cap made a "full scan" capable * of missing featured images on larger stores and custom-post-type sites. * Zero is retained in the response as the conventional "no limit" value. */ const MAX_POSTS = 0; /** AI cost for inspecting one unique featured-image attachment. */ const FEATURED_ALT_CREDITS = 2; /** Maximum local image payload sent for one vision request (4 MiB). */ const MAX_VISION_IMAGE_BYTES = 4194304; /** * Character count above which a title is reported as long. * * Deliberately described as a rule of thumb everywhere it surfaces. Google * and Bing both truncate titles by PIXEL width in the rendered SERP, not by * character count, so no character number is a real limit: "Illinois" and * "lilliii" are the same length and nowhere near the same width. 60 is the * common working figure because it lands near the truncation point for * average-width text, and it is the number Bing Webmaster Tools itself uses * for its "Title too long" warning. * * We do not claim a pixel figure anywhere in the UI, because we cannot * measure pixels from PHP without knowing the font the SERP renders in. */ const TITLE_MAX = 60; /** * Longest description still reported as "very short" rather than fine. * * A one-word value in the meta field is a placeholder somebody typed to * silence their SEO plugin, not a snippet. It is reported separately from a * missing description and never folded into it: a page storing "Coffee" * renders a real meta description, so listing it under "nothing stored" * would be a false statement about the user's own site. */ const DESC_SHORT_MAX = 15; /** * Duplicate-description clusters drawn in the card. * * Purely a display limit, and it is applied where the drawing happens so * the count next to it can disclose the rest. Every number the card * headlines is taken from the full cluster list before this is applied. */ const DUPE_CLUSTERS_SHOWN = 20; /** * Maximum reusable-block nesting followed during one page inspection. * * Core prevents recursive reusable blocks from rendering forever. We need * the same protection locally, plus a hard ceiling for malformed imports * that form a very deep acyclic chain. Twelve nested patterns is already an * extreme editor shape; 32 leaves ample room without making a scan an * unbounded walk. */ const REUSABLE_BLOCK_MAX_DEPTH = 32; /** * Severity per finding, in one place. * * public_state() needs this twice, once to build the groups and once to * count how many pages carry a real problem as opposed to something merely * worth a look. Two lists that can drift apart would eventually disagree. */ const SEVERITY = array( 'h1_multiple' => 'problem', 'h1_in_body' => 'check', 'h1_missing' => 'problem', 'desc_missing' => 'problem', 'desc_short' => 'check', 'title_long' => 'check', 'alt_missing' => 'problem', 'alt_empty' => 'check', 'alt_featured' => 'problem', ); /** * "N credit(s) per page", read from the charged price so the text * cannot drift from what the customer pays. */ private static function credits_per_page( $fix_type ) { $cost = class_exists( __NAMESPACE__ . '\\MetadataGenerator' ) ? (int) MetadataGenerator::fix_cost( $fix_type ) : 1; return $cost . ( 1 === $cost ? ' credit' : ' credits' ) . ' per page'; } /** Read the stored state, shaped. */ public static function get_state() { $raw = get_option( self::OPT, array() ); if ( ! is_array( $raw ) ) { $raw = array(); } // Never present findings produced by an older inspection algorithm. // The UI will request a fresh scan instead of showing stale results. if ( self::SCAN_VERSION !== (string) ( $raw['scan_version'] ?? '' ) ) { return array( 'status' => 'never', 'queue' => array(), 'checked' => 0, 'total' => 0, 'published_total' => 0, 'post_types' => self::scannable_post_types(), 'pages' => array(), 'desc_index' => array(), 'has_content' => false, 'scanned_at' => '', 'scan_version' => self::SCAN_VERSION, ); } return array( 'status' => (string) ( $raw['status'] ?? 'never' ), // never|scanning|done 'queue' => ( isset( $raw['queue'] ) && is_array( $raw['queue'] ) ) ? $raw['queue'] : array(), 'checked' => (int) ( $raw['checked'] ?? 0 ), 'total' => (int) ( $raw['total'] ?? 0 ), 'published_total' => (int) ( $raw['published_total'] ?? 0 ), 'post_types' => ( isset( $raw['post_types'] ) && is_array( $raw['post_types'] ) ) ? $raw['post_types'] : self::scannable_post_types(), 'pages' => ( isset( $raw['pages'] ) && is_array( $raw['pages'] ) ) ? $raw['pages'] : array(), 'desc_index' => ( isset( $raw['desc_index'] ) && is_array( $raw['desc_index'] ) ) ? $raw['desc_index'] : array(), 'has_content' => ! empty( $raw['has_content'] ), 'scanned_at' => (string) ( $raw['scanned_at'] ?? '' ), 'scan_version' => self::SCAN_VERSION, ); } private static function save_state( $state ) { update_option( self::OPT, $state, false ); } /** Clear stale scan rows when the owner changes the shared content scope. */ public static function invalidate_for_scope_change() { delete_option( self::OPT ); } /** * Start a scan: queue every eligible published content item, drop prior results. * * Costs two queries, one for the queue and one cached count for the honest * site total. The parsing happens batch by batch, so this returns instantly * even on a site with hundreds of posts. */ public static function start_scan() { $post_types = self::scannable_post_types(); $ids = get_posts( array( 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'suppress_filters' => true, ) ); $ids = array_map( 'intval', (array) $ids ); if ( class_exists( __NAMESPACE__ . '\\Answer_Density' ) ) { $ids = array_values( array_filter( $ids, static function ( $post_id ) { return '' === Answer_Density::exclusion_reason( (int) $post_id ); } ) ); } $state = array( 'status' => empty( $ids ) ? 'done' : 'scanning', 'scan_version' => self::SCAN_VERSION, 'queue' => $ids, 'checked' => 0, 'total' => count( $ids ), // Only eligible search-facing content belongs in this denominator. // Transactional/noindex items are intentionally outside the work // queue, even when their post type is selected. 'published_total' => count( $ids ), 'post_types' => $post_types, 'pages' => array(), 'desc_index' => array(), // Separate from total on purpose. total counts down as the queue // drains in some designs; this one records "there was something to // scan" so an empty site can never be reported as a clean site. 'has_content' => ! empty( $ids ), 'scanned_at' => current_time( 'mysql' ), ); self::save_state( $state ); return self::public_state( $state ); } /** * Public, editor-visible content types Content Health can inspect. * * The saved list comes from WordPress' public, editor-visible post types, * so it includes site-specific products, courses, listings, downloads and * other custom types while excluding internal records. */ private static function scannable_post_types() { $types = class_exists( __NAMESPACE__ . '\\Answer_Density' ) ? Answer_Density::selected_post_types() : array( 'post', 'page' ); $types = array_values( array_filter( array_map( 'sanitize_key', (array) $types ), static function ( $type ) { if ( in_array( $type, array( 'attachment', 'nav_menu_item', 'revision', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation' ), true ) ) { return false; } return post_type_exists( $type ); } ) ); /** Filter the public content types included in a full Content Health scan. */ $types = apply_filters( 'aeogm_content_health_post_types', $types ); $types = array_values( array_unique( array_filter( array_map( 'sanitize_key', (array) $types ), 'post_type_exists' ) ) ); return ! empty( $types ) ? $types : array( 'post', 'page' ); } /** Parse the next batch of queued posts. */ public static function run_batch() { $state = self::get_state(); if ( 'scanning' !== $state['status'] || empty( $state['queue'] ) ) { return self::public_state( $state ); } $batch = array_splice( $state['queue'], 0, self::BATCH ); foreach ( $batch as $post_id ) { $state['checked']++; $post = get_post( (int) $post_id ); if ( ! $post ) { continue; } $row = self::inspect( $post ); // The description index has to see every page, including the clean // ones, or "duplicate across pages" cannot be answered: two pages // sharing a description are both individually fine. if ( '' !== $row['desc'] ) { $hash = md5( self::normalise_desc( $row['desc'] ) ); if ( ! isset( $state['desc_index'][ $hash ] ) ) { $state['desc_index'][ $hash ] = array( 'text' => $row['desc'], 'ids' => array() ); } $state['desc_index'][ $hash ]['ids'][] = (int) $post->ID; } // Only pages with something to say are stored. On a healthy site // this option stays near empty instead of carrying a row per page. if ( ! empty( $row['flags'] ) ) { unset( $row['desc'] ); $state['pages'][] = $row; } } if ( empty( $state['queue'] ) ) { $state['status'] = 'done'; } self::save_state( $state ); return self::public_state( $state ); } /** * Everything we can determine about one post without rendering it. * * Reads post_content rather than the_content(). Published synced patterns * are expanded because they are saved WordPress content, but shortcodes, * dynamic blocks and theme output are not rendered. Running the content * filters would pull in every other plugin's oEmbeds and callbacks on a * scan of 500 posts, which is both slow and a great way to trigger somebody * else's side effects. The cost of that choice is stated honestly in the H1 * copy: we can count saved content, not the heading a theme adds. */ private static function inspect( $post ) { $content = self::expand_reusable_blocks( (string) $post->post_content ); $flags = array(); $html = self::parse_html( $content ); // ---- Headings ---- $h1_count = $html['h1']; if ( $h1_count >= 2 ) { $flags['h1_multiple'] = $h1_count; } elseif ( 1 === $h1_count ) { // One H1 in the body is the interesting case and the one we cannot // settle from here. Nearly every theme prints the post title as an // H1, which would make this the second. Reported as "worth // checking" rather than as a defect, because a theme that does not // print a title H1 makes this page correct. $flags['h1_in_body'] = 1; } elseif ( '' === self::trim_text( (string) $post->post_title ) ) { // No H1 written and no title for a theme to promote into one. This // is the only genuinely certain "missing H1" available locally. $flags['h1_missing'] = 1; } // ---- Meta description ---- $desc = self::effective_description( $post->ID ); $desc_len = self::visible_length( $desc['text'] ); if ( 0 === $desc_len ) { $flags['desc_missing'] = 1; } elseif ( $desc_len <= self::DESC_SHORT_MAX ) { $flags['desc_short'] = $desc_len; } // ---- Title length ---- $title = self::effective_title( $post ); $title_len = self::visible_length( $title ); if ( $title_len > self::TITLE_MAX ) { $flags['title_long'] = $title_len; } // ---- Images ---- // Three separate findings, because they are three separate decisions. // An image with no alt attribute is broken. An image with alt="" may be // a correctly marked decorative image or an alt box the author left // blank, and nothing stored can tell those apart. A featured image with // no alt on the attachment is neither, and is fixed somewhere else. if ( $html['img_no_alt'] > 0 ) { $flags['alt_missing'] = $html['img_no_alt']; } $empty_alt_fingerprint = self::empty_alt_fingerprint_from_html( $html ); $reviewed_empty_alt = (string) get_post_meta( $post->ID, '_asgm_alt_empty_reviewed', true ); if ( $html['img_empty_alt'] > 0 && $reviewed_empty_alt !== $empty_alt_fingerprint ) { $flags['alt_empty'] = $html['img_empty_alt']; } if ( self::featured_image_missing_alt( $post->ID ) ) { $flags['alt_featured'] = 1; } return array( 'post_id' => (int) $post->ID, 'title' => (string) $post->post_title, 'type' => (string) $post->post_type, 'title_len' => $title_len, 'images' => (int) $html['img_total'], 'img_empty' => (int) $html['img_empty_alt'], 'desc' => ( $desc_len > 0 ) ? $desc['text'] : '', 'desc_len' => $desc_len, 'desc_text' => ( $desc_len > 0 && $desc_len <= self::DESC_SHORT_MAX ) ? $desc['text'] : '', // Named so the "no description" row can say which plugin is holding // a leftover, without ever counting that leftover as a pass. 'desc_stale' => (string) $desc['stale_label'], 'flags' => $flags, ); } /** * Expand published core/block references without rendering the page. * * A synced pattern is not present as HTML in the host post. Gutenberg saves * only ``, so scanning post_content verbatim * misses every heading and image inside it. parse_blocks() gives us a safe * structural walk and lets us resolve only this one core storage primitive. * No shortcode, dynamic block, render callback or content filter runs. * * Cycles are cut at the repeated reference, matching the practical result * of core's reusable-block recursion protection. Draft, private, trashed, * password-protected and deleted patterns contribute nothing because they * do not render for an ordinary visitor. * * @param string $content Saved block content. * @param array $seen Reusable block IDs already on this branch. * @param int $depth Current reusable block depth. * @return string HTML fragments that are actually saved to render. */ private static function expand_reusable_blocks( $content, $seen = array(), $depth = 0 ) { $content = (string) $content; if ( '' === $content || $depth >= self::REUSABLE_BLOCK_MAX_DEPTH ) { return ''; } $blocks = parse_blocks( $content ); $out = ''; foreach ( (array) $blocks as $block ) { $out .= self::expand_saved_block( $block, $seen, $depth ); } return $out; } /** Expand one parsed block while preserving its saved static HTML. */ private static function expand_saved_block( $block, $seen, $depth ) { if ( ! is_array( $block ) ) { return ''; } if ( 'core/block' === ( $block['blockName'] ?? null ) ) { $ref = (int) ( $block['attrs']['ref'] ?? 0 ); if ( ! $ref || isset( $seen[ $ref ] ) || $depth >= self::REUSABLE_BLOCK_MAX_DEPTH ) { return ''; } $pattern = get_post( $ref ); if ( ! $pattern || 'wp_block' !== $pattern->post_type || 'publish' !== $pattern->post_status || '' !== (string) $pattern->post_password ) { return ''; } $seen[ $ref ] = true; return self::expand_reusable_blocks( (string) $pattern->post_content, $seen, $depth + 1 ); } $inner_blocks = (array) ( $block['innerBlocks'] ?? array() ); $inner_content = (array) ( $block['innerContent'] ?? array() ); // Freeform HTML has no block wrapper and no innerContent on some older // parse_blocks() shapes, so retain innerHTML as the final fallback. if ( empty( $inner_content ) ) { return (string) ( $block['innerHTML'] ?? '' ); } $out = ''; $index = 0; foreach ( $inner_content as $fragment ) { if ( null !== $fragment ) { $out .= (string) $fragment; continue; } if ( isset( $inner_blocks[ $index ] ) ) { $out .= self::expand_saved_block( $inner_blocks[ $index ], $seen, $depth ); } $index++; } return $out; } /** * One pass of the HTML tag processor: H1s and the image alt breakdown. * * Both findings come from the same walk because both need the same thing, * a real tokenizer. The processor skips HTML comments, script and style raw * text, and reads unquoted attribute values and values containing ">", * which is the whole reason the regular expressions this replaced produced * findings that were not on the page. * * It is a tokenizer and not a tree builder, though, so it walks happily into * three kinds of markup a browser never renders, and every tag it finds in * there was reported as a real finding on the page. Each is handled below * against what Chrome actually does with it, because DOMDocument agrees with * neither the tokenizer nor the browser and is no use as a referee. */ private static function parse_html( $content ) { $out = array( 'h1' => 0, 'img_total' => 0, 'img_no_alt' => 0, 'img_empty_alt' => 0, 'empty_alt_srcs'=> array(), ); if ( '' === self::trim_text( $content ) ) { return $out; } // What we are inside of, if anything: '' | 'rawtext' | 'template'. $skip = ''; $depth = 0; $tags = new \WP_HTML_Tag_Processor( $content ); while ( $tags->next_tag( array( 'tag_closers' => 'visit' ) ) ) { $tag = $tags->get_tag(); $closer = $tags->is_tag_closer(); if ( 'rawtext' === $skip ) { // Raw text really is text: a second