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' ] ); } /** * 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 ( ! isset( $_POST['accessiq_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['accessiq_nonce'] ) ), 'accessiq_save_scan' ) ) { wp_send_json_error( [ 'message' => __( 'Security check failed.', 'accessiq' ) ], 403 ); } 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(); $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', ); } } return $issues; } 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 ) ); } }