scan( $url ); } public function scan( string $url ): array { $html = $this->fetch_html( $url ); if ( ! $html ) return $this->empty_result( $url ); libxml_use_internal_errors( true ); $this->dom = new DOMDocument(); $this->dom->loadHTML( mb_convert_encoding( $html, 'HTML-ENTITIES', 'UTF-8' ) ); libxml_clear_errors(); // ── Phase 1 rules ────────────────────────────────────── $this->check_alt_text(); $this->check_form_labels(); $this->check_keyboard(); $this->check_language(); $this->check_focus( $html ); // ── Phase 5 rules ────────────────────────────────────── $this->check_contrast( $html ); $this->check_headings(); $this->check_link_text(); $this->check_aria(); $this->check_tables(); $this->check_landmarks(); $this->check_motion( $html ); $score = $this->calculate_score(); $result = [ 'url' => $url, 'scanned_at' => current_time( 'c' ), 'score' => $score, 'grade' => $this->calculate_grade( $score ), 'issues' => $this->issues, 'passed' => max( 0, self::TOTAL_RULES - count( $this->issues ) ), 'failed' => count( $this->issues ), ]; // Append to rolling history (kept in Phase 5 scheduler too). self::append_history( $result ); return $result; } private function fetch_html( string $url ) { $response = wp_remote_get( $url, [ 'timeout' => 15 ] ); if ( is_wp_error( $response ) ) return false; return wp_remote_retrieve_body( $response ); } // ── Phase 1 rules ────────────────────────────────────────────────────── // Rule 1 — WCAG 1.1.1 private function check_alt_text(): void { $count = 0; $first = null; foreach ( $this->dom->getElementsByTagName( 'img' ) as $img ) { if ( ! $img->hasAttribute( 'alt' ) ) { $count++; if ( ! $first ) $first = $this->outer_html( $img ); } } if ( $count > 0 ) { $this->add_issue([ 'rule' => 'alt-text', 'wcag' => '1.1.1', 'wcag_level' => 'A', 'wcag_standard' => '2.0 A', 'severity' => 'critical', /* translators: Accessibility issue title. */ 'title' => __( 'Images missing alt text attribute', 'accessmate' ), /* translators: Accessibility issue description. */ 'description' => __( 'Screen readers skip images without alt attributes entirely.', 'accessmate' ), 'element' => $first, 'selector' => 'img:not([alt])', 'count' => $count, 'ai_fixable' => true, ]); } } // Rule 2 — WCAG 4.1.2 private function check_form_labels(): void { $count = 0; $skip = [ 'hidden', 'submit', 'button', 'reset', 'image' ]; foreach ( $this->dom->getElementsByTagName( 'input' ) as $input ) { if ( in_array( $input->getAttribute( 'type' ), $skip, true ) ) continue; $has_aria = $input->hasAttribute( 'aria-label' ) || $input->hasAttribute( 'aria-labelledby' ); if ( ! $has_aria ) $count++; } if ( $count > 0 ) { $this->add_issue([ 'rule' => 'form-labels', 'wcag' => '4.1.2', 'wcag_level' => 'A', 'wcag_standard' => '2.0 A', 'severity' => 'critical', /* translators: Accessibility issue title. */ 'title' => __( 'Form inputs missing label association', 'accessmate' ), /* translators: Accessibility issue description. */ 'description' => __( 'Screen readers cannot announce what the input is for.', 'accessmate' ), 'selector' => 'input:not([aria-label]):not([aria-labelledby])', 'count' => $count, 'ai_fixable' => true, ]); } } // Rule 3 — WCAG 2.1.1 private function check_keyboard(): void { $xpath = new DOMXPath( $this->dom ); $nodes = $xpath->query( '//*[@onclick and not(self::a) and not(self::button) and not(self::input) and not(self::select) and not(self::textarea)]' ); $count = $nodes ? $nodes->length : 0; if ( $count > 0 ) { $this->add_issue([ 'rule' => 'keyboard', 'wcag' => '2.1.1', 'wcag_level' => 'A', 'wcag_standard' => '2.0 A', 'severity' => 'critical', /* translators: Accessibility issue title. */ 'title' => __( 'Interactive element not keyboard accessible', 'accessmate' ), /* translators: Accessibility issue description. */ 'description' => __( 'Click handlers on non-button elements cannot be triggered by keyboard.', 'accessmate' ), 'selector' => 'div[onclick], span[onclick]', 'count' => $count, 'ai_fixable' => true, ]); } } // Rule 4 — WCAG 3.1.1 private function check_language(): void { $html = $this->dom->getElementsByTagName( 'html' )->item( 0 ); if ( $html && ! $html->hasAttribute( 'lang' ) ) { $this->add_issue([ 'rule' => 'language', 'wcag' => '3.1.1', 'wcag_level' => 'A', 'wcag_standard' => '2.0 A', 'severity' => 'warning', /* translators: Accessibility issue title. */ 'title' => __( 'HTML lang attribute missing', 'accessmate' ), /* translators: Accessibility issue description. */ 'description' => __( 'Screen readers need the lang attribute to determine pronunciation rules.', 'accessmate' ), 'element' => '', 'selector' => 'html:not([lang])', 'count' => 1, 'ai_fixable' => false, ]); } } // Rule 5 — WCAG 2.4.7 private function check_focus( string $html ): void { if ( preg_match( '/outline\s*:\s*(none|0)\b/i', $html ) ) { $this->add_issue([ 'rule' => 'focus-indicator', 'wcag' => '2.4.7', 'wcag_level' => 'AA', 'wcag_standard' => '2.0 AA', 'severity' => 'warning', /* translators: Accessibility issue title. */ 'title' => __( 'Focus indicator removed via CSS', 'accessmate' ), /* translators: Accessibility issue description. */ 'description' => __( 'outline:none prevents keyboard users from seeing their current position.', 'accessmate' ), 'element' => 'outline: none', 'selector' => ':focus', 'count' => 1, 'ai_fixable' => true, ]); } } // ── Phase 5 rules ────────────────────────────────────────────────────── // Rule 6 — WCAG 1.4.3: heuristic check for very-light inline text colours. private function check_contrast( string $html ): void { // Detect inline colour values that are likely too light to meet 4.5:1. // Full contrast calculation requires a colour-parsing library; this is // a practical heuristic for the most common patterns. if ( preg_match( '/color\s*:\s*(#(?:[fF]{2}[a-fA-F0-9]{4}|[a-fA-F0-9]{2}[fF]{2}[fF]{2})|white|rgb\(\s*25[0-5]\s*,\s*25[0-5]\s*,\s*25[0-5]\s*\))/i', $html ) ) { $this->add_issue([ 'rule' => 'contrast', 'wcag' => '1.4.3', 'wcag_level' => 'AA', 'wcag_standard' => '2.0 AA', 'severity' => 'critical', /* translators: Accessibility issue title. */ 'title' => __( 'Potentially insufficient colour contrast', 'accessmate' ), /* translators: Accessibility issue description. */ 'description' => __( 'Very light inline colour styles may fall below the 4.5:1 WCAG contrast ratio.', 'accessmate' ), 'selector' => '[style*="color"]', 'count' => 1, 'ai_fixable' => true, ]); } } // Rule 7 — WCAG 2.4.6: skipped heading levels or empty headings. private function check_headings(): void { $found = []; $empty = 0; $has_skipped = false; foreach ( [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ] as $tag ) { foreach ( $this->dom->getElementsByTagName( $tag ) as $node ) { $text = trim( $node->textContent ); if ( $text === '' ) { $empty++; } $found[] = (int) substr( $tag, 1 ); } } $prev = 0; foreach ( $found as $level ) { if ( $prev > 0 && $level - $prev > 1 ) { $has_skipped = true; break; } $prev = $level; } if ( $empty > 0 || $has_skipped ) { $this->add_issue([ 'rule' => 'headings', 'wcag' => '2.4.6', 'wcag_level' => 'AA', 'wcag_standard' => '2.0 AA', 'severity' => 'warning', /* translators: Accessibility issue title. */ 'title' => $empty > 0 ? __( 'Empty heading elements found', 'accessmate' ) : __( 'Heading levels are skipped', 'accessmate' ), /* translators: Accessibility issue description. */ 'description' => __( 'Logical heading structure and non-empty headings help screen reader users navigate.', 'accessmate' ), 'selector' => 'h1, h2, h3, h4, h5, h6', 'count' => $empty + ( $has_skipped ? 1 : 0 ), 'ai_fixable' => false, ]); } } // Rule 8 — WCAG 2.4.4: links with non-descriptive text. private function check_link_text(): void { $generic = '/^(click here|here|read more|more|learn more|link|details|info|this|go)$/i'; $count = 0; $first = null; foreach ( $this->dom->getElementsByTagName( 'a' ) as $a ) { $text = trim( $a->textContent ); if ( preg_match( $generic, $text ) ) { $count++; if ( ! $first ) $first = $this->outer_html( $a ); continue; } // Image-only link with no alt text. $imgs = $a->getElementsByTagName( 'img' ); if ( $text === '' && $imgs->length > 0 ) { foreach ( $imgs as $img ) { if ( ! $img->hasAttribute( 'alt' ) || $img->getAttribute( 'alt' ) === '' ) { $count++; if ( ! $first ) $first = $this->outer_html( $a ); break; } } } } if ( $count > 0 ) { $this->add_issue([ 'rule' => 'link-text', 'wcag' => '2.4.4', 'wcag_level' => 'A', 'wcag_standard' => '2.0 A', 'severity' => 'warning', /* translators: Accessibility issue title. */ 'title' => __( 'Links with non-descriptive text', 'accessmate' ), /* translators: Accessibility issue description. */ 'description' => __( 'Generic link text like "click here" does not describe the destination for screen reader users.', 'accessmate' ), 'element' => $first, 'selector' => 'a', 'count' => $count, 'ai_fixable' => true, ]); } } // Rule 9 — WCAG 4.1.2: invalid ARIA role values. private function check_aria(): void { static $valid_roles = [ 'alert','alertdialog','application','article','banner','button','cell', 'checkbox','columnheader','combobox','complementary','contentinfo', 'definition','dialog','directory','document','feed','figure','form', 'grid','gridcell','group','heading','img','link','list','listbox', 'listitem','log','main','marquee','math','menu','menubar','menuitem', 'menuitemcheckbox','menuitemradio','navigation','none','note','option', 'presentation','progressbar','radio','radiogroup','region','row', 'rowgroup','rowheader','scrollbar','search','searchbox','separator', 'slider','spinbutton','status','switch','tab','table','tablist', 'tabpanel','term','textbox','timer','toolbar','tooltip','tree', 'treegrid','treeitem', ]; $xpath = new DOMXPath( $this->dom ); $nodes = $xpath->query( '//*[@role]' ); $count = 0; $first = null; if ( $nodes ) { foreach ( $nodes as $node ) { $role = strtolower( trim( $node->getAttribute( 'role' ) ) ); if ( ! in_array( $role, $valid_roles, true ) ) { $count++; if ( ! $first ) $first = $this->outer_html( $node ); } } } if ( $count > 0 ) { $this->add_issue([ 'rule' => 'aria', 'wcag' => '4.1.2', 'wcag_level' => 'A', 'wcag_standard' => '2.0 A', 'severity' => 'critical', /* translators: Accessibility issue title. */ 'title' => __( 'Invalid ARIA role attributes', 'accessmate' ), /* translators: Accessibility issue description. */ 'description' => __( 'Invalid ARIA roles are ignored by assistive technologies, removing announced semantics.', 'accessmate' ), 'element' => $first, 'selector' => '[role]', 'count' => $count, 'ai_fixable' => true, ]); } } // Rule 10 — WCAG 1.3.1: data tables missing header or caption. private function check_tables(): void { $count = 0; foreach ( $this->dom->getElementsByTagName( 'table' ) as $table ) { // Skip layout tables. $role = strtolower( $table->getAttribute( 'role' ) ); if ( in_array( $role, [ 'presentation', 'none' ], true ) ) continue; $has_caption = $table->getElementsByTagName( 'caption' )->length > 0; $has_th = $table->getElementsByTagName( 'th' )->length > 0; if ( ! $has_caption && ! $has_th ) { $count++; } } if ( $count > 0 ) { $this->add_issue([ 'rule' => 'tables', 'wcag' => '1.3.1', 'wcag_level' => 'A', 'wcag_standard' => '2.0 A', 'severity' => 'warning', /* translators: Accessibility issue title. */ 'title' => __( 'Data tables missing headers or caption', 'accessmate' ), /* translators: Accessibility issue description. */ 'description' => __( 'Tables without <th> or <caption> elements cannot be navigated by screen reader users.', 'accessmate' ), 'selector' => 'table', 'count' => $count, 'ai_fixable' => true, ]); } } // Rule 11 — WCAG 1.3.6: page missing essential landmark elements. private function check_landmarks(): void { $xpath = new DOMXPath( $this->dom ); $has_main = $xpath->query( '//main | //*[@role="main"]' )->length > 0; $has_nav = $xpath->query( '//nav | //*[@role="navigation"]' )->length > 0; $missing = []; if ( ! $has_main ) $missing[] = '
'; if ( ! $has_nav ) $missing[] = '