emptyResult(__('Invalid scan path. Could not resolve path.', 'ability')); } $wp_root = rtrim($wp_path, DIRECTORY_SEPARATOR); if ($real_path !== $wp_root && strpos($real_path, $wp_root . DIRECTORY_SEPARATOR) !== 0) { return $this->emptyResult(__('Invalid scan path. Path must be within WordPress installation.', 'ability')); } if (!class_exists('\DOMDocument') || !class_exists('\DOMXPath')) { return $this->emptyResult(__('ABILITY scanning requires the PHP DOM extension. Ask the site host to enable the standard DOM/XML extension, then retry the scan.', 'ability')); } $is_pro = function_exists('ability_is_pro') ? ability_is_pro() : false; $settings = class_exists('ABILITY\Config') ? \ABILITY\Config::get_settings() : []; $tier_limit = class_exists('ABILITY\Config') ? \ABILITY\Config::get_scan_limit() : ($is_pro ? 999 : 5); $requested_limit = isset($settings['max_files_per_scan']) ? absint($settings['max_files_per_scan']) : $tier_limit; $max_files = max(1, min($tier_limit, $requested_limit ?: $tier_limit)); $daily_limit = class_exists('ABILITY\Config') ? \ABILITY\Config::get_free_scan_limit() : 5; $daily_scans = 0; $daily_key = ''; if (!$is_pro) { $today = function_exists('wp_date') ? wp_date('Y-m-d') : date('Y-m-d'); $daily_key = 'ability_daily_scans_' . $today; $daily_scans = get_transient($daily_key); if ($daily_scans === false) $daily_scans = 0; $daily_scans = max(0, absint($daily_scans)); if ($daily_scans >= $daily_limit) { return [ 'total' => 0, 'errors' => 0, 'warnings' => 0, 'results' => [], 'files_scanned' => 0, 'max_files' => $max_files, 'daily_limit' => $daily_limit, 'daily_remaining' => 0, 'error' => sprintf( __('Daily scan limit reached (%d pages/day).', 'ability'), $daily_limit ), ]; } // Do not allow a single request to exceed the remaining daily quota. $max_files = min($max_files, max(0, $daily_limit - $daily_scans)); } // 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); $files_scanned = isset($scan_results['files_scanned']) ? absint($scan_results['files_scanned']) : 0; $daily_remaining = null; if (!$is_pro) { set_transient($daily_key, min($daily_limit, $daily_scans + $files_scanned), DAY_IN_SECONDS); $daily_remaining = max(0, $daily_limit - ($daily_scans + $files_scanned)); } 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' => $files_scanned, 'max_files' => $max_files, 'daily_limit' => $is_pro ? null : $daily_limit, 'daily_remaining'=> $daily_remaining, ]; } 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(); $previous_libxml_state = libxml_use_internal_errors(true); @$dom->loadHTML($html); libxml_clear_errors(); libxml_use_internal_errors($previous_libxml_state); // 1) Images missing an alt attribute. An explicitly empty alt value can be // correct for decorative images, so this heuristic must not flag it by itself. $images = $dom->getElementsByTagName('img'); foreach ($images as $img) { if (!$img->hasAttribute('alt')) { $results[] = [ 'rule' => 'a11y-img-alt-missing', 'file' => $url, 'line' => null, 'severity' => 'ERROR', 'message' => __('Image is missing an alt attribute. Review whether it needs a text alternative or an intentionally empty alt value.', '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'); // aria-label / aria-labelledby can provide a programmatic name even // when no HTML