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' ] ); add_action( 'wp_ajax_accessiq_scheduled_scan_page', [ $this, 'ajax_scheduled_scan_page' ] ); add_action( 'wp_ajax_nopriv_accessiq_scheduled_scan_page', [ $this, 'ajax_scheduled_scan_page' ] ); } /** * AJAX: Initiate a page scan. * The actual DOM scanning happens in the browser (scanner-engine.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' ) ] ); } wp_send_json_success( [ 'url' => $url, 'level' => $this->settings->get( 'scan_level', 'AA' ), 'rules' => AccessIQ_WCAG_Rules::get_by_level( $this->settings->get( 'scan_level', 'AA' ) ), ] ); } /** * 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'] ?? '' ) ); $score = isset( $_POST['score'] ) ? absint( sanitize_text_field( wp_unslash( $_POST['score'] ) ) ) : 0; $level = sanitize_text_field( wp_unslash( $_POST['level'] ?? 'AA' ) ); $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; } $issues[] = array( 'wcag_id' => isset( $accessiq_issue['wcag_id'] ) ? sanitize_text_field( $accessiq_issue['wcag_id'] ) : '', 'level' => isset( $accessiq_issue['level'] ) ? sanitize_text_field( $accessiq_issue['level'] ) : 'A', 'severity' => isset( $accessiq_issue['severity'] ) ? sanitize_text_field( $accessiq_issue['severity'] ) : '', 'type' => isset( $accessiq_issue['type'] ) ? sanitize_text_field( $accessiq_issue['type'] ) : '', 'element' => isset( $accessiq_issue['element'] ) ? sanitize_textarea_field( $accessiq_issue['element'] ) : '', 'suggestion' => isset( $accessiq_issue['suggestion'] ) ? sanitize_textarea_field( $accessiq_issue['suggestion'] ) : '', ); } } 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_total', count( $issues ) ); 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' => sanitize_text_field( $issue['element'] ?? '' ), 'issue_type' => sanitize_text_field( $issue['type'] ?? '' ), 'suggestion' => sanitize_textarea_field( $issue['suggestion'] ?? '' ), ] ); } wp_send_json_success( [ 'scan_id' => $scan_id, 'score' => $score, 'total' => count( $issues ), 'report_url' => wp_nonce_url( admin_url( 'admin.php?page=accessiq-reports&scan_id=' . intval( $scan_id ) ), 'accessiq_view_report' ), ] ); } /** * 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(); 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' ) ); $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'; } /** * 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 ) { $response = wp_remote_get( $url, array( 'timeout' => 30, 'sslverify' => false ) ); 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.' ); } $settings = AccessIQ_Settings::instance(); $level = $settings->get( 'scan_level', 'AA' ); $issues = self::analyse_html( $html, $level ); $errors = count( array_filter( $issues, function ( $i ) { return $i['severity'] === 'error'; } ) ); $warnings = count( $issues ) - $errors; $total = count( $issues ); $score = $total === 0 ? 100 : max( 0, 100 - ( $errors * 10 + $warnings * 3 ) ); 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_total', $total ); update_post_meta( $scan_id, '_accessiq_errors', $errors ); update_post_meta( $scan_id, '_accessiq_warnings', $warnings ); $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' => sanitize_text_field( $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 or AA). * @return array Array of issue arrays. */ private static function analyse_html( $html, $level = 'AA' ) { $issues = array(); 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 = $img->getAttribute( 'alt' ); if ( $alt === '' && ! $img->hasAttribute( 'alt' ) || ( $img->hasAttribute( 'alt' ) && trim( $alt ) === '' && ! $img->hasAttribute( 'role' ) ) ) { $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', ); } } $headings = array(); for ( $i = 1; $i <= 6; $i++ ) { foreach ( $doc->getElementsByTagName( 'h' . $i ) as $h ) { $headings[] = $i; } } 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; } } if ( 'AA' === $level ) { $html_tag = $doc->getElementsByTagName( 'html' )->item( 0 ); if ( $html_tag && ! $html_tag->getAttribute( 'lang' ) ) { $issues[] = array( 'wcag_id' => '3.1.1', 'level' => 'AA', 'element' => '', 'type' => 'Missing lang attribute', 'suggestion' => 'Add a lang attribute to the element.', 'severity' => 'error', ); } // ── 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; } /** * AJAX: Run a scheduled server-side scan for a single URL. * Called internally by the cron via wp_remote_post (fire-and-forget). * Accepts both logged-in and non-logged-in requests; verifies nonce * and that the URL belongs to this site. */ public function ajax_scheduled_scan_page() { check_ajax_referer( 'accessiq_nonce', 'nonce' ); $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''; // URL must belong to this site. if ( ! $url || strpos( $url, home_url() ) !== 0 ) { wp_send_json_error( array( 'message' => 'Invalid URL.' ), 400 ); } $result = self::run_scan_for_schedule( $url ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } wp_send_json_success( $result ); } 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(); } }