settings = $settings; } /** * Run a lightweight public-content accessibility scan. * * @param int $limit Number of public items to scan. * @return array */ public function scan( $limit = 25 ) { $options = $this->settings->get_options(); $limit = max( 1, min( 50, absint( $limit ) ) ); $post_types = isset( $options['include_post_types'] ) && is_array( $options['include_post_types'] ) ? $options['include_post_types'] : array( 'page', 'post' ); $post_types = array_values( array_filter( array_map( 'sanitize_key', $post_types ) ) ); if ( empty( $post_types ) ) { $post_types = array( 'page', 'post' ); } $metrics = $this->empty_metrics(); $issues = array(); $pages = array(); $query = new WP_Query( array( 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'ignore_sticky_posts' => true, 'no_found_rows' => true, ) ); if ( $query->have_posts() ) { foreach ( $query->posts as $post ) { $html = apply_filters( 'the_content', $post->post_content ); $page = array( 'id' => absint( $post->ID ), 'title' => get_the_title( $post ), 'url' => get_permalink( $post ), 'type' => get_post_type( $post ), ); $page_result = $this->analyse_html( $html, $page ); $pages[] = array_merge( $page, array( 'issues' => count( $page_result['issues'] ), ) ); foreach ( $page_result['metrics'] as $key => $value ) { if ( isset( $metrics[ $key ] ) ) { $metrics[ $key ] += absint( $value ); } } $issues = array_merge( $issues, $page_result['issues'] ); } wp_reset_postdata(); } $metrics['pages_scanned'] = count( $pages ); $metrics['language_defined'] = '' !== trim( (string) get_bloginfo( 'language' ) ) ? 1 : 0; if ( ! $metrics['language_defined'] ) { $issues[] = $this->issue( 'language_missing', __( 'Page language is not defined', 'agizoai-agentic-browsing-optimizer' ), __( 'WordPress did not return a site language code.', 'agizoai-agentic-browsing-optimizer' ), __( 'Language metadata helps assistive technology and agents interpret page content correctly.', 'agizoai-agentic-browsing-optimizer' ), __( 'Set the site language in WordPress Settings > General.', 'agizoai-agentic-browsing-optimizer' ), 'warning', array() ); } $critical_count = count( array_filter( $issues, function ( $issue ) { return isset( $issue['severity'] ) && 'critical' === $issue['severity']; } ) ); $warning_count = count( array_filter( $issues, function ( $issue ) { return isset( $issue['severity'] ) && 'warning' === $issue['severity']; } ) ); return array( 'enabled' => ! empty( $options['accessibility_scanner_enabled'] ), 'pages' => $pages, 'pages_scanned' => absint( $metrics['pages_scanned'] ), 'metrics' => $metrics, 'issues' => $issues, 'issue_count' => count( $issues ), 'critical_count' => $critical_count, 'warning_count' => $warning_count, 'score' => $this->score_from_metrics( $metrics, $issues ), ); } /** * Empty metrics array. * * @return array */ private function empty_metrics() { return array( 'pages_scanned' => 0, 'images_total' => 0, 'images_missing_alt' => 0, 'links_total' => 0, 'links_vague' => 0, 'links_empty' => 0, 'buttons_total' => 0, 'buttons_without_name' => 0, 'inputs_total' => 0, 'inputs_without_name' => 0, 'inputs_without_label' => 0, 'headings_total' => 0, 'heading_level_skips' => 0, 'h1_in_content' => 0, 'language_defined' => 0, ); } /** * Analyse a rendered content HTML fragment. * * @param string $html Rendered HTML. * @param array $page Page context. * @return array */ private function analyse_html( $html, array $page ) { if ( class_exists( 'DOMDocument' ) ) { return $this->analyse_with_dom( $html, $page ); } return $this->analyse_with_regex( $html, $page ); } /** * Analyse HTML with DOMDocument. * * @param string $html Rendered HTML. * @param array $page Page context. * @return array */ private function analyse_with_dom( $html, array $page ) { $metrics = $this->empty_metrics(); $issues = array(); $dom = new DOMDocument(); $previous = libxml_use_internal_errors( true ); $encoded = '' . (string) $html; $dom->loadHTML( $encoded, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); $xpath = new DOMXPath( $dom ); $images = $dom->getElementsByTagName( 'img' ); $metrics['images_total'] = $images->length; foreach ( $images as $img ) { $alt = $img->hasAttribute( 'alt' ) ? trim( $img->getAttribute( 'alt' ) ) : ''; if ( '' === $alt ) { $metrics['images_missing_alt']++; $issues[] = $this->issue( 'image_alt_missing', __( 'Image is missing descriptive alt text', 'agizoai-agentic-browsing-optimizer' ), __( 'An image in public content has no useful alt text.', 'agizoai-agentic-browsing-optimizer' ), __( 'Agents and assistive technologies use alt text to understand visual content.', 'agizoai-agentic-browsing-optimizer' ), __( 'Add concise, descriptive alt text or mark decorative images intentionally.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } } $links = $dom->getElementsByTagName( 'a' ); $metrics['links_total'] = $links->length; foreach ( $links as $link ) { $name = $this->accessible_name_from_node( $link ); $text = $this->normalise_text( $link->textContent ); if ( '' === $name ) { $metrics['links_empty']++; $issues[] = $this->issue( 'link_name_missing', __( 'Link has no accessible name', 'agizoai-agentic-browsing-optimizer' ), __( 'A link in public content has no readable text, title or aria-label.', 'agizoai-agentic-browsing-optimizer' ), __( 'Agents may not understand where the link goes or why it matters.', 'agizoai-agentic-browsing-optimizer' ), __( 'Add clear link text or an aria-label.', 'agizoai-agentic-browsing-optimizer' ), 'critical', $page ); } elseif ( $this->is_vague_link_text( $text ) ) { $metrics['links_vague']++; $issues[] = $this->issue( 'link_text_vague', __( 'Link text is vague', 'agizoai-agentic-browsing-optimizer' ), __( 'A link uses generic text such as “click here” or “read more”.', 'agizoai-agentic-browsing-optimizer' ), __( 'Agents understand pages better when link text describes the destination.', 'agizoai-agentic-browsing-optimizer' ), __( 'Replace generic link text with descriptive anchor text.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } } $buttons = $dom->getElementsByTagName( 'button' ); $metrics['buttons_total'] += $buttons->length; foreach ( $buttons as $button ) { if ( '' === $this->accessible_name_from_node( $button ) ) { $metrics['buttons_without_name']++; $issues[] = $this->issue( 'button_name_missing', __( 'Button has no accessible name', 'agizoai-agentic-browsing-optimizer' ), __( 'A button has no visible text, title or aria-label.', 'agizoai-agentic-browsing-optimizer' ), __( 'Agents use button names to decide which action to take.', 'agizoai-agentic-browsing-optimizer' ), __( 'Add visible button text or an aria-label.', 'agizoai-agentic-browsing-optimizer' ), 'critical', $page ); } } foreach ( array( 'input', 'select', 'textarea' ) as $tag ) { $nodes = $dom->getElementsByTagName( $tag ); foreach ( $nodes as $node ) { $type = $node->hasAttribute( 'type' ) ? strtolower( $node->getAttribute( 'type' ) ) : ''; if ( 'input' === $tag && in_array( $type, array( 'hidden', 'submit', 'button', 'reset', 'image' ), true ) ) { if ( in_array( $type, array( 'submit', 'button', 'reset', 'image' ), true ) ) { $metrics['buttons_total']++; if ( '' === $this->accessible_name_from_node( $node ) && ! $node->hasAttribute( 'value' ) ) { $metrics['buttons_without_name']++; } } continue; } $metrics['inputs_total']++; $name = $node->hasAttribute( 'name' ) ? trim( $node->getAttribute( 'name' ) ) : ''; if ( '' === $name ) { $metrics['inputs_without_name']++; $issues[] = $this->issue( 'input_name_missing', __( 'Form field is missing a name attribute', 'agizoai-agentic-browsing-optimizer' ), __( 'A public form field has no stable name attribute.', 'agizoai-agentic-browsing-optimizer' ), __( 'Agents and browsers need field names to map and submit form values correctly.', 'agizoai-agentic-browsing-optimizer' ), __( 'Set a field name in the form plugin or template markup.', 'agizoai-agentic-browsing-optimizer' ), 'critical', $page ); } if ( ! $this->field_has_label( $node, $xpath ) ) { $metrics['inputs_without_label']++; $issues[] = $this->issue( 'input_label_missing', __( 'Form field has no accessible label', 'agizoai-agentic-browsing-optimizer' ), __( 'A public form field has no label, aria-label, title or useful placeholder.', 'agizoai-agentic-browsing-optimizer' ), __( 'Agents rely on field labels to understand what information is required.', 'agizoai-agentic-browsing-optimizer' ), __( 'Add a visible label or aria-label for this form field.', 'agizoai-agentic-browsing-optimizer' ), 'critical', $page ); } } } $heading_nodes = $xpath->query( '//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6]' ); $previous_level = 0; if ( $heading_nodes ) { $metrics['headings_total'] = $heading_nodes->length; foreach ( $heading_nodes as $heading ) { $level = absint( substr( strtolower( $heading->nodeName ), 1 ) ); if ( 1 === $level ) { $metrics['h1_in_content']++; $issues[] = $this->issue( 'h1_in_content', __( 'Content contains an H1 heading', 'agizoai-agentic-browsing-optimizer' ), __( 'The rendered content includes an H1 heading inside the article body.', 'agizoai-agentic-browsing-optimizer' ), __( 'Most themes already use H1 for the page title; duplicate H1s can confuse hierarchy.', 'agizoai-agentic-browsing-optimizer' ), __( 'Use H2 for main article sections unless this template intentionally owns the page H1.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } if ( $previous_level > 0 && $level > $previous_level + 1 ) { $metrics['heading_level_skips']++; $issues[] = $this->issue( 'heading_level_skip', __( 'Heading level is skipped', 'agizoai-agentic-browsing-optimizer' ), __( 'A heading jumps by more than one level, for example H2 to H4.', 'agizoai-agentic-browsing-optimizer' ), __( 'A clean heading outline helps agents understand page structure.', 'agizoai-agentic-browsing-optimizer' ), __( 'Use sequential heading levels where possible.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } $previous_level = $level; } } return array( 'metrics' => $metrics, 'issues' => $this->dedupe_and_limit_issues( $issues ), ); } /** * Basic regex fallback when DOMDocument is unavailable. * * @param string $html Rendered HTML. * @param array $page Page context. * @return array */ private function analyse_with_regex( $html, array $page ) { $metrics = $this->empty_metrics(); $issues = array(); $html = (string) $html; if ( preg_match_all( '/]*>/i', $html, $matches ) ) { $metrics['images_total'] = count( $matches[0] ); foreach ( $matches[0] as $tag ) { if ( ! preg_match( '/\salt\s*=\s*(["\'])(.*?)\1/i', $tag, $alt ) || '' === trim( html_entity_decode( $alt[2] ) ) ) { $metrics['images_missing_alt']++; $issues[] = $this->issue( 'image_alt_missing', __( 'Image is missing descriptive alt text', 'agizoai-agentic-browsing-optimizer' ), __( 'An image in public content has no useful alt text.', 'agizoai-agentic-browsing-optimizer' ), __( 'Agents and assistive technologies use alt text to understand visual content.', 'agizoai-agentic-browsing-optimizer' ), __( 'Add concise, descriptive alt text or mark decorative images intentionally.', 'agizoai-agentic-browsing-optimizer' ), 'warning', $page ); } } } if ( preg_match_all( '/<(input|select|textarea)\b[^>]*>/i', $html, $fields ) ) { foreach ( $fields[0] as $tag ) { if ( preg_match( '/\stype\s*=\s*(["\'])(hidden|submit|button|reset|image)\1/i', $tag ) ) { continue; } $metrics['inputs_total']++; if ( ! preg_match( '/\sname\s*=\s*(["\'])(.*?)\1/i', $tag, $name ) || '' === trim( $name[2] ) ) { $metrics['inputs_without_name']++; $issues[] = $this->issue( 'input_name_missing', __( 'Form field is missing a name attribute', 'agizoai-agentic-browsing-optimizer' ), __( 'A public form field has no stable name attribute.', 'agizoai-agentic-browsing-optimizer' ), __( 'Agents and browsers need field names to map and submit form values correctly.', 'agizoai-agentic-browsing-optimizer' ), __( 'Set a field name in the form plugin or template markup.', 'agizoai-agentic-browsing-optimizer' ), 'critical', $page ); } if ( ! preg_match( '/\s(aria-label|title|placeholder)\s*=\s*(["\'])(.*?)\2/i', $tag ) ) { $metrics['inputs_without_label']++; $issues[] = $this->issue( 'input_label_missing', __( 'Form field has no accessible label', 'agizoai-agentic-browsing-optimizer' ), __( 'A public form field has no label, aria-label, title or useful placeholder.', 'agizoai-agentic-browsing-optimizer' ), __( 'Agents rely on field labels to understand what information is required.', 'agizoai-agentic-browsing-optimizer' ), __( 'Add a visible label or aria-label for this form field.', 'agizoai-agentic-browsing-optimizer' ), 'critical', $page ); } } } return array( 'metrics' => $metrics, 'issues' => $this->dedupe_and_limit_issues( $issues ), ); } /** * Get a practical accessible name from a node. * * @param DOMNode $node Node. * @return string */ private function accessible_name_from_node( $node ) { if ( $node instanceof DOMElement ) { foreach ( array( 'aria-label', 'title', 'alt', 'value' ) as $attr ) { if ( $node->hasAttribute( $attr ) && '' !== trim( $node->getAttribute( $attr ) ) ) { return $this->normalise_text( $node->getAttribute( $attr ) ); } } } $text = $this->normalise_text( $node->textContent ); if ( '' !== $text ) { return $text; } if ( $node instanceof DOMElement ) { $imgs = $node->getElementsByTagName( 'img' ); foreach ( $imgs as $img ) { if ( $img->hasAttribute( 'alt' ) && '' !== trim( $img->getAttribute( 'alt' ) ) ) { return $this->normalise_text( $img->getAttribute( 'alt' ) ); } } } return ''; } /** * Check whether a form field has an accessible label. * * @param DOMElement $node Field node. * @param DOMXPath $xpath XPath helper. * @return bool */ private function field_has_label( $node, $xpath ) { foreach ( array( 'aria-label', 'aria-labelledby', 'title', 'placeholder' ) as $attr ) { if ( $node->hasAttribute( $attr ) && '' !== trim( $node->getAttribute( $attr ) ) ) { return true; } } if ( $node->hasAttribute( 'id' ) && '' !== trim( $node->getAttribute( 'id' ) ) ) { $id = $node->getAttribute( 'id' ); $labels = $xpath->query( '//label[@for=' . $this->xpath_literal( $id ) . ']' ); if ( $labels && $labels->length > 0 ) { return true; } } $parent = $node->parentNode; while ( $parent ) { if ( $parent instanceof DOMElement && 'label' === strtolower( $parent->tagName ) ) { return true; } $parent = $parent->parentNode; } return false; } /** * Build an XPath string literal. * * @param string $value Value. * @return string */ private function xpath_literal( $value ) { $value = (string) $value; if ( false === strpos( $value, '"' ) ) { return '"' . $value . '"'; } if ( false === strpos( $value, "'" ) ) { return "'" . $value . "'"; } $parts = explode( '"', $value ); return 'concat("' . implode( '", \'"\', "', $parts ) . '")'; } /** * Return true when link text is too generic. * * @param string $text Link text. * @return bool */ private function is_vague_link_text( $text ) { $text = strtolower( $this->normalise_text( $text ) ); $vague = array( 'click here', 'here', 'read more', 'more', 'learn more', 'view more', 'continue reading', 'details' ); return in_array( $text, $vague, true ); } /** * Normalise whitespace in text. * * @param string $text Text. * @return string */ private function normalise_text( $text ) { $text = wp_strip_all_tags( html_entity_decode( (string) $text, ENT_QUOTES, get_bloginfo( 'charset' ) ) ); $text = preg_replace( '/\s+/', ' ', $text ); return trim( (string) $text ); } /** * Dedupe and limit issues to keep dashboard readable. * * @param array> $issues Issues. * @return array> */ private function dedupe_and_limit_issues( array $issues ) { $seen = array(); $result = array(); foreach ( $issues as $issue ) { $key = $issue['type'] . '|' . ( isset( $issue['page_url'] ) ? $issue['page_url'] : '' ); if ( isset( $seen[ $key ] ) && $seen[ $key ] >= 3 ) { continue; } if ( ! isset( $seen[ $key ] ) ) { $seen[ $key ] = 0; } $seen[ $key ]++; $result[] = $issue; } return array_slice( $result, 0, 80 ); } /** * Build one issue. * * @param string $type Type. * @param string $title Title. * @param string $problem Problem. * @param string $why Why. * @param string $fix Fix. * @param string $severity Severity. * @param array $page Page context. * @return array */ private function issue( $type, $title, $problem, $why, $fix, $severity, array $page ) { return array( 'type' => sanitize_key( $type ), 'title' => $title, 'problem' => $problem, 'why' => $why, 'fix' => $fix, 'severity' => sanitize_key( $severity ), 'page_id' => isset( $page['id'] ) ? absint( $page['id'] ) : 0, 'page_title' => isset( $page['title'] ) ? sanitize_text_field( $page['title'] ) : '', 'page_url' => isset( $page['url'] ) ? esc_url_raw( $page['url'] ) : '', 'page_type' => isset( $page['type'] ) ? sanitize_key( $page['type'] ) : '', ); } /** * Calculate a simple accessibility sub-score. * * @param array $metrics Metrics. * @param array> $issues Issues. * @return int */ private function score_from_metrics( array $metrics, array $issues ) { if ( empty( $metrics['pages_scanned'] ) ) { return 0; } $score = 100; foreach ( $issues as $issue ) { $score -= isset( $issue['severity'] ) && 'critical' === $issue['severity'] ? 8 : 3; } return max( 0, min( 100, absint( $score ) ) ); } }