settings = AccessIQ_Settings::instance(); add_action( 'wp_ajax_accessiq_scan_page', [ $this, 'ajax_scan_page' ] ); add_action( 'wp_ajax_accessiq_save_results', [ $this, 'ajax_save_results' ] ); add_action( 'wp_ajax_accessiq_get_scans', [ $this, 'ajax_get_scans' ] ); add_action( 'wp_ajax_accessiq_delete_scan', [ $this, 'ajax_delete_scan' ] ); add_action( 'wp_ajax_accessiq_get_score_history', [ $this, 'ajax_get_score_history' ] ); add_action( 'wp_ajax_accessiq_get_element_ranking', [ $this, 'ajax_get_element_ranking' ] ); add_action( 'wp_ajax_accessiq_get_all_issues', [ $this, 'ajax_get_all_issues' ] ); add_action( 'wp_ajax_accessiq_dismiss_issue', [ $this, 'ajax_dismiss_issue' ] ); } /** * AJAX: Initiate a page scan. * The actual DOM scanning happens in the browser (getWCAGChecks() in admin/js/admin.js). * This endpoint validates the request and returns config to the JS. */ public function ajax_scan_page() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_die(); $url = esc_url_raw( wp_unslash( $_POST['url'] ?? '' ) ); if ( ! $url ) { wp_send_json_error( [ 'message' => __( 'Invalid URL.', 'accessiq' ), 'code' => 'invalid_url' ] ); } if ( ! self::is_same_site_url( $url ) ) { wp_send_json_error( [ 'message' => __( 'The free scanner checks pages on your own site only. Enter a URL on this site.', 'accessiq' ), 'code' => 'external_url', ] ); } $level = self::clean_level( $_POST['level'] ?? '' ); $version = self::clean_version( $_POST['version'] ?? '' ); wp_send_json_success( [ 'url' => $url, 'level' => $level, 'version' => $version, 'rules' => AccessIQ_WCAG_Rules::get_by_level( $level, $version ), ] ); } /** * AJAX: Save scan results sent from browser JS after DOM scan. */ public function ajax_save_results() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Unauthorized.', 'accessiq' ) ], 403 ); } global $wpdb; $url = esc_url_raw( wp_unslash( $_POST['url'] ?? '' ) ); $level = sanitize_text_field( wp_unslash( $_POST['level'] ?? 'AA' ) ); if ( ! in_array( $level, array( 'A', 'AA', 'AAA' ), true ) ) { $level = 'AA'; } $version = self::clean_version( $_POST['version'] ?? '' ); $accessiq_issues_raw = isset( $_POST['issues'] ) ? wp_unslash( $_POST['issues'] ) : '[]'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON string; each decoded field sanitized before DB insert. $accessiq_issues_decoded = json_decode( $accessiq_issues_raw, true ); $issues = array(); if ( is_array( $accessiq_issues_decoded ) ) { foreach ( $accessiq_issues_decoded as $accessiq_issue ) { if ( ! is_array( $accessiq_issue ) ) { continue; } $accessiq_lvl = isset( $accessiq_issue['level'] ) ? sanitize_text_field( $accessiq_issue['level'] ) : 'A'; $accessiq_sev = isset( $accessiq_issue['severity'] ) ? sanitize_text_field( $accessiq_issue['severity'] ) : 'error'; $issues[] = array( 'wcag_id' => isset( $accessiq_issue['wcag_id'] ) ? sanitize_text_field( $accessiq_issue['wcag_id'] ) : '', 'level' => in_array( $accessiq_lvl, array( 'A', 'AA', 'AAA' ), true ) ? $accessiq_lvl : 'A', 'severity' => in_array( $accessiq_sev, array( 'error', 'warning' ), true ) ? $accessiq_sev : 'error', 'type' => isset( $accessiq_issue['type'] ) ? sanitize_text_field( $accessiq_issue['type'] ) : '', // Element HTML is kept (tags intact); every output site escapes it. 'element' => isset( $accessiq_issue['element'] ) ? self::sanitize_element( $accessiq_issue['element'] ) : '', 'suggestion' => isset( $accessiq_issue['suggestion'] ) ? sanitize_textarea_field( $accessiq_issue['suggestion'] ) : '', ); } } // Keep only issues that belong to the chosen level / WCAG version. $issues = self::filter_issues( $issues, $level, $version ); // Score is computed server-side so every scan path uses one formula. $score = self::compute_score( count( array_filter( $issues, function ( $i ) { return 'error' === $i['severity']; } ) ), count( array_filter( $issues, function ( $i ) { return 'warning' === $i['severity']; } ) ) ); if ( ! $url ) { wp_send_json_error( [ 'message' => 'Invalid data.' ] ); } // Create a CPT post to store the scan $scan_id = wp_insert_post( [ 'post_type' => ACCESSIQ_SCAN_CPT, 'post_title' => $url, 'post_status' => 'publish', ] ); if ( is_wp_error( $scan_id ) ) { wp_send_json_error( [ 'message' => 'Could not save scan.' ] ); } // Store summary as post meta update_post_meta( $scan_id, '_accessiq_url', $url ); update_post_meta( $scan_id, '_accessiq_score', $score ); update_post_meta( $scan_id, '_accessiq_level', $level ); update_post_meta( $scan_id, '_accessiq_version', $version ); update_post_meta( $scan_id, '_accessiq_total', count( $issues ) ); update_post_meta( $scan_id, '_accessiq_checks', min( 50, absint( $_POST['checks_total'] ?? 0 ) ) ); update_post_meta( $scan_id, '_accessiq_errors', count( array_filter( $issues, fn($i) => $i['severity'] === 'error' ) ) ); update_post_meta( $scan_id, '_accessiq_warnings', count( array_filter( $issues, fn($i) => $i['severity'] === 'warning' ) ) ); // Store individual issues in custom table $table = $wpdb->prefix . 'accessiq_issues'; foreach ( $issues as $issue ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $table, [ 'scan_id' => $scan_id, 'wcag_id' => sanitize_text_field( $issue['wcag_id'] ?? '' ), 'level' => sanitize_text_field( $issue['level'] ?? 'A' ), 'element' => self::sanitize_element( $issue['element'] ?? '' ), 'issue_type' => sanitize_text_field( $issue['type'] ?? '' ), 'severity' => sanitize_text_field( $issue['severity'] ?? 'error' ), 'suggestion' => sanitize_textarea_field( $issue['suggestion'] ?? '' ), ] ); } wp_send_json_success( [ 'scan_id' => $scan_id, 'score' => $score, 'total' => count( $issues ), // add_query_arg, not wp_nonce_url: the latter returns &, which breaks a href set from JSON. 'report_url' => esc_url_raw( add_query_arg( '_wpnonce', wp_create_nonce( 'accessiq_view_report' ), admin_url( 'admin.php?page=accessiq-reports&scan_id=' . intval( $scan_id ) ) ) ), ] ); } /** * AJAX: Get list of past scans for the reports page. */ public function ajax_get_scans() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_die(); $scans = get_posts( [ 'post_type' => ACCESSIQ_SCAN_CPT, 'posts_per_page' => 50, 'orderby' => 'date', 'order' => 'DESC', ] ); $data = array_map( function( $scan ) { return [ 'id' => $scan->ID, 'url' => get_post_meta( $scan->ID, '_accessiq_url', true ), 'score' => get_post_meta( $scan->ID, '_accessiq_score', true ), 'total' => get_post_meta( $scan->ID, '_accessiq_total', true ), 'errors' => get_post_meta( $scan->ID, '_accessiq_errors', true ), 'warnings' => get_post_meta( $scan->ID, '_accessiq_warnings', true ), 'level' => get_post_meta( $scan->ID, '_accessiq_level', true ), 'date' => get_the_date( 'M j, Y — H:i', $scan ), 'report_url' => wp_nonce_url( admin_url( 'admin.php?page=accessiq-reports&scan_id=' . intval( $scan->ID ) ), 'accessiq_view_report' ), ]; }, $scans ); wp_send_json_success( $data ); } /** * AJAX: Delete a scan and its issues. */ public function ajax_delete_scan() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_die(); global $wpdb; $scan_id = intval( $_POST['scan_id'] ?? 0 ); if ( ! $scan_id ) wp_send_json_error(); if ( ACCESSIQ_SCAN_CPT !== get_post_type( $scan_id ) ) { wp_send_json_error( [ 'message' => __( 'Invalid scan.', 'accessiq' ) ], 400 ); } wp_delete_post( $scan_id, true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->delete( $wpdb->prefix . 'accessiq_issues', [ 'scan_id' => $scan_id ], [ '%d' ] ); wp_send_json_success(); } /** * AJAX: Get score history for a specific URL. * * Returns all scans for the given URL ordered by date ASC. */ public function ajax_get_score_history() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'accessiq' ) ), 403 ); } $url = isset( $_POST['url'] ) ? sanitize_text_field( wp_unslash( $_POST['url'] ) ) : ''; if ( ! $url ) { wp_send_json_error( array( 'message' => __( 'Invalid URL.', 'accessiq' ) ) ); } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $scan_rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_date FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id WHERE p.post_type = %s AND p.post_status = 'publish' AND pm.meta_key = '_accessiq_url' AND pm.meta_value = %s ORDER BY p.post_date ASC", ACCESSIQ_SCAN_CPT, $url ) ); $history = array(); foreach ( (array) $scan_rows as $row ) { $history[] = array( 'date' => get_the_date( 'M j, Y', $row->ID ), 'score' => (int) get_post_meta( $row->ID, '_accessiq_score', true ), 'level' => sanitize_text_field( (string) get_post_meta( $row->ID, '_accessiq_level', true ) ?: 'AA' ), 'issues' => (int) get_post_meta( $row->ID, '_accessiq_total', true ), ); } wp_send_json_success( $history ); } /** * AJAX: Element Ranking — stats, top elements, weekly trend. * * Nonce: accessiq_admin_nonce (same as heatmap page). * Input POST: date_range (7|30|90), device_type (all|desktop|mobile|tablet). */ public function ajax_get_element_ranking() { check_ajax_referer( 'accessiq_admin_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'accessiq' ) ), 403 ); } $date_range = isset( $_POST['date_range'] ) ? absint( $_POST['date_range'] ) : 7; $device_type = isset( $_POST['device_type'] ) ? sanitize_text_field( wp_unslash( $_POST['device_type'] ) ) : 'all'; if ( ! in_array( $date_range, array( 7, 30, 90 ), true ) ) { $date_range = 7; } $allowed_devices = array( 'all', 'desktop', 'mobile', 'tablet' ); if ( ! in_array( $device_type, $allowed_devices, true ) ) { $device_type = 'all'; } global $wpdb; $accessiq_clicks_tbl = $wpdb->prefix . 'accessiq_clicks'; $accessiq_date_start = gmdate( 'Y-m-d H:i:s', strtotime( '-' . $date_range . ' days', current_time( 'timestamp' ) ) ); $accessiq_use_device = ( 'all' !== $device_type ); // ── Stats ───────────────────────────────────────────────────────── // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( $accessiq_use_device ) { $accessiq_stats = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS total_clicks, COUNT(DISTINCT session_id) AS unique_sessions, COUNT(DISTINCT TRIM(TRAILING \'/\' FROM page_url)) AS unique_pages FROM `' . esc_sql( $accessiq_clicks_tbl ) . '` WHERE created_at >= %s AND device_type = %s', $accessiq_date_start, $device_type ) ); } else { $accessiq_stats = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS total_clicks, COUNT(DISTINCT session_id) AS unique_sessions, COUNT(DISTINCT TRIM(TRAILING \'/\' FROM page_url)) AS unique_pages FROM `' . esc_sql( $accessiq_clicks_tbl ) . '` WHERE created_at >= %s', $accessiq_date_start ) ); } $accessiq_total = isset( $accessiq_stats->total_clicks ) ? (int) $accessiq_stats->total_clicks : 0; $accessiq_unique = isset( $accessiq_stats->unique_sessions ) ? (int) $accessiq_stats->unique_sessions : 0; $accessiq_pages = isset( $accessiq_stats->unique_pages ) ? max( 1, (int) $accessiq_stats->unique_pages ) : 1; $accessiq_avg_pp = $accessiq_total > 0 ? round( $accessiq_total / $accessiq_pages, 1 ) : 0; // ── Avg accessibility score (latest 10 scans) ────────────────────── $accessiq_scan_posts = get_posts( array( 'post_type' => ACCESSIQ_SCAN_CPT, 'posts_per_page' => 10, 'orderby' => 'date', 'order' => 'DESC', ) ); $accessiq_score_sum = 0; foreach ( $accessiq_scan_posts as $accessiq_sp ) { $accessiq_score_sum += (int) get_post_meta( $accessiq_sp->ID, '_accessiq_score', true ); } $accessiq_avg_score = count( $accessiq_scan_posts ) > 0 ? round( $accessiq_score_sum / count( $accessiq_scan_posts ) ) : 0; // ── Top elements ─────────────────────────────────────────────────── if ( $accessiq_use_device ) { $accessiq_els_raw = $wpdb->get_results( $wpdb->prepare( 'SELECT element, COUNT(*) AS count FROM `' . esc_sql( $accessiq_clicks_tbl ) . '` WHERE created_at >= %s AND element != \'\' AND device_type = %s GROUP BY element ORDER BY count DESC LIMIT 20', $accessiq_date_start, $device_type ) ); } else { $accessiq_els_raw = $wpdb->get_results( $wpdb->prepare( 'SELECT element, COUNT(*) AS count FROM `' . esc_sql( $accessiq_clicks_tbl ) . '` WHERE created_at >= %s AND element != \'\' GROUP BY element ORDER BY count DESC LIMIT 20', $accessiq_date_start ) ); } // ── Trend (by day of week) ───────────────────────────────────────── if ( $accessiq_use_device ) { $accessiq_trend_raw = $wpdb->get_results( $wpdb->prepare( 'SELECT DAYOFWEEK(created_at) AS dow, COUNT(*) AS count FROM `' . esc_sql( $accessiq_clicks_tbl ) . '` WHERE created_at >= %s AND device_type = %s GROUP BY DAYOFWEEK(created_at) ORDER BY dow ASC', $accessiq_date_start, $device_type ) ); } else { $accessiq_trend_raw = $wpdb->get_results( $wpdb->prepare( 'SELECT DAYOFWEEK(created_at) AS dow, COUNT(*) AS count FROM `' . esc_sql( $accessiq_clicks_tbl ) . '` WHERE created_at >= %s GROUP BY DAYOFWEEK(created_at) ORDER BY dow ASC', $accessiq_date_start ) ); } // phpcs:enable // MySQL DAYOFWEEK: 1=Sun, 2=Mon … 7=Sat. $accessiq_day_map = array( 1 => 'Sun', 2 => 'Mon', 3 => 'Tue', 4 => 'Wed', 5 => 'Thu', 6 => 'Fri', 7 => 'Sat' ); $accessiq_trend_map = array(); foreach ( (array) $accessiq_trend_raw as $accessiq_tr ) { $accessiq_trend_map[ (int) $accessiq_tr->dow ] = (int) $accessiq_tr->count; } $accessiq_trend = array(); for ( $accessiq_d = 1; $accessiq_d <= 7; $accessiq_d++ ) { $accessiq_trend[] = array( 'day' => $accessiq_day_map[ $accessiq_d ], 'count' => isset( $accessiq_trend_map[ $accessiq_d ] ) ? $accessiq_trend_map[ $accessiq_d ] : 0, ); } // ── Build element list with breadcrumb + severity ────────────────── $accessiq_elements = array(); $accessiq_rank = 0; foreach ( (array) $accessiq_els_raw as $accessiq_el ) { ++$accessiq_rank; $accessiq_sel = sanitize_text_field( (string) $accessiq_el->element ); $accessiq_cnt = (int) $accessiq_el->count; $accessiq_pct = $accessiq_total > 0 ? round( ( $accessiq_cnt / $accessiq_total ) * 100, 1 ) : 0.0; $accessiq_elements[] = array( 'rank' => $accessiq_rank, 'selector' => $accessiq_sel, 'breadcrumb' => self::er_breadcrumb( $accessiq_sel ), 'count' => $accessiq_cnt, 'pct' => $accessiq_pct, 'severity' => self::er_severity( $accessiq_sel, $accessiq_rank ), ); } wp_send_json_success( array( 'stats' => array( 'total_clicks' => $accessiq_total, 'unique_interactors' => $accessiq_unique, 'avg_per_page' => $accessiq_avg_pp, 'avg_score' => $accessiq_avg_score, ), 'elements' => $accessiq_elements, 'trend' => $accessiq_trend, ) ); } /** * Generate human-readable breadcrumb from CSS selector. * * @param string $selector Raw CSS selector. * @return string e.g. "Button › .cta-primary" */ private static function er_breadcrumb( $selector ) { $accessiq_sel = trim( $selector ); if ( preg_match( '/^([a-z][a-z0-9]*)(.*)$/i', $accessiq_sel, $accessiq_m ) ) { $accessiq_tag = strtolower( $accessiq_m[1] ); $accessiq_rest = isset( $accessiq_m[2] ) ? trim( $accessiq_m[2] ) : ''; $accessiq_labels = array( 'button' => 'Button', 'a' => 'Anchor', 'input' => 'Input', 'select' => 'Select', 'textarea' => 'Textarea', 'form' => 'Form', 'nav' => 'Nav', 'header' => 'Header', 'footer' => 'Footer', 'main' => 'Main', 'div' => 'Div', 'span' => 'Span', 'img' => 'Image', 'label' => 'Label', 'li' => 'Item', 'ul' => 'List', 'section' => 'Section', 'article' => 'Article', 'h1' => 'Heading', 'h2' => 'Heading', 'h3' => 'Heading', ); $accessiq_label = isset( $accessiq_labels[ $accessiq_tag ] ) ? $accessiq_labels[ $accessiq_tag ] : ucfirst( $accessiq_tag ); return empty( $accessiq_rest ) ? $accessiq_label : $accessiq_label . ' › ' . $accessiq_rest; } return $selector; } /** * Determine accessibility severity from CSS selector + rank. * * @param string $selector CSS selector. * @param int $rank 1-based element rank. * @return string 'CRITICAL'|'WARNING'|'PASS' */ private static function er_severity( $selector, $rank ) { $accessiq_s = strtolower( trim( $selector ) ); // Input without aria/label = screen-reader barrier → critical. if ( strpos( $accessiq_s, 'input' ) !== false && strpos( $accessiq_s, 'aria' ) === false && strpos( $accessiq_s, 'label' ) === false ) { return 'CRITICAL'; } // High-traffic anchor without aria → potential contrast/purpose issue. if ( $rank <= 10 ) { $accessiq_is_link = ( 'a' === $accessiq_s || 0 === strpos( $accessiq_s, 'a.' ) || 0 === strpos( $accessiq_s, 'a#' ) || 0 === strpos( $accessiq_s, 'a[' ) ); if ( $accessiq_is_link && strpos( $accessiq_s, 'aria' ) === false ) { return 'WARNING'; } } return 'PASS'; } /** * Alias for hub / remote-triggered scans. * * @param string $url The URL to scan. * @return array|WP_Error Scan result array or WP_Error on failure. */ public static function run_scan( $url ) { return self::run_scan_for_schedule( $url ); } /** * Server-side scan entry point for Pro scheduled scans. * * Fetches the page via wp_remote_get, runs basic WCAG checks, * saves results as a scan CPT, and returns score data. * * @param string $url The URL to scan. * @return array|WP_Error Scan result array or WP_Error on failure. */ public static function run_scan_for_schedule( $url, $level = null, $version = null ) { $url = esc_url_raw( $url ); // Allow this site's own host even if it resolves to a private/loopback IP (local dev, intranet). $allow_own = function ( $external, $host ) { $home = wp_parse_url( home_url(), PHP_URL_HOST ); return ( $home && 0 === strcasecmp( $host, $home ) ) ? true : $external; }; add_filter( 'http_request_host_is_external', $allow_own, 10, 2 ); if ( ! $url || ! wp_http_validate_url( $url ) ) { remove_filter( 'http_request_host_is_external', $allow_own, 10 ); return new WP_Error( 'invalid_url', 'Invalid or disallowed URL.' ); } // wp_safe_remote_get blocks internal hosts (SSRF); sslverify filterable for local dev only. $response = wp_safe_remote_get( $url, array( 'timeout' => 30, 'sslverify' => (bool) apply_filters( 'accessiq_scan_sslverify', true ), ) ); remove_filter( 'http_request_host_is_external', $allow_own, 10 ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 400 ) { return new WP_Error( 'http_error', sprintf( 'HTTP %d for %s', $code, $url ) ); } $html = wp_remote_retrieve_body( $response ); if ( empty( $html ) ) { return new WP_Error( 'empty_body', 'Empty response body.' ); } return self::save_scan_from_html( $url, $html, array(), $level, $version ); } /** * Analyse HTML that is already in hand (e.g. a draft rendered server-side), save it as a scan * and return the same shape as run_scan_for_schedule(). * * @param string $url URL the scan is recorded against. * @param string $html Full HTML document. * @param array $meta Optional extra post meta (key => value), e.g. _accessiq_source. * @return array|WP_Error */ public static function save_scan_from_html( $url, $html, $meta = array(), $level = null, $version = null ) { $settings = AccessIQ_Settings::instance(); // Optional overrides (setup wizard); default is the saved scan target. $level = self::clean_level( null !== $level ? $level : $settings->get( 'scan_level', 'AA' ) ); $version = self::clean_version( null !== $version ? $version : $settings->get( 'scan_wcag_version', '2.2' ) ); $issues = self::analyse_html( $html, $level, $version ); $errors = count( array_filter( $issues, function ( $i ) { return $i['severity'] === 'error'; } ) ); $warnings = count( $issues ) - $errors; $total = count( $issues ); $score = self::compute_score( $errors, $warnings ); global $wpdb; $scan_id = wp_insert_post( array( 'post_type' => ACCESSIQ_SCAN_CPT, 'post_title' => $url, 'post_status' => 'publish', ) ); if ( is_wp_error( $scan_id ) ) { return $scan_id; } update_post_meta( $scan_id, '_accessiq_url', $url ); update_post_meta( $scan_id, '_accessiq_score', $score ); update_post_meta( $scan_id, '_accessiq_level', $level ); update_post_meta( $scan_id, '_accessiq_version', $version ); update_post_meta( $scan_id, '_accessiq_total', $total ); update_post_meta( $scan_id, '_accessiq_checks', ( '2.2' === $version && in_array( $level, array( 'AA', 'AAA' ), true ) ) ? 7 : 4 ); update_post_meta( $scan_id, '_accessiq_errors', $errors ); update_post_meta( $scan_id, '_accessiq_warnings', $warnings ); foreach ( (array) $meta as $meta_key => $meta_value ) { update_post_meta( $scan_id, sanitize_key( $meta_key ), sanitize_text_field( (string) $meta_value ) ); } $table = $wpdb->prefix . 'accessiq_issues'; foreach ( $issues as $issue ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $table, array( 'scan_id' => $scan_id, 'wcag_id' => sanitize_text_field( $issue['wcag_id'] ), 'level' => sanitize_text_field( $issue['level'] ), 'element' => self::sanitize_element( $issue['element'] ), 'issue_type' => sanitize_text_field( $issue['type'] ), 'suggestion' => sanitize_textarea_field( $issue['suggestion'] ), 'severity' => sanitize_text_field( $issue['severity'] ), ) ); } return array( 'scan_id' => $scan_id, 'score' => $score, 'issues_count' => $total, ); } /** * Basic server-side HTML analysis for WCAG issues. * * @param string $html Raw HTML content. * @param string $level WCAG level (A, AA, or AAA). * @return array Array of issue arrays. */ private static function analyse_html( $html, $level = 'AA', $version = '2.2' ) { $issues = array(); // A UTF-8 BOM (or stray whitespace) before makes libxml drop attributes // such as lang, producing false "missing lang" errors. $html = preg_replace( '/^(?:\xEF\xBB\xBF|\s)+/', '', (string) $html ); libxml_use_internal_errors( true ); $doc = new DOMDocument(); $doc->loadHTML( '' . $html, LIBXML_NOERROR | LIBXML_NOWARNING ); libxml_clear_errors(); $xpath = new DOMXPath( $doc ); $imgs = $doc->getElementsByTagName( 'img' ); foreach ( $imgs as $img ) { // alt="" is valid (decorative). Flag only images with no alt attribute at all, // unless explicitly presentational / hidden from assistive tech. $role = strtolower( $img->getAttribute( 'role' ) ); if ( ! $img->hasAttribute( 'alt' ) && 'presentation' !== $role && 'none' !== $role && 'true' !== $img->getAttribute( 'aria-hidden' ) && '' === trim( $img->getAttribute( 'aria-label' ) . $img->getAttribute( 'aria-labelledby' ) ) ) { $issues[] = array( 'wcag_id' => '1.1.1', 'level' => 'A', 'element' => self::truncate_element( $doc->saveHTML( $img ) ), 'type' => 'Missing alt text', 'suggestion' => 'Add descriptive text or aria-label to all images.', 'severity' => 'error', ); } } $links = $doc->getElementsByTagName( 'a' ); foreach ( $links as $link ) { $text = trim( $link->textContent ); if ( $text === '' && ! $link->getAttribute( 'aria-label' ) && ! $link->getAttribute( 'aria-labelledby' ) ) { $issues[] = array( 'wcag_id' => '2.4.4', 'level' => 'A', 'element' => self::truncate_element( $doc->saveHTML( $link ) ), 'type' => 'Empty link', 'suggestion' => 'Add descriptive text or aria-label to all links.', 'severity' => 'error', ); } } // Document order matters: a single XPath union returns nodes in document order. $headings = array(); $h_nodes = $xpath->query( '//h1 | //h2 | //h3 | //h4 | //h5 | //h6' ); if ( $h_nodes ) { foreach ( $h_nodes as $h ) { $headings[] = (int) substr( $h->nodeName, 1 ); } } for ( $i = 1; $i < count( $headings ); $i++ ) { if ( $headings[ $i ] > $headings[ $i - 1 ] + 1 ) { $issues[] = array( 'wcag_id' => '1.3.1', 'level' => 'A', 'element' => sprintf( 'h%d → h%d (skipped)', $headings[ $i - 1 ], $headings[ $i ] ), 'type' => 'Heading level skipped', 'suggestion' => 'Use heading levels in sequential order without skipping.', 'severity' => 'warning', ); break; } } // 3.1.1 Language of Page is Level A — checked at every scan level. $html_tag = $doc->getElementsByTagName( 'html' )->item( 0 ); if ( $html_tag && '' === trim( $html_tag->getAttribute( 'lang' ) . $html_tag->getAttribute( 'xml:lang' ) ) ) { $issues[] = array( 'wcag_id' => '3.1.1', 'level' => 'A', 'element' => '', 'type' => 'Missing lang attribute', 'suggestion' => 'Add a lang attribute to the element.', 'severity' => 'error', ); } // WCAG 2.2 AA checks (2.4.11, 2.5.7, 2.5.8): only when the target is WCAG 2.2, at level AA or AAA. if ( '2.2' === $version && in_array( $level, array( 'AA', 'AAA' ), true ) ) { // ── WCAG 2.2: 2.4.11 Focus Not Obscured (Minimum) ───────────── $sticky_elements = $xpath->query( '//*[contains(@style,"position:sticky") or contains(@style,"position: sticky") or contains(@style,"position:fixed") or contains(@style,"position: fixed")]' ); if ( $sticky_elements && $sticky_elements->length > 0 ) { $issues[] = array( 'wcag_id' => '2.4.11', 'level' => 'AA', 'element' => self::truncate_element( $doc->saveHTML( $sticky_elements->item( 0 ) ) ), 'type' => 'Possible focus obstruction', 'suggestion' => 'Ensure sticky/fixed elements do not fully cover focused components. Test keyboard navigation.', 'severity' => 'warning', ); } // ── WCAG 2.2: 2.5.7 Dragging Movements ─────────────────────── $draggable = $xpath->query( '//*[@draggable="true"]' ); if ( $draggable ) { foreach ( $draggable as $el ) { $has_alternative = $el->getAttribute( 'aria-grabbed' ) !== '' || $el->getAttribute( 'aria-dropeffect' ) !== '' || $el->getAttribute( 'role' ) !== ''; if ( ! $has_alternative ) { $issues[] = array( 'wcag_id' => '2.5.7', 'level' => 'AA', 'element' => self::truncate_element( $doc->saveHTML( $el ) ), 'type' => 'Draggable element without pointer alternative', 'suggestion' => 'Provide a single-pointer alternative (e.g. click-to-select + click-to-drop) for all draggable elements.', 'severity' => 'warning', ); } } } // ── WCAG 2.2: 2.5.8 Target Size (Minimum) ──────────────────── $interactive = $xpath->query( '//button | //a | //input | //select' ); if ( $interactive ) { foreach ( $interactive as $el ) { $style = $el->getAttribute( 'style' ); if ( preg_match( '/(?:width|height)\s*:\s*([0-9]+)px/i', $style, $matches ) ) { if ( (int) $matches[1] < 24 ) { $issues[] = array( 'wcag_id' => '2.5.8', 'level' => 'AA', 'element' => self::truncate_element( $doc->saveHTML( $el ) ), 'type' => 'Target size too small', 'suggestion' => 'Interactive targets should be at least 24×24 CSS pixels. Increase size or add padding.', 'severity' => 'warning', ); } } } } } return $issues; } /** * True when $url's host (and scheme http/https) matches this site's host. */ public static function is_same_site_url( $url ) { $u = wp_parse_url( $url ); $h = wp_parse_url( home_url() ); if ( empty( $u['host'] ) || empty( $h['host'] ) || empty( $u['scheme'] ) ) { return false; } return in_array( strtolower( $u['scheme'] ), array( 'http', 'https' ), true ) && 0 === strcasecmp( $u['host'], $h['host'] ); } /** * Validate a WCAG level; falls back to the configured one. */ public static function clean_level( $raw ) { $level = strtoupper( sanitize_text_field( wp_unslash( (string) $raw ) ) ); if ( in_array( $level, array( 'A', 'AA', 'AAA' ), true ) ) { return $level; } $configured = AccessIQ_Settings::instance()->get( 'scan_level', 'AA' ); return in_array( $configured, array( 'A', 'AA', 'AAA' ), true ) ? $configured : 'AA'; } /** * Validate a WCAG version (2.1 / 2.2); falls back to the configured one. */ public static function clean_version( $raw ) { $version = sanitize_text_field( wp_unslash( (string) $raw ) ); if ( in_array( $version, array( '2.1', '2.2' ), true ) ) { return $version; } $configured = (string) AccessIQ_Settings::instance()->get( 'scan_wcag_version', '2.2' ); return in_array( $configured, array( '2.1', '2.2' ), true ) ? $configured : '2.2'; } /** * Drop issues above the chosen level and WCAG 2.2-only issues when the target is 2.1. * * @param array $issues Issue arrays (wcag_id, level, ...). * @param string $level A | AA | AAA. * @param string $version 2.1 | 2.2. * @return array */ public static function filter_issues( array $issues, $level, $version ) { $rank = array( 'A' => 1, 'AA' => 2, 'AAA' => 3 ); $max = $rank[ $level ] ?? 2; $rules = AccessIQ_WCAG_Rules::get_all(); return array_values( array_filter( $issues, function ( $issue ) use ( $rank, $max, $rules, $version ) { $lvl = $rank[ $issue['level'] ?? 'A' ] ?? 1; if ( $lvl > $max ) { return false; } $rule = $rules[ $issue['wcag_id'] ?? '' ] ?? null; return ! ( '2.1' === $version && $rule && isset( $rule['version'] ) && '2.2' === $rule['version'] ); } ) ); } /** * Human label for a scan's target standard, e.g. "WCAG 2.2 AA" (older scans without the meta read as 2.1). */ public static function standard_label( $scan_id ) { $version = (string) get_post_meta( $scan_id, '_accessiq_version', true ); $level = (string) get_post_meta( $scan_id, '_accessiq_level', true ); return 'WCAG ' . ( in_array( $version, array( '2.1', '2.2' ), true ) ? $version : '2.1' ) . ' ' . ( $level ?: 'AA' ); } /** * Single scoring formula for all scan paths (browser + server). */ public static function compute_score( $errors, $warnings ) { return (int) max( 0, 100 - ( (int) $errors * self::ERROR_WEIGHT + (int) $warnings * self::WARNING_WEIGHT ) ); } /** * One-line, translatable explanation of the score, built from the same weights as compute_score(). */ public static function score_explanation() { return sprintf( /* translators: 1: points taken off per error, 2: points taken off per warning */ __( 'Score = 100 minus %1$d per error and %2$d per warning (minimum 0). It reflects only the automated checks that ran, not full WCAG conformance.', 'accessiq' ), self::ERROR_WEIGHT, self::WARNING_WEIGHT ); } /** * Sanitize an element snippet for storage. Keeps tags (they are escaped on output); * strips invalid UTF-8 / control chars and caps length. */ public static function sanitize_element( $html ) { $html = wp_check_invalid_utf8( (string) $html ); $html = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $html ); $html = trim( preg_replace( '/\s+/', ' ', $html ) ); return function_exists( 'mb_substr' ) ? mb_substr( $html, 0, 500 ) : substr( $html, 0, 500 ); } private static function truncate_element( $html ) { $html = trim( preg_replace( '/\s+/', ' ', $html ) ); return strlen( $html ) > 120 ? substr( $html, 0, 117 ) . '...' : $html; } /** * Get issues for a specific scan (used by report page). */ public static function get_scan_issues( $scan_id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}accessiq_issues WHERE scan_id = %d ORDER BY level ASC, wcag_id ASC", $scan_id ) ); } /** * AJAX: Fetch all issues across all scans with filters + pagination. */ public function ajax_get_all_issues() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => 'Unauthorized.' ), 403 ); } global $wpdb; $issues_tbl = $wpdb->prefix . 'accessiq_issues'; // ── Sanitize inputs ──────────────────────────────────────── $severity_raw = isset( $_POST['severity'] ) ? sanitize_text_field( wp_unslash( $_POST['severity'] ) ) : 'all'; $severity = in_array( $severity_raw, array( 'error', 'warning', 'all' ), true ) ? $severity_raw : 'all'; $wcag_id = isset( $_POST['wcag_id'] ) ? sanitize_text_field( wp_unslash( $_POST['wcag_id'] ) ) : ''; $page_url = isset( $_POST['page_url'] ) ? esc_url_raw( wp_unslash( $_POST['page_url'] ) ) : ''; $show_dimissed = ! empty( $_POST['show_dismissed'] ); $paged = max( 1, absint( $_POST['paged'] ?? 1 ) ); $per_page = 20; $offset = ( $paged - 1 ) * $per_page; // ── Build dynamic WHERE ──────────────────────────────────── $where_clauses = array(); $where_params = array(); if ( 'all' !== $severity ) { $where_clauses[] = 'i.severity = %s'; $where_params[] = $severity; } if ( $wcag_id ) { $where_clauses[] = 'i.wcag_id = %s'; $where_params[] = $wcag_id; } if ( $page_url ) { $where_clauses[] = 'pm_url.meta_value = %s'; $where_params[] = $page_url; } if ( ! $show_dimissed ) { $where_clauses[] = 'i.dismissed_at IS NULL'; } // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $where_sql = $where_clauses ? 'AND ' . implode( ' AND ', $where_clauses ) : ''; $base_from = "FROM `{$issues_tbl}` i LEFT JOIN {$wpdb->postmeta} pm_url ON pm_url.post_id = i.scan_id AND pm_url.meta_key = '_accessiq_url' LEFT JOIN {$wpdb->postmeta} pm_score ON pm_score.post_id = i.scan_id AND pm_score.meta_key = '_accessiq_score' WHERE 1=1 {$where_sql}"; $data_sql = "SELECT i.id, i.scan_id, i.wcag_id, i.level, i.element, i.issue_type, i.severity, i.suggestion, i.created_at, i.dismissed_at, i.dismiss_note, pm_url.meta_value AS page_url, pm_score.meta_value AS page_score {$base_from} ORDER BY i.severity ASC, i.created_at DESC LIMIT %d OFFSET %d"; $count_sql = "SELECT COUNT(*) {$base_from}"; // Stats SQL (all records, ignore show_dismissed / other filters for summary counts). $stats_sql = "SELECT SUM( CASE WHEN i.dismissed_at IS NULL THEN 1 ELSE 0 END ) AS active, SUM( CASE WHEN i.dismissed_at IS NULL AND i.severity = 'error' THEN 1 ELSE 0 END ) AS errors, SUM( CASE WHEN i.dismissed_at IS NULL AND i.severity = 'warning' THEN 1 ELSE 0 END ) AS warnings, SUM( CASE WHEN i.dismissed_at IS NOT NULL THEN 1 ELSE 0 END ) AS dismissed FROM `{$issues_tbl}` i"; // phpcs:enable // ── Execute queries ──────────────────────────────────────── $data_params = array_merge( $where_params, array( $per_page, $offset ) ); $count_params = $where_params; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( ! empty( $data_params ) ) { $rows = $wpdb->get_results( $wpdb->prepare( $data_sql, $data_params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } else { $rows = $wpdb->get_results( $data_sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } if ( ! empty( $count_params ) ) { $total = (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $count_params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } else { $total = (int) $wpdb->get_var( $count_sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } $stats = $wpdb->get_row( $stats_sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared // phpcs:enable wp_send_json_success( array( 'issues' => $rows ?: array(), 'total' => $total, 'pages' => $total > 0 ? (int) ceil( $total / $per_page ) : 0, 'stats' => array( 'active' => (int) ( $stats['active'] ?? 0 ), 'errors' => (int) ( $stats['errors'] ?? 0 ), 'warnings' => (int) ( $stats['warnings'] ?? 0 ), 'dismissed' => (int) ( $stats['dismissed'] ?? 0 ), ), ) ); } /** * AJAX: Dismiss or restore a single issue. */ public function ajax_dismiss_issue() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => 'Unauthorized.' ), 403 ); } global $wpdb; $issue_id = absint( $_POST['issue_id'] ?? 0 ); $note = sanitize_text_field( wp_unslash( $_POST['note'] ?? '' ) ); $restore = ! empty( $_POST['restore'] ); if ( ! $issue_id ) { wp_send_json_error( array( 'message' => 'Invalid issue ID.' ) ); } $issues_tbl = $wpdb->prefix . 'accessiq_issues'; if ( $restore ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "UPDATE `{$issues_tbl}` SET dismissed_at = NULL, dismiss_note = NULL WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $issue_id ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $issues_tbl, array( 'dismissed_at' => current_time( 'mysql' ), 'dismiss_note' => $note, ), array( 'id' => $issue_id ), array( '%s', '%s' ), array( '%d' ) ); } wp_send_json_success(); } }