'#000000', 'white' => '#ffffff', 'red' => '#ff0000', 'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00', 'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0', 'maroon' => '#800000', 'navy' => '#000080', 'olive' => '#808000', 'purple' => '#800080', 'teal' => '#008080', 'aqua' => '#00ffff', 'fuchsia' => '#ff00ff', 'lime' => '#00ff00', 'orange' => '#ffa500', ); /** * Constructor. * * @param string $html Raw HTML content. * @param string $url URL of the page. * @param int $post_id WordPress post ID. */ public function __construct( $html, $url = '', $post_id = 0 ) { $this->html = $html; $this->url = $url; $this->post_id = $post_id; $this->dom = new DOMDocument(); libxml_use_internal_errors( true ); // Prepend UTF-8 charset meta so DOMDocument handles encoding correctly. $this->dom->loadHTML( '' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); $this->xpath = new DOMXPath( $this->dom ); } /** * Fetch rendered HTML from a URL. * * @param string $url Page URL. * @return string|WP_Error HTML string or error. */ public static function fetch_page_html( $url ) { $response = wp_remote_get( $url, array( 'timeout' => 30, 'sslverify' => ! defined( 'WP_DEBUG' ) || ! WP_DEBUG, ) ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); if ( $code !== 200 ) { return new WP_Error( 'wpag_fetch_error', sprintf( /* translators: 1: HTTP status code, 2: URL */ __( 'HTTP %1$d when fetching %2$s', 'accessibility-guard' ), $code, $url ) ); } $body = wp_remote_retrieve_body( $response ); if ( empty( $body ) ) { return new WP_Error( 'wpag_empty_body', __( 'Empty response body.', 'accessibility-guard' ) ); } // Enforce 2 MB limit. if ( strlen( $body ) > 2 * 1024 * 1024 ) { return new WP_Error( 'wpag_too_large', __( 'Page HTML exceeds 2 MB limit.', 'accessibility-guard' ) ); } return $body; } /** * Run all checks. * * @param bool $force Skip cache. * @return array Issues. */ public function scan( $force = false ) { if ( ! $force && $this->post_id ) { $cached = get_transient( 'wpag_scan_' . $this->post_id ); if ( false !== $cached ) { $this->issues = $cached; return $cached; } } $this->issues = array(); $this->check_missing_alt_text(); $this->check_empty_links_buttons(); $this->check_color_contrast(); $this->check_heading_hierarchy(); $this->check_missing_form_labels(); $this->check_missing_lang_attr(); $this->check_missing_focus_indicators(); $this->check_missing_landmarks(); $this->check_empty_table_headers(); $this->check_missing_skip_nav(); $this->check_missing_page_title(); $this->check_duplicate_ids(); $this->check_empty_headings(); $this->check_images_missing_dimensions(); $this->check_missing_link_text(); $this->check_tabindex_positive(); $this->check_auto_playing_media(); $this->check_missing_viewport_meta(); $this->check_inline_styles_a11y(); $this->check_missing_list_structure(); // Run custom checks from Pro. $this->run_custom_checks(); if ( $this->post_id ) { set_transient( 'wpag_scan_' . $this->post_id, $this->issues, DAY_IN_SECONDS ); } return $this->issues; } /** * Get collected issues. * * @return array */ public function get_issues() { return $this->issues; } /** * Get summary counts by severity. * * @return array */ public function get_summary() { $summary = array( 'error' => 0, 'warning' => 0, 'notice' => 0, 'total' => count( $this->issues ), ); foreach ( $this->issues as $issue ) { if ( isset( $summary[ $issue['severity'] ] ) ) { $summary[ $issue['severity'] ]++; } } return $summary; } /** * Add an issue to the list. * * @param string $check_id Check identifier. * @param string $message Human-readable message. * @param string $element HTML snippet (truncated). */ private function add_issue( $check_id, $message, $element = '' ) { $check = WPAG_Checks::get_check( $check_id ); if ( ! $check ) { return; } $this->issues[] = array( 'check_id' => $check_id, 'severity' => $check['severity'], 'wcag' => $check['wcag'], 'level' => $check['level'], 'message' => $message, 'element' => mb_substr( $element, 0, 500 ), ); } /** * Get outer HTML of a DOM node (truncated). * * @param DOMNode $node DOM node. * @return string */ private function get_outer_html( $node ) { return $this->dom->saveHTML( $node ); } /** * Run custom checks from Pro features. */ private function run_custom_checks() { // Get custom checks registered via filter (from WPAG_Pro). $custom_checks = apply_filters( 'wpag_scan_checks', array() ); foreach ( $custom_checks as $check_id => $check ) { // Only process custom checks. if ( strpos( $check_id, 'custom_' ) !== 0 ) { continue; } // Run the callback if it exists. if ( ! empty( $check['callback'] ) && is_callable( $check['callback'] ) ) { $issues = call_user_func( $check['callback'], $this->html ); foreach ( $issues as $issue ) { $this->issues[] = array( 'check_id' => $check_id, 'severity' => $check['severity'] ?? 'warning', 'wcag' => $check['wcag'] ?? 'custom', 'level' => $check['level'] ?? 'A', 'message' => $issue['message'] ?? $check['description'], 'element' => mb_substr( $issue['element'] ?? '', 0, 500 ), ); } } } } // ------------------------------------------------------------------------- // Individual Checks // ------------------------------------------------------------------------- /** * Check 1: Missing image alt text. */ private function check_missing_alt_text() { $images = $this->xpath->query( '//img' ); foreach ( $images as $img ) { if ( ! $img->hasAttribute( 'alt' ) ) { // Skip decorative images. if ( $img->getAttribute( 'role' ) === 'presentation' || $img->getAttribute( 'aria-hidden' ) === 'true' ) { continue; } $this->add_issue( 'missing_alt_text', __( 'Image is missing the alt attribute.', 'accessibility-guard' ), $this->get_outer_html( $img ) ); } } } /** * Check 2: Empty links or buttons. */ private function check_empty_links_buttons() { // Empty links. $links = $this->xpath->query( '//a' ); foreach ( $links as $link ) { if ( $this->is_element_empty( $link ) && ! $this->has_accessible_name( $link ) ) { $this->add_issue( 'empty_links_buttons', __( 'Link has no text content or accessible name.', 'accessibility-guard' ), $this->get_outer_html( $link ) ); } } // Empty buttons. $buttons = $this->xpath->query( '//button' ); foreach ( $buttons as $btn ) { if ( $this->is_element_empty( $btn ) && ! $this->has_accessible_name( $btn ) ) { $this->add_issue( 'empty_links_buttons', __( 'Button has no text content or accessible name.', 'accessibility-guard' ), $this->get_outer_html( $btn ) ); } } } /** * Check 3: Color contrast (inline styles only — best effort). */ private function check_color_contrast() { $elements = $this->xpath->query( '//*[@style]' ); foreach ( $elements as $el ) { $style = $el->getAttribute( 'style' ); $fg = $this->extract_css_property( $style, 'color' ); $bg = $this->extract_css_property( $style, 'background-color' ); if ( ! $fg || ! $bg ) { continue; } $fg_rgb = $this->parse_color( $fg ); $bg_rgb = $this->parse_color( $bg ); if ( ! $fg_rgb || ! $bg_rgb ) { continue; } $ratio = $this->contrast_ratio( $fg_rgb, $bg_rgb ); // WCAG AA: 4.5:1 for normal text, 3:1 for large text. // We use 4.5:1 as default since we can't reliably determine font size. if ( $ratio < 4.5 ) { $this->add_issue( 'color_contrast', sprintf( /* translators: %s: contrast ratio */ __( 'Contrast ratio is %s:1 (minimum 4.5:1 required for normal text).', 'accessibility-guard' ), round( $ratio, 2 ) ), $this->get_outer_html( $el ) ); } } } /** * Check 4: Heading hierarchy. */ private function check_heading_hierarchy() { $headings = $this->xpath->query( '//h1|//h2|//h3|//h4|//h5|//h6' ); $prev_level = 0; $has_h1 = false; foreach ( $headings as $heading ) { $tag = strtolower( $heading->nodeName ); $level = (int) substr( $tag, 1 ); if ( $level === 1 ) { $has_h1 = true; } if ( $prev_level > 0 && $level > $prev_level + 1 ) { $this->add_issue( 'heading_hierarchy', sprintf( /* translators: 1: previous heading tag, 2: current heading tag */ __( 'Heading level skipped: H%1$d to H%2$d.', 'accessibility-guard' ), $prev_level, $level ), $this->get_outer_html( $heading ) ); } $prev_level = $level; } if ( $headings->length > 0 && ! $has_h1 ) { $this->add_issue( 'heading_hierarchy', __( 'Page has headings but is missing an H1 element.', 'accessibility-guard' ), '' ); } } /** * Check 5: Missing form labels. */ private function check_missing_form_labels() { $fields = $this->xpath->query( '//input[not(@type="hidden") and not(@type="submit") and not(@type="button") and not(@type="image") and not(@type="reset")]|//select|//textarea' ); foreach ( $fields as $field ) { // Check for aria-label or aria-labelledby. if ( $field->hasAttribute( 'aria-label' ) || $field->hasAttribute( 'aria-labelledby' ) ) { continue; } // Check for associated label via id. $id = $field->getAttribute( 'id' ); if ( $id ) { $label = $this->xpath->query( '//label[@for="' . $this->escape_xpath( $id ) . '"]' ); if ( $label->length > 0 ) { continue; } } // Check if wrapped in a label. $parent = $field->parentNode; while ( $parent ) { if ( $parent->nodeName === 'label' ) { break; } $parent = $parent->parentNode; } if ( $parent && $parent->nodeName === 'label' ) { continue; } // Check for title attribute as fallback. if ( $field->hasAttribute( 'title' ) ) { continue; } $this->add_issue( 'missing_form_labels', __( 'Form field has no associated label or accessible name.', 'accessibility-guard' ), $this->get_outer_html( $field ) ); } } /** * Check 6: Missing document language. */ private function check_missing_lang_attr() { $html = $this->xpath->query( '//html' ); if ( $html->length === 0 ) { return; } $el = $html->item( 0 ); if ( ! $el->hasAttribute( 'lang' ) && ! $el->hasAttribute( 'xml:lang' ) ) { $this->add_issue( 'missing_lang_attr', __( 'The HTML element is missing a lang attribute.', 'accessibility-guard' ), '' ); } } /** * Check 7: Removed focus indicators. */ private function check_missing_focus_indicators() { // Check