emptyResult(__('Invalid scan path. Could not resolve path.', 'ability')); } if (strpos($real_path, $wp_path) !== 0) { return $this->emptyResult(__('Invalid scan path. Path must be within WordPress installation.', 'ability')); } $is_pro = function_exists('ability_is_pro') ? ability_is_pro() : false; $settings = class_exists('ABILITY\Config') ? \ABILITY\Config::get_settings() : []; $max_files = $settings['max_files_per_scan'] ?? ($is_pro ? 999 : 10); if (!$is_pro) { $today = date('Y-m-d'); $daily_key = 'ability_daily_scans_' . $today; $daily_scans = get_transient($daily_key); if ($daily_scans === false) $daily_scans = 0; if ($daily_scans >= 10) { return [ 'total' => 0, 'errors' => 0, 'warnings' => 0, 'results' => [], 'error' => __('Daily scan limit reached (10 pages/day). Upgrade to Pro for unlimited scans.', 'ability'), 'upgrade_url' => 'https://demewebsolutions.com/ability', ]; } } // Run pure PHP scan against published pages/posts (rendered HTML). $scan_results = $this->scan_pages($max_files, $is_pro); $results = $scan_results['results']; $tool_version = $scan_results['tool_version']; $ruleset_version = $scan_results['ruleset_version']; $this->save_results($results, $tool_version, $ruleset_version); if (!$is_pro) { $today = date('Y-m-d'); $daily_key = 'ability_daily_scans_' . $today; $daily_scans = get_transient($daily_key) ?: 0; $files_scanned = count(array_unique(array_column($results, 'file'))); set_transient($daily_key, $daily_scans + $files_scanned, DAY_IN_SECONDS); } return [ 'total' => count($results), 'errors' => count(array_filter($results, fn($r) => ($r['severity'] ?? '') === 'ERROR')), 'warnings' => count(array_filter($results, fn($r) => ($r['severity'] ?? '') === 'WARNING')), 'results' => $results, 'tool_version' => $tool_version, 'ruleset_version'=> $ruleset_version, 'tier' => $is_pro ? 'pro' : 'basic', 'files_scanned' => count(array_unique(array_column($results, 'file'))), 'max_files' => $max_files, ]; } private function emptyResult(string $error): array { return [ 'total' => 0, 'errors' => 0, 'warnings' => 0, 'results' => [], 'error' => $error, ]; } private function scan_basic(string $path, int $limit, bool $is_pro): array { // Deprecated – kept only for backward-compatibility signature. // Delegate to page-based scanner. $scan_results = $this->scan_pages($limit, $is_pro); return [ 'total' => $scan_results['total'], 'errors' => $scan_results['errors'], 'warnings' => $scan_results['warnings'], 'results' => $scan_results['results'], 'tier' => $is_pro ? 'pro' : 'basic', 'files_scanned' => $scan_results['files_scanned'], 'max_files' => $limit, 'tool_version' => $scan_results['tool_version'], 'ruleset_version'=> $scan_results['ruleset_version'], ]; } /** * Scan rendered pages (posts/pages/front page) for basic accessibility issues. * * @param int $limit Maximum number of pages to scan. * @param bool $is_pro Whether Pro tier is active. */ private function scan_pages(int $limit, bool $is_pro): array { $results = []; // Collect URLs: front page + published posts/pages, limited by $limit. $urls = []; // Always include the homepage if possible. $home_url = home_url('/'); if ($home_url) { $urls[] = $home_url; } // Query published posts and pages. if (class_exists('\WP_Query')) { $q = new \WP_Query([ 'post_type' => ['post', 'page'], 'post_status' => 'publish', 'posts_per_page' => max(1, $limit - count($urls)), 'fields' => 'ids', 'no_found_rows' => true, ]); if ($q->have_posts()) { foreach ($q->posts as $post_id) { $url = get_permalink($post_id); if ($url && !in_array($url, $urls, true)) { $urls[] = $url; if (count($urls) >= $limit) { break; } } } } wp_reset_postdata(); } $urls = array_slice(array_unique($urls), 0, $limit); foreach ($urls as $url) { $response = wp_remote_get($url, [ 'timeout' => 10, 'redirection' => 3, ]); if (is_wp_error($response)) { $results[] = [ 'rule' => 'a11y-page-unreachable', 'file' => $url, 'line' => null, 'severity' => 'WARNING', 'message' => sprintf( /* translators: 1: URL 2: error message */ __('Unable to retrieve page %1$s: %2$s', 'ability'), $url, $response->get_error_message() ), ]; continue; } $code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($code >= 400 || empty($body)) { $results[] = [ 'rule' => 'a11y-page-error-status', 'file' => $url, 'line' => null, 'severity' => 'WARNING', 'message' => sprintf( /* translators: 1: URL 2: HTTP status code */ __('Page %1$s responded with status code %2$d.', 'ability'), $url, $code ), ]; continue; } $results = array_merge( $results, $this->analyze_html($url, $body) ); } $total = count($results); $errors = count(array_filter($results, fn($r) => ($r['severity'] ?? '') === 'ERROR')); $warnings = count(array_filter($results, fn($r) => ($r['severity'] ?? '') === 'WARNING')); return [ 'total' => $total, 'errors' => $errors, 'warnings' => $warnings, 'results' => $results, 'tool_version' => 'ability-php-scanner', 'ruleset_version'=> 'php-heuristics-1', 'files_scanned' => count($urls), ]; } /** * Analyze a single HTML document and extract accessibility issues. * * @param string $url * @param string $html * @return array> */ private function analyze_html(string $url, string $html): array { $results = []; if ('' === trim($html)) { return $results; } $dom = new \DOMDocument(); libxml_use_internal_errors(true); @$dom->loadHTML($html); libxml_clear_errors(); // 1) Images missing or empty alt. $images = $dom->getElementsByTagName('img'); foreach ($images as $img) { $alt = $img->getAttribute('alt'); if ($alt === null || trim($alt) === '') { $results[] = [ 'rule' => 'a11y-img-alt-missing', 'file' => $url, 'line' => null, 'severity' => 'ERROR', 'message' => __('Image tag missing meaningful alt attribute.', 'ability'), ]; } } // 2) Form inputs without labels (very simple heuristic). $inputs = $dom->getElementsByTagName('input'); foreach ($inputs as $input) { $type = strtolower($input->getAttribute('type')); if (in_array($type, ['hidden', 'submit', 'button', 'image'], true)) { continue; } $has_label = false; $id = $input->getAttribute('id'); if ($id) { $labels = $dom->getElementsByTagName('label'); foreach ($labels as $label) { if ($label->getAttribute('for') === $id) { $has_label = true; break; } } } if (!$has_label) { $parent = $input->parentNode; while ($parent) { if ($parent->nodeName === 'label') { $has_label = true; break; } $parent = $parent->parentNode; } } if (!$has_label) { $results[] = [ 'rule' => 'a11y-form-control-no-label', 'file' => $url, 'line' => null, 'severity' => 'ERROR', 'message' => __('Form control appears to have no associated label.', 'ability'), ]; } } // 3) Links with very generic text. $links = $dom->getElementsByTagName('a'); foreach ($links as $link) { $text = trim($link->textContent); if ($text === '') { $results[] = [ 'rule' => 'a11y-link-empty-text', 'file' => $url, 'line' => null, 'severity' => 'ERROR', 'message' => __('Link has no readable text.', 'ability'), ]; continue; } $lower = strtolower($text); $generic = ['click here', 'read more', 'learn more', 'more']; if (in_array($lower, $generic, true)) { $results[] = [ 'rule' => 'a11y-link-generic-text', 'file' => $url, 'line' => null, 'severity' => 'WARNING', 'message' => sprintf( /* translators: 1: link text */ __('Link text "%s" may be too generic. Consider making it more descriptive.', 'ability'), $text ), ]; } } // 4) Buttons without an accessible name. $buttons = $dom->getElementsByTagName('button'); foreach ($buttons as $button) { $label = trim($button->textContent); if ($button->hasAttribute('aria-label')) { $label = trim($button->getAttribute('aria-label')); } if ($label === '') { $results[] = [ 'rule' => 'a11y-button-empty-text', 'file' => $url, 'line' => null, 'severity' => 'ERROR', 'message' => __('Button appears to have no accessible name (text or aria-label).', 'ability'), ]; } } // 5) Basic heading structure checks (missing H1, multiple H1, skipped levels). $heading_nodes = []; foreach (['h1','h2','h3','h4','h5','h6'] as $tag) { $nodeList = $dom->getElementsByTagName($tag); foreach ($nodeList as $node) { $heading_nodes[] = $node; } } usort($heading_nodes, function($a, $b) { // Preserve document order using line attribute if available, fallback to 0. $la = $a->getLineNo(); $lb = $b->getLineNo(); if ($la === $lb) return 0; return ($la < $lb) ? -1 : 1; }); $h1_count = 0; $prev_level = null; foreach ($heading_nodes as $node) { $tag = strtolower($node->nodeName); $level = (int) substr($tag, 1); if ($level === 1) { $h1_count++; } if ($prev_level !== null && $level > $prev_level + 1) { $results[] = [ 'rule' => 'a11y-heading-skip-level', 'file' => $url, 'line' => null, 'severity' => 'WARNING', 'message' => __('Heading levels appear to skip (e.g., H2 directly to H4).', 'ability'), ]; } $prev_level = $level; } if ($h1_count === 0) { $results[] = [ 'rule' => 'a11y-heading-missing-h1', 'file' => $url, 'line' => null, 'severity' => 'WARNING', 'message' => __('No H1 heading was found on this page.', 'ability'), ]; } elseif ($h1_count > 1) { $results[] = [ 'rule' => 'a11y-heading-multiple-h1', 'file' => $url, 'line' => null, 'severity' => 'WARNING', 'message' => __('Multiple H1 headings were found on this page.', 'ability'), ]; } // 6) Positive tabindex values, which can create confusing tab order. $xpath = new \DOMXPath($dom); $nodes_with_tabindex = $xpath->query('//*[@tabindex]'); if ($nodes_with_tabindex instanceof \DOMNodeList) { foreach ($nodes_with_tabindex as $node) { $tabindex = (int) $node->getAttribute('tabindex'); if ($tabindex > 0) { $results[] = [ 'rule' => 'a11y-tabindex-positive', 'file' => $url, 'line' => null, 'severity' => 'WARNING', 'message' => __('Elements with tabindex greater than 0 can cause an unexpected keyboard focus order.', 'ability'), ]; // One warning is enough; avoid spamming. break; } } } // 7) Check for missing or empty HTML lang attribute. $html_nodes = $dom->getElementsByTagName('html'); if ($html_nodes->length > 0) { $html_el = $html_nodes->item(0); $lang = trim($html_el->getAttribute('lang')); if ($lang === '') { $results[] = [ 'rule' => 'a11y-html-lang-missing', 'file' => $url, 'line' => null, 'severity' => 'WARNING', 'message' => __('The element is missing a lang attribute.', 'ability'), ]; } } // 8) Detect CSS that removes focus outlines (possible non-compliance risk). $css_sources = ''; // Collect