$query_args Base get_posts() arguments. Supply * a stable order for safe paging. * @param int $batch_size Maximum IDs fetched per query. * @return Generator */ public static function post_ids_in_batches( array $query_args, int $batch_size = 100 ): Generator { $batch_size = max( 1, $batch_size ); $paged = 1; do { $post_ids = get_posts( array_merge( $query_args, array( 'posts_per_page' => $batch_size, 'paged' => $paged, 'fields' => 'ids', 'no_found_rows' => true, ) ) ); foreach ( $post_ids as $post_id ) { yield (int) $post_id; } ++$paged; } while ( ! empty( $post_ids ) ); } /** * Options whose values define which findings a scan can produce. * * @return array */ public static function scan_profile_option_names(): array { return array( 'acceguru_wcag_level', 'acceguru_include_best_practice', 'acceguru_include_section_508', 'acceguru_include_en_301_549', 'acceguru_ignored_rules', 'acceguru_scan_exclude_selectors', ); } /** * Returns the normalized settings snapshot that defines scan output. * * @return array */ public static function current_scan_profile(): array { $level = (string) self::scalar_option( 'acceguru_wcag_level', 'AA' ); if ( ! in_array( $level, array( 'A', 'AA', 'AAA' ), true ) ) { $level = 'AA'; } $ignored = self::get_ignored_rules(); sort( $ignored, SORT_STRING ); $excludes = self::get_exclude_selectors(); sort( $excludes, SORT_STRING ); $profile = array( 'wcag_level' => $level, 'include_best_practice' => (bool) self::scalar_option( 'acceguru_include_best_practice', 1 ), 'include_section_508' => (bool) self::scalar_option( 'acceguru_include_section_508', 0 ), 'include_en_301_549' => (bool) self::scalar_option( 'acceguru_include_en_301_549', 0 ), 'ignored_rules' => array_values( $ignored ), 'exclude_selectors' => array_values( $excludes ), ); /** * Filters the settings snapshot stored with every scan. * * Add-ons that alter result selection should add only stable, scalar or * array data here so their changes also invalidate older summaries. * * @param array $profile Current scan profile. */ $profile = (array) apply_filters( 'acceguru_scan_profile', $profile ); return self::canonicalize_profile( $profile ); } /** * Creates a stable hash for a normalized scan profile. * * @param array|null $profile Profile to hash, or current profile. */ public static function scan_profile_hash( ?array $profile = null ): string { $profile = self::canonicalize_profile( $profile ?? self::current_scan_profile() ); return hash( 'sha256', (string) wp_json_encode( $profile ) ); } /** * Returns scan contract data in the camelCase shape localized to JavaScript. * * @return array */ public static function client_scan_context(): array { $profile = self::current_scan_profile(); return array( 'scanSchemaVersion' => self::SCAN_SCHEMA_VERSION, 'rulesetVersion' => self::RULESET_VERSION, 'scanProfile' => $profile, 'scanProfileHash' => self::scan_profile_hash( $profile ), ); } /** * Whether a stored per-post or site summary matches today's scan contract. * Missing fields intentionally make legacy summaries stale. * * @param mixed $summary Stored summary value. */ public static function summary_is_current( mixed $summary ): bool { if ( ! is_array( $summary ) ) { return false; } if ( self::SCAN_SCHEMA_VERSION !== (int) ( $summary['schema_version'] ?? 0 ) ) { return false; } if ( self::RULESET_VERSION !== (string) ( $summary['ruleset_version'] ?? '' ) ) { return false; } $saved_hash = (string) ( $summary['scan_profile_hash'] ?? '' ); return '' !== $saved_hash && hash_equals( self::scan_profile_hash(), $saved_hash ); } /** * Adds non-persisted freshness flags for REST and rendering consumers. * * @param mixed $summary Stored summary value. * @return array|null */ public static function summary_with_state( mixed $summary ): ?array { if ( ! is_array( $summary ) ) { return null; } $current = self::summary_is_current( $summary ); $summary['summary_current'] = $current; $summary['stale'] = ! $current; return $summary; } /** * Counts stored issues produced by one engine. * * @param array> $issues Issues to inspect. * @param string $source Expected source key. */ public static function count_issues_by_source( array $issues, string $source ): int { $count = 0; foreach ( $issues as $issue ) { if ( $source === (string) ( $issue['source'] ?? '' ) ) { ++$count; } } return $count; } /** * Contract fields stamped onto every saved summary. * * Legacy clients are accepted but get schema version 1, which keeps their * results available while making the UI request a fresh scan. * * @param bool $current_payload Whether the client sent the full v2 payload. * @return array */ private static function summary_contract_fields( bool $current_payload ): array { $profile = self::current_scan_profile(); return array( 'schema_version' => $current_payload ? self::SCAN_SCHEMA_VERSION : 1, 'ruleset_version' => self::RULESET_VERSION, // Kept for consumers that used the old plugin-version field. 'rules_version' => ACCEGURU_VERSION, 'scan_profile' => $profile, 'scan_profile_hash' => self::scan_profile_hash( $profile ), ); } /** * Whether a request carries the complete current result-counting payload. * * @param WP_REST_Request $request Incoming request. */ private static function request_has_current_contract( WP_REST_Request $request ): bool { $hash = sanitize_text_field( (string) $request->get_param( 'scan_profile_hash' ) ); return $request->has_param( 'axe_passes_count' ) && $request->has_param( 'axe_violations_count' ) && '' !== $hash && hash_equals( self::scan_profile_hash(), $hash ); } /** * Rejects a result produced under settings that changed during the run. * A missing hash is accepted as a legacy payload and saved as stale. * * @param WP_REST_Request $request Incoming request. * @return WP_REST_Response|null */ private static function profile_mismatch_response( WP_REST_Request $request ): ?WP_REST_Response { $hash = sanitize_text_field( (string) $request->get_param( 'scan_profile_hash' ) ); if ( '' === $hash || hash_equals( self::scan_profile_hash(), $hash ) ) { return null; } return new WP_REST_Response( array( 'success' => false, 'error' => 'scan_profile_changed', 'current_scan_profile_hash' => self::scan_profile_hash(), ), 409 ); } /** * Whether a persisted paused state can resume under current settings. * * @param mixed $state Stored state. */ private static function paused_state_is_current( mixed $state ): bool { if ( ! is_array( $state ) ) { return false; } $schema_version = (int) ( $state['schema_version'] ?? $state['scan_schema_version'] ?? 0 ); if ( self::SCAN_SCHEMA_VERSION !== $schema_version ) { return false; } if ( self::RULESET_VERSION !== (string) ( $state['ruleset_version'] ?? '' ) ) { return false; } if ( empty( $state['axe_counts_complete'] ) ) { return false; } $hash = (string) ( $state['scan_profile_hash'] ?? '' ); return '' !== $hash && hash_equals( self::scan_profile_hash(), $hash ); } /** * Recursively orders profile keys and list values for stable hashing. * * @param mixed $value Value to normalize. * @return mixed */ private static function canonicalize_profile( mixed $value ): mixed { if ( ! is_array( $value ) ) { return $value; } if ( array() === $value ) { return array(); } $is_list = array_keys( $value ) === range( 0, count( $value ) - 1 ); foreach ( $value as $key => $item ) { $value[ $key ] = self::canonicalize_profile( $item ); } if ( $is_list ) { usort( $value, static function ( $left, $right ): int { return strcmp( (string) wp_json_encode( $left ), (string) wp_json_encode( $right ) ); } ); } else { ksort( $value, SORT_STRING ); } return $value; } /** * Constructor - registers hooks. */ public function __construct() { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scanner' ) ); add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_scanner' ) ); add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); add_action( 'admin_bar_menu', array( $this, 'add_admin_bar_button' ), 100 ); add_action( 'wp_enqueue_scripts', array( $this, 'admin_bar_button_styles' ), 100 ); // Skip pages the owner marked noindex (opt-in). These are often // utility or redirected pages where the iframe scan would just burn // its per-page timeout for no usable result. add_filter( 'acceguru_should_skip_page', array( $this, 'maybe_skip_noindex' ), 10, 3 ); // A settings change makes cached aggregates describe a different scan // profile. Drop the cache immediately; stored summaries remain available // but summary_is_current() marks them stale until the next full scan. foreach ( self::scan_profile_option_names() as $option_name ) { add_action( "update_option_{$option_name}", array( $this, 'invalidate_scan_profile_cache' ), 10, 3 ); add_action( "add_option_{$option_name}", array( $this, 'invalidate_scan_profile_cache' ), 10, 2 ); add_action( "delete_option_{$option_name}", array( $this, 'invalidate_scan_profile_cache' ), 10, 1 ); } } /** * Clears the dashboard aggregate after a scan-profile option changes. * * @param mixed ...$unused Values supplied by the dynamic option hook. */ public function invalidate_scan_profile_cache( mixed ...$unused ): void { unset( $unused ); delete_transient( 'acceguru_dashboard_data' ); } // ------------------------------------------------------------------------- // Front-end enqueue // ------------------------------------------------------------------------- /** * Enqueues axe-core and the scanner script on the front-end. * Only runs for logged-in users with edit_posts capability. */ public function enqueue_scanner(): void { if ( ! current_user_can( 'edit_posts' ) ) { return; } // axe-core is 540KB+ and only needed when scanning actually happens: // (a) inside the site-scan iframe (?acceguru_scan=1 in the URL), // (b) when the user clicks the admin-bar button on the front end. // For (a) we still enqueue it directly so scanner.js can run on its // own. For (b) highlighter.js lazy-loads it via axeUrl on first click, // keeping every other admin pageview free of the half-megabyte cost. $is_iframe_scan = isset( $_GET['acceguru_scan'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only presence check that only decides which script to enqueue; nothing is written. wp_enqueue_script( 'acceguru-custom-rules', ACCEGURU_URL . 'assets/js/acceguru-custom-rules.js', array( 'wp-i18n' ), ACCEGURU_VERSION, true ); wp_set_script_translations( 'acceguru-custom-rules', 'acceguru', ACCEGURU_PATH . 'languages' ); if ( $is_iframe_scan ) { wp_enqueue_script( 'axe-core', ACCEGURU_URL . 'assets/js/axe.min.js', array(), ACCEGURU_AXE_VERSION, true ); wp_enqueue_script( 'acceguru-issue-filters', ACCEGURU_URL . 'assets/js/acceguru-issue-filters.js', array(), ACCEGURU_VERSION, true ); wp_enqueue_script( 'acceguru-scanner', ACCEGURU_URL . 'assets/js/acceguru-scanner.js', array( 'axe-core', 'acceguru-custom-rules', 'acceguru-issue-filters' ), ACCEGURU_VERSION, true ); } wp_enqueue_script( 'acceguru-issue-filters', ACCEGURU_URL . 'assets/js/acceguru-issue-filters.js', array(), ACCEGURU_VERSION, true ); wp_enqueue_script( 'acceguru-highlighter', ACCEGURU_URL . 'assets/js/acceguru-highlighter.js', array( 'acceguru-custom-rules', 'acceguru-issue-filters', 'wp-i18n' ), ACCEGURU_VERSION, true ); wp_set_script_translations( 'acceguru-highlighter', 'acceguru', ACCEGURU_PATH . 'languages' ); // DM Sans across the front-end overlay + admin-bar button, bundled with // the plugin (no external request - see assets/fonts/acceguru-fonts.css). wp_enqueue_style( 'acceguru-dmsans', ACCEGURU_URL . 'assets/fonts/acceguru-fonts.css', array(), ACCEGURU_VERSION ); wp_enqueue_style( 'acceguru-font', ACCEGURU_URL . 'assets/css/acceguru-font.css', array( 'acceguru-dmsans' ), ACCEGURU_VERSION ); // Only attach a postId when we are on a single post/page. On archives // (category, tag, blog index, author, date, search...) get_the_ID() // returns the FIRST post in the queried loop - if we passed that to // the scanner it would write the WHOLE archive page's issues into // that one post's _acceguru_scan_summary, polluting the // dashboard with rows for posts that were never individually scanned. // With postId=0 the in-frame scanner skips the per-post save and // only postMessages the totals up to the parent window, which is // exactly what the archive scan needs. $current_post_id = ( is_singular() || is_admin() ) ? (int) get_the_ID() : 0; $shared = array_merge( self::client_scan_context(), array( 'wcagLevel' => (string) self::scalar_option( 'acceguru_wcag_level', 'AA' ), 'includeBestPractice' => (bool) self::scalar_option( 'acceguru_include_best_practice', 1 ), 'includeSection508' => (bool) self::scalar_option( 'acceguru_include_section_508', 0 ), 'includeEN301549' => (bool) self::scalar_option( 'acceguru_include_en_301_549', 0 ), 'restUrl' => esc_url_raw( rest_url( 'acceguru/v1/scan-results' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'postId' => $current_post_id, 'wpHints' => self::wp_hints(), 'iconUrl' => ACCEGURU_URL . 'assets/images/icon.png', 'logoUrl' => ACCEGURU_URL . 'assets/images/logo.svg', 'axeUrl' => add_query_arg( 'ver', ACCEGURU_AXE_VERSION, ACCEGURU_URL . 'assets/js/axe.min.js' ), 'pluginVersion' => ACCEGURU_VERSION, 'overlayPosition' => (string) self::scalar_option( 'acceguru_overlay_position', 'right' ), 'fabPosition' => (string) self::scalar_option( 'acceguru_fab_position', 'bottom-right' ), // Filter lists shipped to the client so the overlay's always-fresh // scan honours the same exclusions the saved-scan endpoint applies // in get_scan_results(). 'ignoredRules' => array_values( self::get_ignored_rules() ), 'excludeSelectors' => self::get_exclude_selectors(), // Page-settle wait (ms) for waitForStableDom. 'settleMs' => 5000, ) ); /** * Filters the settings handed to the overlay and the in-frame scanner. * * An add-on that registers an issue filter through * window.acceguruIssueFilters ships the data that filter needs here. * The PHP side of the same extension point is acceguru_filter_issues. * * @param array $shared Settings passed to the client. * @param int $current_post_id Post being viewed, 0 on archives. */ $shared = (array) apply_filters( 'acceguru_client_settings', $shared, $current_post_id ); wp_localize_script( 'acceguru-scanner', 'acceguruSettings', $shared ); wp_localize_script( 'acceguru-highlighter', 'acceguruSettings', $shared ); } // ------------------------------------------------------------------------- // Block Editor enqueue // ------------------------------------------------------------------------- /** * Enqueues the editor scan script in the Block Editor. * Only runs for logged-in users with edit_posts capability. */ public function enqueue_block_editor_scanner(): void { if ( ! current_user_can( 'edit_posts' ) ) { return; } // Honour the per-site toggle. Default ON to keep existing installs // unchanged; users can disable when their editor is too crowded. if ( 1 !== absint( get_option( 'acceguru_show_editor_sidebar', 1 ) ) ) { return; } wp_enqueue_style( 'acceguru-admin', ACCEGURU_URL . 'admin/css/acceguru-admin.css', array(), ACCEGURU_VERSION ); // DM Sans across the Block Editor sidebar panel, bundled with the plugin // (no external request - see assets/fonts/acceguru-fonts.css). wp_enqueue_style( 'acceguru-dmsans', ACCEGURU_URL . 'assets/fonts/acceguru-fonts.css', array(), ACCEGURU_VERSION ); wp_enqueue_style( 'acceguru-font', ACCEGURU_URL . 'assets/css/acceguru-font.css', array( 'acceguru-dmsans' ), ACCEGURU_VERSION ); wp_enqueue_script( 'acceguru-sidebar', ACCEGURU_URL . 'admin/js/acceguru-sidebar.js', array( 'wp-plugins', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data', 'wp-i18n' ), ACCEGURU_VERSION, true ); wp_set_script_translations( 'acceguru-sidebar', 'acceguru', ACCEGURU_PATH . 'languages' ); $shared = array_merge( self::client_scan_context(), array( 'wcagLevel' => (string) self::scalar_option( 'acceguru_wcag_level', 'AA' ), 'includeBestPractice' => (bool) self::scalar_option( 'acceguru_include_best_practice', 1 ), 'includeSection508' => (bool) self::scalar_option( 'acceguru_include_section_508', 0 ), 'includeEN301549' => (bool) self::scalar_option( 'acceguru_include_en_301_549', 0 ), 'restUrl' => esc_url_raw( rest_url( 'acceguru/v1/scan-results' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'axeUrl' => add_query_arg( 'ver', ACCEGURU_AXE_VERSION, ACCEGURU_URL . 'assets/js/axe.min.js' ), 'iconUrl' => ACCEGURU_URL . 'assets/images/icon.png', 'wpHints' => self::wp_hints(), ) ); wp_localize_script( 'acceguru-sidebar', 'acceguruEditor', $shared ); } // ------------------------------------------------------------------------- // Admin bar // ------------------------------------------------------------------------- /** * Adds a "AcceGuru" button to the front-end admin bar. * Only shown to logged-in users with edit_posts capability. * Hidden on wp-admin pages (the block editor has its own sidebar). * * @param WP_Admin_Bar $wp_admin_bar WordPress admin bar instance. */ public function add_admin_bar_button( WP_Admin_Bar $wp_admin_bar ): void { if ( is_admin() || ! current_user_can( 'edit_posts' ) ) { return; } // Text wrapped in its own span so highlighter.js can swap the // 'Loading.../Scanning...' label without clobbering the icon // (setting textContent on the parent .ab-item nukes the icon span). $wp_admin_bar->add_node( array( 'id' => 'acceguru', 'parent' => 'root-default', 'title' => 'AcceGuru', 'href' => '#', 'meta' => array( 'title' => __( 'Run accessibility scan on this page', 'acceguru' ), ), ) ); } /** * Inline styling for the admin-bar AcceGuru button on the front end. * Makes the button stand out from the dark WP admin bar with a brand * gradient pill so users notice it on every page they visit. */ public function admin_bar_button_styles(): void { if ( is_admin() || ! is_admin_bar_showing() || ! current_user_can( 'edit_posts' ) ) { return; } // Brand-styled admin bar button: solid green pill with dark navy // text by default, inverts to navy bg + green text on hover. Same // palette as the WP sidebar menu item, so the plugin reads as one // visual identity across both surfaces. $css = ' #wpadminbar #wp-admin-bar-acceguru > .ab-item { background-color: #2FBE8B !important; color: #134B87 !important; font-weight: 700 !important; padding: 0 14px !important; transition: background-color .15s ease, color .15s ease; } #wpadminbar #wp-admin-bar-acceguru > .ab-item .a11y-ab-text { font-weight: 700 !important; } #wpadminbar #wp-admin-bar-acceguru:hover > .ab-item, #wpadminbar #wp-admin-bar-acceguru.hover > .ab-item { background-color: #134B87 !important; color: #2FBE8B !important; } #wpadminbar #wp-admin-bar-acceguru .a11y-ab-text { color: inherit !important; } #wpadminbar #wp-admin-bar-acceguru .a11y-ab-logo { width: 20px; height: 20px; vertical-align: middle; margin-right: 8px; margin-top: -2px; border-radius: 50%; } '; wp_add_inline_style( 'admin-bar', $css ); } // ------------------------------------------------------------------------- // REST API // ------------------------------------------------------------------------- /** * Registers REST API routes. */ public function register_rest_routes(): void { register_rest_route( 'acceguru/v1', '/scan-results/(?P\d+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_scan_results' ), 'permission_callback' => array( $this, 'check_edit_permission' ), 'args' => array( 'post_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( 'acceguru/v1', '/scan-results', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_scan_results' ), 'permission_callback' => array( $this, 'check_edit_permission' ), 'args' => array( 'post_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'results' => array( 'required' => true, 'type' => 'array', 'sanitize_callback' => array( $this, 'sanitize_results' ), ), 'passes_count' => array( 'required' => false, 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), 'axe_passes_count' => array( 'required' => false, 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'axe_violations_count' => array( 'required' => false, 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'scan_profile_hash' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'scan_date' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( 'acceguru/v1', '/site-scan-targets', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_site_scan_targets' ), 'permission_callback' => array( $this, 'check_manage_permission' ), 'args' => array( 'statuses' => array( 'required' => false, 'type' => 'array', 'items' => array( 'type' => 'string' ), ), // Browser-clock ISO timestamp marking the start of a fresh // scan dataset. Stored so the dashboard can show only pages // scanned in this run (see get_site_scan_targets()). 'since' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( 'acceguru/v1', '/site-scan-summary', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_site_scan_summary' ), 'permission_callback' => array( $this, 'check_manage_permission' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_site_scan_summary' ), 'permission_callback' => array( $this, 'check_manage_permission' ), 'args' => array( 'pages_scanned' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'total_issues' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'by_impact' => array( 'required' => true, 'type' => 'object', ), 'passes_count' => array( 'required' => false, 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), 'axe_passes_count' => array( 'required' => false, 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'axe_violations_count' => array( 'required' => false, 'type' => 'integer', 'sanitize_callback' => 'absint', ), 'scan_profile_hash' => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'duration_ms' => array( 'required' => false, 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), 'scan_date' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), // True when the run was cancelled before finishing, so the // totals cover only part of the site. Stored on the summary // so consumers can tell a partial run from a full one (a // 75-of-583 run would otherwise look like a sudden drop). 'partial' => array( 'required' => false, 'type' => 'boolean', 'default' => false, ), ), ), ) ); register_rest_route( 'acceguru/v1', '/site-scan-log', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_site_scan_log' ), 'permission_callback' => array( $this, 'check_manage_permission' ), 'args' => array( 'entries' => array( 'required' => true, 'type' => 'array', ), // Optional counters summary so the on-screen "Scanned X - Y // with issues - Z failed - W clean" row and the "Totals:" // line in the .txt survive the post-scan auto-reload. Old // callers (without counters) still work - the callback // stores empty defaults. 'counters' => array( 'required' => false, ), ), ) ); register_rest_route( 'acceguru/v1', '/site-scan-failed-targets', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_site_scan_failed_targets' ), 'permission_callback' => array( $this, 'check_manage_permission' ), ) ); register_rest_route( 'acceguru/v1', '/site-scan-recompute-summary', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'recompute_site_scan_summary' ), 'permission_callback' => static function () { return current_user_can( 'manage_options' ); }, 'args' => array( // True when rebuilding after a discarded (partial) paused // scan, so the summary is marked as partial coverage. 'partial' => array( 'required' => false, 'type' => 'boolean', 'default' => false, ), ), ) ); register_rest_route( 'acceguru/v1', '/reset-dashboard', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'reset_dashboard' ), 'permission_callback' => static function () { return current_user_can( 'manage_options' ); }, ) ); // Paused-scan state: GET to check if a paused scan exists, POST to // save the current in-flight queue + counters + log when the user // hits Pause, DELETE to clear it on Resume / Cancel. register_rest_route( 'acceguru/v1', '/paused-scan', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_paused_scan_state' ), 'permission_callback' => array( $this, 'check_manage_permission' ), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'save_paused_scan_state' ), 'permission_callback' => array( $this, 'check_manage_permission' ), 'args' => array( 'state' => array( 'required' => true, 'type' => 'object', ), ), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'clear_paused_scan_state' ), 'permission_callback' => array( $this, 'check_manage_permission' ), ), ) ); register_rest_route( 'acceguru/v1', '/ignored-rules', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'toggle_ignored_rule' ), 'permission_callback' => array( $this, 'check_manage_permission' ), 'args' => array( 'rule_id' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'action' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); } /** * Returns the list of axe rule IDs the site admin has chosen to ignore. * Ignored rules are filtered out of dashboard counts, Top Offending * Pages, Most Common Issues, the per-post overlay, and the editor * sidebar - so the user only sees issues they actually want to track. * * @return array */ public static function get_ignored_rules(): array { $raw = get_option( 'acceguru_ignored_rules', array() ); if ( ! is_array( $raw ) ) { return array(); } $rules = array(); foreach ( $raw as $rule ) { if ( ! is_scalar( $rule ) ) { continue; } $rule = (string) $rule; if ( '' !== $rule ) { $rules[] = $rule; } } return array_values( array_unique( $rules ) ); } /** * Returns the user-defined "Exclude from scanning" CSS selectors as a * clean list: one selector per line in the option, trimmed, empties and * duplicates dropped. Validity is checked client-side right before the * list is handed to axe (the browser is the authority on what parses), * so invalid lines simply do nothing rather than break a scan. * * @return array */ public static function get_exclude_selectors(): array { $raw = get_option( 'acceguru_scan_exclude_selectors', '' ); if ( ! is_string( $raw ) ) { return array(); } $lines = preg_split( '/\r\n|\r|\n/', $raw ); if ( ! is_array( $lines ) ) { return array(); } return array_values( array_unique( array_filter( array_map( 'trim', $lines ) ) ) ); } /** * Adds or removes a rule_id from the ignored-rules list. * * @param WP_REST_Request $request Incoming request with rule_id and action ('add'|'remove'). */ public function toggle_ignored_rule( WP_REST_Request $request ): WP_REST_Response { $rule_id = sanitize_text_field( (string) $request->get_param( 'rule_id' ) ); $action = sanitize_text_field( (string) $request->get_param( 'action' ) ); if ( '' === $rule_id ) { return new WP_REST_Response( array( 'error' => 'rule_id required' ), 400 ); } $current = self::get_ignored_rules(); if ( 'add' === $action ) { if ( ! in_array( $rule_id, $current, true ) ) { $current[] = $rule_id; } } elseif ( 'remove' === $action ) { $current = array_values( array_diff( $current, array( $rule_id ) ) ); } else { return new WP_REST_Response( array( 'error' => 'invalid action' ), 400 ); } update_option( 'acceguru_ignored_rules', $current, false ); delete_transient( 'acceguru_dashboard_data' ); return new WP_REST_Response( array( 'success' => true, 'ignored' => $current ), 200 ); } /** * Wipes all stored scan data so the dashboard returns to its empty state. * Removes: site-scan summary option, site-scan log option, dashboard * transient, every per-post scan results and summary post_meta. Keeps * user settings (WCAG level, fixer toggles, AI keys) untouched. * * @return WP_REST_Response */ public function reset_dashboard( WP_REST_Request $request ): WP_REST_Response { delete_option( 'acceguru_site_scan_summary' ); delete_option( 'acceguru_last_scan_log' ); delete_option( 'acceguru_last_scan_log_counters' ); delete_option( 'acceguru_paused_scan' ); delete_transient( 'acceguru_dashboard_data' ); // Remove all per-post scan data while keeping plugin settings intact. // A missing key is not an error - it just means nothing was stored. $meta_keys = array( '_acceguru_scan_results', '_acceguru_scan_summary', '_acceguru_issue_count', ); foreach ( $meta_keys as $meta_key ) { delete_post_meta_by_key( $meta_key ); } /** * Fires after "Reset all scan data" has removed this plugin's own data. * * Add-ons storing data the user produced from scans delete it here, so * the button stays literal instead of leaving their rows behind. */ do_action( 'acceguru_reset_scan_data' ); return new WP_REST_Response( array( 'success' => true ), 200 ); } /** * GET /paused-scan - returns the paused scan state if one exists, * otherwise null. Used by site-scan.js on dashboard load to decide * whether to show the "Resume paused scan" banner. * * @param WP_REST_Request $request Incoming request. * @return WP_REST_Response */ public function get_paused_scan_state( WP_REST_Request $request ): WP_REST_Response { $state = get_option( 'acceguru_paused_scan', null ); $state = is_array( $state ) ? $state : null; return new WP_REST_Response( array( 'state' => $state, 'stale' => null !== $state && ! self::paused_state_is_current( $state ), 'currentScan' => self::client_scan_context(), ), 200 ); } /** * POST /paused-scan - saves the in-flight scan state (remaining queue, * counters, log entries, mode flags) when the user hits Pause. The * top-level structure and scalar metadata used by the server are * normalised before the state is stored. The remaining data is * round-tripped through the same site-scan client that produced it. * * @param WP_REST_Request $request Incoming request. * @return WP_REST_Response */ public function save_paused_scan_state( WP_REST_Request $request ): WP_REST_Response { $state = (array) $request->get_param( 'state' ); $ruleset_version = $state['ruleset_version'] ?? ''; $scan_profile_hash = $state['scan_profile_hash'] ?? ''; $state['paused_at'] = gmdate( 'c' ); $state['axe_passes_count'] = absint( $state['axe_passes_count'] ?? $state['total_passes'] ?? 0 ); $state['axe_violations_count'] = absint( $state['axe_violations_count'] ?? 0 ); $state['schema_version'] = absint( $state['schema_version'] ?? $state['scan_schema_version'] ?? 1 ); $state['scan_schema_version'] = $state['schema_version']; $state['ruleset_version'] = is_scalar( $ruleset_version ) ? sanitize_text_field( (string) $ruleset_version ) : ''; $state['axe_counts_complete'] = ! empty( $state['axe_counts_complete'] ); $state['scan_profile_hash'] = is_scalar( $scan_profile_hash ) ? sanitize_text_field( (string) $scan_profile_hash ) : ''; $state['scan_profile'] = isset( $state['scan_profile'] ) && is_array( $state['scan_profile'] ) ? self::canonicalize_profile( $state['scan_profile'] ) : array(); update_option( 'acceguru_paused_scan', $state, false ); return new WP_REST_Response( array( 'success' => true, 'stale' => ! self::paused_state_is_current( $state ), 'currentScan' => self::client_scan_context(), ), 200 ); } /** * DELETE /paused-scan - clears the saved paused-scan state. Called when * the user clicks Resume (after the queue has been read back into JS) * or Cancel from the resume banner. * * @param WP_REST_Request $request Incoming request. * @return WP_REST_Response */ public function clear_paused_scan_state( WP_REST_Request $request ): WP_REST_Response { delete_option( 'acceguru_paused_scan' ); return new WP_REST_Response( array( 'success' => true ), 200 ); } /** * Normalises saved site-scan log rows for storage and every later reader. * * A missing category remains valid for logs produced by current Pro * versions. Rows with missing required fields or non-scalar field values * are discarded as a whole so they cannot create empty dashboard rows, * invalid retry targets, or JavaScript errors in the text export. * * @param mixed $entries Raw log rows. * @return array> */ public static function normalize_scan_log_entries( mixed $entries ): array { if ( ! is_array( $entries ) ) { return array(); } $allowed = array( 'ok', 'err' ); $allowed_categories = array( 'with_issues', 'timed_out', 'save_failed', 'scan_error', 'skipped', 'other' ); $clean = array(); $required_fields = array( 'kind', 'label', 'detail' ); $optional_fields = array( 'category', 'time', 'id' ); foreach ( $entries as $entry ) { if ( ! is_array( $entry ) ) { continue; } foreach ( $required_fields as $field ) { if ( ! array_key_exists( $field, $entry ) || ! is_scalar( $entry[ $field ] ) ) { continue 2; } } foreach ( $optional_fields as $field ) { if ( array_key_exists( $field, $entry ) && ! is_scalar( $entry[ $field ] ) ) { continue 2; } } $kind = sanitize_text_field( (string) $entry['kind'] ); if ( ! in_array( $kind, $allowed, true ) ) { $kind = 'ok'; } $category = sanitize_key( (string) ( $entry['category'] ?? '' ) ); if ( ! in_array( $category, $allowed_categories, true ) ) { $category = ''; } $clean[] = array( 'kind' => $kind, 'label' => sanitize_text_field( (string) $entry['label'] ), 'detail' => sanitize_text_field( (string) $entry['detail'] ), 'category' => $category, 'time' => sanitize_text_field( (string) ( $entry['time'] ?? '' ) ), 'id' => max( 0, (int) ( $entry['id'] ?? 0 ) ), ); } return $clean; } /** * Persists the per-page log of the most recent full-site scan so the * dashboard can render it after a page reload. * * @param WP_REST_Request $request Incoming request. */ public function save_site_scan_log( WP_REST_Request $request ): WP_REST_Response { $entries = self::normalize_scan_log_entries( $request->get_param( 'entries' ) ); $nonnegative_int = static function ( mixed $value ): int { return is_scalar( $value ) ? max( 0, (int) $value ) : 0; }; // Two-pass cap: keep ALL error/skip entries (rare but critical for // diagnostics), and cap successes at 500 (most recent). Without this // late-stage timeouts on large sites used to silently disappear when // the flat 200-entry cap filled up with successes early. $errors = array(); $successes = array(); foreach ( $entries as $entry ) { if ( 'err' === $entry['kind'] ) { $errors[] = $entry; } else { $successes[] = $entry; } } if ( count( $successes ) > 500 ) { $successes = array_slice( $successes, -500 ); } $clean = array_merge( $errors, $successes ); update_option( 'acceguru_last_scan_log', $clean, false ); // Optional counters are persisted in a SEPARATE option. The log stays an // array of per-entry rows (each row may include a stable category key). // Counters are stored as non-negative ints with all keys present so the // JS does not have to defend against missing fields after restore. $counters_in = $request->get_param( 'counters' ); if ( is_array( $counters_in ) || is_object( $counters_in ) ) { $src = (array) $counters_in; update_option( 'acceguru_last_scan_log_counters', array( 'scanned' => $nonnegative_int( $src['scanned'] ?? 0 ), 'with_issues' => $nonnegative_int( $src['with_issues'] ?? 0 ), 'failed' => $nonnegative_int( $src['failed'] ?? 0 ), 'clean' => $nonnegative_int( $src['clean'] ?? 0 ), 'dropped_records' => $nonnegative_int( $src['dropped_records'] ?? 0 ), ), false ); } else { // No counters in the request (older clients): clear any stale // counters from a previous run so the dashboard does not show // numbers that no longer match the saved entries. delete_option( 'acceguru_last_scan_log_counters' ); } return new WP_REST_Response( array( 'success' => true, 'saved' => count( $clean ) ), 200 ); } /** * GET /site-scan-failed-targets - returns the list of posts that failed * during the most recent site-wide scan, resolved to scannable targets * the JS runner can replay. */ public function get_site_scan_failed_targets( WP_REST_Request $request ): WP_REST_Response { $site_summary = get_option( 'acceguru_site_scan_summary', null ); if ( ! is_array( $site_summary ) || ! self::summary_is_current( $site_summary ) ) { return new WP_REST_Response( array( 'error' => 'scan_profile_changed', 'currentScan' => self::client_scan_context(), ), 409 ); } $log = self::normalize_scan_log_entries( get_option( 'acceguru_last_scan_log', array() ) ); $targets = array(); $seen = array(); // Re-apply the SAME gates a fresh scan uses, so a retry can never // scan more than get_site_scan_targets() would: a stale failed-list // must not replay post types outside the current allow-list, and // deliberately skipped pages (e.g. noindex) must not be resurrected // by the retry. $allowed_types = self::scan_post_types(); if ( is_array( $log ) ) { foreach ( $log as $row ) { if ( ! is_array( $row ) || 'err' !== ( $row['kind'] ?? '' ) ) { continue; } $id = isset( $row['id'] ) ? (int) $row['id'] : 0; if ( $id <= 0 || isset( $seen[ $id ] ) ) { continue; } $seen[ $id ] = true; if ( ! get_post( $id ) ) { continue; } // Skip post types outside the current allow-list. if ( ! in_array( get_post_type( $id ), $allowed_types, true ) ) { continue; } $permalink = get_permalink( $id ); if ( ! $permalink ) { continue; } $status = (string) get_post_status( $id ); $status_obj = get_post_status_object( $status ); $target = array( 'id' => $id, 'title' => get_the_title( $id ), 'url' => $permalink, 'status' => $status, 'statusLabel' => $status_obj ? $status_obj->label : $status, ); // Honour the skip filter (noindex etc.) so intentionally // skipped pages are not brought back by a retry. if ( (bool) apply_filters( 'acceguru_should_skip_page', false, $id, $target ) ) { continue; } $targets[] = $target; } } return new WP_REST_Response( array( 'targets' => $targets, 'count' => count( $targets ), ), 200 ); } /** * POST /site-scan-recompute-summary - rebuilds acceguru_site_scan_summary * from the current per-post meta. Used after a retry so headline cards * always reflect what is actually persisted. */ public function recompute_site_scan_summary( WP_REST_Request $request ): WP_REST_Response { $pages = 0; $total = 0; $axe_passes = 0; $axe_violations = 0; $stale_pages = 0; $by = array( 'critical' => 0, 'serious' => 0, 'moderate' => 0, 'minor' => 0 ); // Same dataset filter the dashboard uses, so the recomputed headline // (cards) counts exactly the pages the dashboard lists show. Without // this a retry / cancel-from-pause would re-sum stale pages from an // earlier run and the cards would disagree with the lists again. $since_raw = (string) self::scalar_option( 'acceguru_dataset_since', '' ); $since_ts = '' !== $since_raw ? strtotime( $since_raw ) : 0; foreach ( self::post_ids_in_batches( array( 'post_type' => 'any', 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), 'meta_key' => '_acceguru_scan_summary', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Recompute runs on demand after a retry, not on every page load. 'orderby' => 'ID', 'order' => 'ASC', ) ) as $pid ) { $summary = get_post_meta( (int) $pid, '_acceguru_scan_summary', true ); if ( ! is_array( $summary ) ) { continue; } if ( $since_ts > 0 ) { $page_ts = isset( $summary['scan_date'] ) ? strtotime( (string) $summary['scan_date'] ) : 0; if ( $page_ts <= 0 || $page_ts < $since_ts ) { continue; } } if ( ! self::summary_is_current( $summary ) ) { ++$stale_pages; continue; } $issues = get_post_meta( (int) $pid, '_acceguru_scan_results', true ); $issues = self::filter_issues( is_array( $issues ) ? $issues : array(), (int) $pid ); ++$pages; $total += count( $issues ); $axe_passes += (int) ( $summary['axe_passes_count'] ?? $summary['passes_count'] ?? 0 ); // Passed Checks uses the original axe result set. Do not replace its // raw violation count with the filtered list shown to the user. $axe_violations += (int) ( $summary['axe_violations_count'] ?? self::count_issues_by_source( $issues, 'axe' ) ); foreach ( $issues as $issue ) { $impact = (string) ( $issue['impact'] ?? '' ); if ( isset( $by[ $impact ] ) ) { ++$by[ $impact ]; } } } $existing = get_option( 'acceguru_site_scan_summary', array() ); // Once a run is partial (cancelled), it stays partial through a Retry - // retrying the few failed pages does not turn partial site coverage // into full coverage. Only a fresh full scan (save_site_scan_summary) // clears the flag back to false. So OR the existing flag with this // request's: a discard passes true, a normal retry passes false but // must not erase an earlier cancel's partial state. $existing_partial = is_array( $existing ) && ! empty( $existing['partial'] ); $summary = array_merge( self::summary_contract_fields( 0 === $stale_pages ), array( 'pages_scanned' => $pages, 'total_issues' => $total, 'passes_count' => $axe_passes, 'axe_passes_count' => $axe_passes, 'axe_violations_count' => $axe_violations, 'by_impact' => $by, 'scan_date' => gmdate( 'c' ), 'duration_ms' => is_array( $existing ) ? absint( $existing['duration_ms'] ?? 0 ) : 0, 'partial' => $existing_partial || (bool) $request->get_param( 'partial' ), 'stale_pages' => $stale_pages, ) ); update_option( 'acceguru_site_scan_summary', $summary, false ); delete_transient( 'acceguru_dashboard_data' ); /** @see save_site_scan_summary() docblock for hook contract. */ do_action( 'acceguru_site_scan_summary_saved', $summary, 'recompute' ); return new WP_REST_Response( array( 'success' => true, 'summary' => $summary ), 200 ); } /** * Returns every post type a site scan may ever touch, keyed by slug. * The single source of truth: the settings checkboxes, the selection * sanitiser, the scanner and the dashboard coverage line all read this * list, so they can never disagree. * * Excluded here: * - Media (attachment): attachment pages use the 'inherit' status, * which the site-scan target query excludes. * - Known service types: registered as public by their authors, but * their entries are internal storage a visitor never opens (e.g. * WPBakery's Gutenberg bridge). Exact slugs only - never a prefix, * a "no entries" or a "hidden from menu" heuristic, all of which * can match a site's real content types. * * @return array Slug-keyed map of eligible type slugs. */ public static function eligible_post_types(): array { $public = get_post_types( array( 'public' => true ), 'names' ); unset( $public['attachment'] ); $service = array( 'wpb_gutenberg_param', // WPBakery Page Builder: Gutenberg attribute storage. ); /** * Filters the exact slugs of service post types excluded from * scanning, the settings picker and the coverage line entirely. * * @param array $service Post-type slugs to exclude. */ $filtered_service = apply_filters( 'acceguru_service_post_types', $service ); if ( is_array( $filtered_service ) ) { $service = $filtered_service; } foreach ( $service as $service_slug ) { if ( ! is_string( $service_slug ) ) { continue; } $service_slug = sanitize_key( $service_slug ); if ( '' !== $service_slug ) { unset( $public[ $service_slug ] ); } } return $public; } /** * Returns the site owner's saved type selection, validated against * eligible_post_types(). Empty after validation => Posts + Pages, so * the settings screen and the scanner always show and use the same * fallback - the two must never disagree. * * @return array */ public static function selected_post_types(): array { $eligible = self::eligible_post_types(); $saved = get_option( 'acceguru_scan_post_types', array( 'post', 'page' ) ); if ( ! is_array( $saved ) ) { $saved = array( 'post', 'page' ); } $types = array(); foreach ( $saved as $slug ) { if ( ! is_string( $slug ) ) { continue; } $slug = sanitize_key( $slug ); if ( isset( $eligible[ $slug ] ) ) { $types[] = $slug; } } if ( empty( $types ) ) { $types = array( 'post', 'page' ); } return array_values( array_unique( $types ) ); } /** * Returns the post types the full-site scan walks. * * @return array */ public static function scan_post_types(): array { $selected = self::selected_post_types(); /** * Filters the list of post types the full-site scan walks. * * @param array $types Validated post-type slugs. */ $filtered = apply_filters( 'acceguru_site_scan_post_types', $selected ); if ( ! is_array( $filtered ) ) { return $selected; } $eligible = self::eligible_post_types(); $types = array(); foreach ( $filtered as $slug ) { if ( ! is_string( $slug ) ) { continue; } $slug = sanitize_key( $slug ); if ( '' !== $slug && isset( $eligible[ $slug ] ) ) { $types[] = $slug; } } return array_values( array_unique( $types ) ); } /** * Returns the list of URLs to scan in a site-wide run. */ public function get_site_scan_targets( WP_REST_Request $request ): WP_REST_Response { $post_types = self::scan_post_types(); // Record the start of this fresh scan dataset. The JS sends its own // (browser) clock here, matching the clock that stamps each page's // scan_date, so the dashboard's "show only this run" filter is not // thrown off by server/browser clock skew. Only a fresh scan calls // this endpoint; retry (site-scan-failed-targets) and resume // (paused-scan) do not, so they keep merging into the same dataset. $since = (string) $request->get_param( 'since' ); if ( '' !== $since ) { update_option( 'acceguru_dataset_since', sanitize_text_field( $since ), false ); delete_transient( 'acceguru_dashboard_data' ); } $allowed_statuses = array( 'publish', 'draft', 'pending', 'private', 'future' ); $requested = (array) $request->get_param( 'statuses' ); $statuses = array(); foreach ( $requested as $status ) { $status = sanitize_key( (string) $status ); if ( in_array( $status, $allowed_statuses, true ) ) { $statuses[] = $status; } } if ( empty( $statuses ) ) { $statuses = array( 'publish' ); } $targets = array(); $skipped = array(); if ( array() === $post_types ) { return new WP_REST_Response( array( 'targets' => $targets, 'skipped' => $skipped, 'count' => 0, ), 200 ); } foreach ( self::post_ids_in_batches( array( 'post_type' => $post_types, 'post_status' => $statuses, 'orderby' => 'ID', 'order' => 'ASC', ) ) as $post_id ) { $permalink = get_permalink( $post_id ); if ( ! $permalink ) { $skipped[] = array( 'id' => (int) $post_id, 'title' => get_the_title( $post_id ), 'reason' => 'empty_permalink', ); continue; } $status = (string) get_post_status( $post_id ); $status_obj = get_post_status_object( $status ); $target = array( 'id' => (int) $post_id, 'title' => get_the_title( $post_id ), 'url' => $permalink, 'status' => $status, 'statusLabel' => $status_obj ? $status_obj->label : $status, ); /** * Filters whether a single page should be skipped during a site-scan. * Returning true leaves the page out of this run. * * @param bool $skip Default false (scan every target). * @param int $post_id Post being considered. * @param array $target Resolved target (id, title, url, status). */ if ( (bool) apply_filters( 'acceguru_should_skip_page', false, (int) $post_id, $target ) ) { $skipped[] = array( 'id' => (int) $post_id, 'title' => get_the_title( $post_id ), 'reason' => 'filter_skip', ); continue; } $targets[] = $target; } return new WP_REST_Response( array( 'targets' => $targets, 'skipped' => $skipped, 'count' => count( $targets ), ), 200 ); } /** * Filter callback for acceguru_should_skip_page. When the "Skip * noindex pages" scanner option is on, drops any post the site owner * marked noindex in their SEO plugin. Such pages are commonly utility * pages or pages redirected elsewhere, where the iframe would just hit * its 30s timeout and return nothing. * * @param mixed $skip Current skip decision (bool-ish) from earlier filters. * @param mixed $post_id Post being considered. * @param mixed $target Resolved target array (unused here). * @return bool */ public function maybe_skip_noindex( $skip, $post_id, $target = array() ): bool { unset( $target ); if ( (bool) $skip ) { return true; } if ( 1 !== absint( get_option( 'acceguru_skip_noindex', 0 ) ) ) { return false; } return self::is_noindex( (int) $post_id ); } /** * Reads the common SEO plugins' per-post robots meta to decide whether * a post is set to noindex. Covers Rank Math, Yoast, and SEOPress (all * store the flag in post meta). The result is filterable to cover SEO * plugins that keep robots data elsewhere (e.g. AIOSEO's table). * * @param int $post_id Post ID. * @return bool True when the post is marked noindex. */ public static function is_noindex( int $post_id ): bool { $noindex = false; // Rank Math: 'rank_math_robots' is an array of robots directives. $rank_math = get_post_meta( $post_id, 'rank_math_robots', true ); if ( is_array( $rank_math ) && in_array( 'noindex', $rank_math, true ) ) { $noindex = true; } // Yoast: '_yoast_wpseo_meta-robots-noindex' is '1' for noindex. if ( ! $noindex && '1' === (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true ) ) { $noindex = true; } // SEOPress: '_seopress_robots_index' is 'yes' when noindex is set. if ( ! $noindex && 'yes' === (string) get_post_meta( $post_id, '_seopress_robots_index', true ) ) { $noindex = true; } /** * Filters the noindex decision for a post, covering SEO plugins * that store robots data outside post meta. * * @param bool $noindex Whether the post is considered noindex. * @param int $post_id Post ID. */ return (bool) apply_filters( 'acceguru_post_is_noindex', $noindex, $post_id ); } /** * Returns the most recent site-wide scan summary. */ public function get_site_scan_summary( WP_REST_Request $request ): WP_REST_Response { $summary = self::summary_with_state( get_option( 'acceguru_site_scan_summary', null ) ); $current = null !== $summary && ! empty( $summary['summary_current'] ); return new WP_REST_Response( array( 'summary' => $summary, 'summary_current' => $current, 'stale' => null !== $summary && ! $current, ), 200 ); } /** * Saves the aggregate site-wide scan summary to wp_options. */ public function save_site_scan_summary( WP_REST_Request $request ): WP_REST_Response { $mismatch = self::profile_mismatch_response( $request ); if ( null !== $mismatch ) { return $mismatch; } $by_impact_raw = (array) $request->get_param( 'by_impact' ); $by_impact = array( 'critical' => isset( $by_impact_raw['critical'] ) ? absint( $by_impact_raw['critical'] ) : 0, 'serious' => isset( $by_impact_raw['serious'] ) ? absint( $by_impact_raw['serious'] ) : 0, 'moderate' => isset( $by_impact_raw['moderate'] ) ? absint( $by_impact_raw['moderate'] ) : 0, 'minor' => isset( $by_impact_raw['minor'] ) ? absint( $by_impact_raw['minor'] ) : 0, ); $current_payload = self::request_has_current_contract( $request ); $axe_passes = $request->has_param( 'axe_passes_count' ) ? absint( $request->get_param( 'axe_passes_count' ) ) : absint( $request->get_param( 'passes_count' ) ); $axe_violations = $request->has_param( 'axe_violations_count' ) ? absint( $request->get_param( 'axe_violations_count' ) ) : 0; $summary = array_merge( self::summary_contract_fields( $current_payload ), array( 'pages_scanned' => (int) $request->get_param( 'pages_scanned' ), 'total_issues' => (int) $request->get_param( 'total_issues' ), 'by_impact' => $by_impact, 'passes_count' => $axe_passes, 'axe_passes_count' => $axe_passes, 'axe_violations_count' => $axe_violations, 'duration_ms' => absint( $request->get_param( 'duration_ms' ) ), 'scan_date' => (string) $request->get_param( 'scan_date' ), 'partial' => (bool) $request->get_param( 'partial' ), ) ); update_option( 'acceguru_site_scan_summary', $summary, false ); delete_transient( 'acceguru_dashboard_data' ); /** * Fires whenever the site-wide scan summary is persisted - either * at the end of a full scan or after a recompute. * * @param array $summary Saved summary (pages_scanned, total_issues, by_impact, ...). * @param string $origin Origin of the save: 'site_scan' (run finished) * or 'recompute' (manual rebuild from per-post meta). */ do_action( 'acceguru_site_scan_summary_saved', $summary, 'site_scan' ); return new WP_REST_Response( array( 'success' => true, 'summary' => self::summary_with_state( $summary ), 'summary_current' => self::summary_is_current( $summary ), ), 200 ); } /** * Permission callback - requires edit_posts capability. */ public function check_edit_permission(): bool { return current_user_can( 'edit_posts' ); } /** * Permission callback for site-wide endpoints that read or write the * global scan state in wp_options (the full-site scan summary, log, * targets / dataset marker, paused-scan state, and the site-wide * ignored-rules list). These affect the whole site and every user, and * the dashboard that drives them is itself manage_options-only, so the * endpoints require the same capability rather than the broader * edit_posts (which editors and authors also hold). * * @return bool */ public function check_manage_permission(): bool { return current_user_can( 'manage_options' ); } /** * Handles GET /acceguru/v1/scan-results/{post_id}. * Returns saved issues and summary for a post. * * @param WP_REST_Request $request Incoming request. */ public function get_scan_results( WP_REST_Request $request ): WP_REST_Response { $post_id = (int) $request->get_param( 'post_id' ); if ( ! get_post( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) { return new WP_REST_Response( array( 'error' => 'Invalid or inaccessible post.' ), 403 ); } $issues = get_post_meta( $post_id, '_acceguru_scan_results', true ); $summary = get_post_meta( $post_id, '_acceguru_scan_summary', true ); $issues = is_array( $issues ) ? $issues : array(); $current = self::summary_is_current( $summary ); // Strip rules the admin chose to ignore site-wide, then let add-ons // strip their own. Summary is recomputed afterwards, so totals and // breakdowns always match the issues actually shown. $issues = self::filter_issues( $issues, $post_id ); if ( is_array( $summary ) ) { $by_impact = array( 'critical' => 0, 'serious' => 0, 'moderate' => 0, 'minor' => 0 ); foreach ( $issues as $i ) { $imp = (string) ( $i['impact'] ?? 'minor' ); if ( isset( $by_impact[ $imp ] ) ) { ++$by_impact[ $imp ]; } } $summary['total_issues'] = count( $issues ); $summary['by_impact'] = $by_impact; if ( $current ) { $summary['axe_passes_count'] = absint( $summary['axe_passes_count'] ?? $summary['passes_count'] ?? 0 ); $summary['passes_count'] = $summary['axe_passes_count']; // Keep the persisted raw axe count: the returned issue list is // filtered and is not the denominator paired with raw axe passes. $summary['axe_violations_count'] = absint( $summary['axe_violations_count'] ?? self::count_issues_by_source( $issues, 'axe' ) ); } } /** * Filters the issues array before it is returned by GET /scan-results. * * @param array $issues Filtered scan issues for this post. * @param int $post_id Post ID the results belong to. */ $issues = (array) apply_filters( 'acceguru_annotate_scan_results', is_array( $issues ) ? $issues : array(), $post_id ); $summary = self::summary_with_state( $summary ); return new WP_REST_Response( array( 'issues' => $issues, 'summary' => $summary, 'summary_current' => $current, 'stale' => null !== $summary && ! $current, ), 200 ); } /** * THE single place that decides which stored issues of a post count. * Drops rules the admin ignores site-wide, then hands the rest to add-ons. * Used by GET /scan-results and by the dashboard aggregate, so the cards, * the tables and the per-post view can never disagree. * * @param array> $issues Stored issues for the post. * @param int $post_id Post the issues belong to. * @return array> */ public static function filter_issues( array $issues, int $post_id ): array { $issues = array_values( array_filter( $issues, static fn( $issue ): bool => is_array( $issue ) ) ); $ignored = self::get_ignored_rules(); if ( ! empty( $ignored ) ) { $issues = array_values( array_filter( $issues, static function ( $issue ) use ( $ignored ) { return ! in_array( (string) ( $issue['rule_id'] ?? '' ), $ignored, true ); } ) ); } /** * Filters a post's stored issues before they are counted or shown. * * Return the issues to KEEP. Runs for the per-post view and for the * dashboard aggregate alike, so an add-on hiding an issue hides it * everywhere at once. The browser-side twin of this extension point * is window.acceguruIssueFilters. * * @param array> $issues Issues surviving the ignored-rules pass. * @param int $post_id Post the issues belong to. */ $filtered = apply_filters( 'acceguru_filter_issues', $issues, $post_id ); if ( ! is_array( $filtered ) ) { return $issues; } return array_values( array_filter( $filtered, static fn( $issue ): bool => is_array( $issue ) ) ); } /** * Handles POST /acceguru/v1/scan-results. * Saves results and summary to post_meta. * * @param WP_REST_Request $request Incoming request. */ public function handle_scan_results( WP_REST_Request $request ): WP_REST_Response { $post_id = (int) $request->get_param( 'post_id' ); $results = (array) $request->get_param( 'results' ); $passes_count = absint( $request->get_param( 'passes_count' ) ); $scan_date = (string) $request->get_param( 'scan_date' ); $mismatch = self::profile_mismatch_response( $request ); if ( null !== $mismatch ) { return $mismatch; } $current_payload = self::request_has_current_contract( $request ); $axe_passes = $request->has_param( 'axe_passes_count' ) ? absint( $request->get_param( 'axe_passes_count' ) ) : $passes_count; // Current clients send the raw axe count captured before issue filters; // that is the denominator paired with raw axe passes. Derive from the // visible results only as a compatibility fallback for legacy clients. $axe_violations = $request->has_param( 'axe_violations_count' ) ? absint( $request->get_param( 'axe_violations_count' ) ) : self::count_issues_by_source( $results, 'axe' ); // Verify the post exists and the user can edit it. if ( ! get_post( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) { return new WP_REST_Response( array( 'error' => 'Invalid or inaccessible post.' ), 403 ); } // Persist the full results. update_post_meta( $post_id, '_acceguru_scan_results', $results ); // Build impact summary. $by_impact = array( 'critical' => 0, 'serious' => 0, 'moderate' => 0, 'minor' => 0, ); foreach ( $results as $issue ) { $impact = $issue['impact'] ?? ''; if ( isset( $by_impact[ $impact ] ) ) { ++$by_impact[ $impact ]; } } $summary = array_merge( self::summary_contract_fields( $current_payload ), array( 'total_issues' => count( $results ), 'by_impact' => $by_impact, 'passes_count' => $axe_passes, 'axe_passes_count' => $axe_passes, 'axe_violations_count' => $axe_violations, 'scan_date' => $scan_date, ) ); update_post_meta( $post_id, '_acceguru_scan_summary', $summary ); // Plain numeric copy of the issue count. The summary above is a // serialized array, so MySQL cannot order by the number inside it - // sorting that blob as a string compares digits as text and puts a // page with 19 issues below one with 2. The A11y column sorts on this // key instead. Written for every scanned post, including 0 issues, so // a clean page sorts as 0 rather than as "never scanned". update_post_meta( $post_id, '_acceguru_issue_count', (int) $summary['total_issues'] ); delete_transient( 'acceguru_dashboard_data' ); return new WP_REST_Response( array( 'success' => true, 'saved' => count( $results ), 'summary' => self::summary_with_state( $summary ), 'summary_current' => self::summary_is_current( $summary ), ), 200 ); } /** * WordPress-specific remediation hints for the most common axe rule IDs. * Surfaces alongside the generic axe `help` text so users know exactly * where in the WP admin to go to fix a problem (vs the abstract advice * Deque docs give for a generic web page). * * Map: axe rule_id => one-line WP-flavoured action. * * @return array */ public static function wp_hints(): array { return array( 'image-alt' => __( 'Open the image in Media Library to set alt text, or use AcceGuru → Bulk Alt Text to fix all at once with AI.', 'acceguru' ), 'color-contrast' => __( 'Adjust colors in Appearance → Editor → Styles, or override in Customizer → Additional CSS. Block themes also expose contrast via theme.json.', 'acceguru' ), 'label' => __( 'Forms from contact-form plugins: edit the form in the plugin\'s editor and add a visible