Scanning...
Scanning...
Scanning...
Scanning...
Scanning...

__( 'Unauthorized', 'accessibility-guard' ) ) ); } $fix_types = isset( $_POST['fix_types'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['fix_types'] ) ) : array(); $posts = get_posts( array( 'post_type' => array( 'post', 'page' ), 'posts_per_page' => -1, 'post_status' => 'publish', ) ); $fixed_counts = array( 'empty_alt' => 0, 'empty_links' => 0, 'empty_buttons' => 0, 'missing_labels' => 0, 'heading_order' => 0, ); foreach ( $posts as $post ) { // Check if page uses Elementor (if so, only fix Elementor data). $elementor_data = get_post_meta( $post->ID, '_elementor_data', true ); if ( ! empty( $elementor_data ) ) { // Page uses Elementor - only fix Elementor data (that's what renders). $original_elementor = $elementor_data; $elementor_data = $this->fix_elementor_data( $elementor_data, $fix_types, $fixed_counts ); if ( $elementor_data !== $original_elementor ) { update_post_meta( $post->ID, '_elementor_data', wp_slash( $elementor_data ) ); // Clear Elementor cache for this post. if ( class_exists( '\Elementor\Plugin' ) ) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } } } else { // No Elementor - fix post_content. $content = $post->post_content; $original_content = $content; $content = $this->apply_bulk_fixes( $content, $fix_types, $fixed_counts ); if ( $content !== $original_content ) { wp_update_post( array( 'ID' => $post->ID, 'post_content' => $content, ) ); } } } $total_fixed = array_sum( $fixed_counts ); /* translators: %d: number of issues fixed */ $summary = sprintf( __( 'Fixed %d issues:', 'accessibility-guard' ), $total_fixed ) . '
'; foreach ( $fixed_counts as $type => $count ) { if ( $count > 0 ) { $summary .= '• ' . ucfirst( str_replace( '_', ' ', $type ) ) . ': ' . $count . '
'; } } wp_send_json_success( array( 'summary' => $summary ) ); } /** * Apply bulk fixes to content string. * * @param string $content Content to fix. * @param array $fix_types Types of fixes to apply. * @param array $fixed_counts Reference to counts array. * @return string Fixed content. */ private function apply_bulk_fixes( $content, $fix_types, &$fixed_counts ) { if ( empty( $content ) ) { return $content; } // Fix empty alt attributes. if ( in_array( 'empty_alt', $fix_types, true ) ) { $pattern = '/]*alt=)[^>]*>/i'; $content = preg_replace_callback( $pattern, function( $matches ) use ( &$fixed_counts ) { $fixed_counts['empty_alt']++; return str_replace( '' . $matches[2] . ''; } return $matches[0]; }, $content ); } // Fix empty buttons. if ( in_array( 'empty_buttons', $fix_types, true ) ) { $pattern = '/]*)>(\s*)<\/button>/i'; $content = preg_replace_callback( $pattern, function( $matches ) use ( &$fixed_counts ) { if ( strpos( $matches[1], 'aria-label' ) === false ) { $fixed_counts['empty_buttons']++; return '' . $matches[2] . ''; } return $matches[0]; }, $content ); } // Fix missing form labels. if ( in_array( 'missing_labels', $fix_types, true ) ) { $pattern = '/]*type=["\']?(text|email|password|tel|number|search)["\']?[^>]*)>/i'; $content = preg_replace_callback( $pattern, function( $matches ) use ( &$fixed_counts ) { $attrs = $matches[1]; if ( strpos( $attrs, 'aria-label' ) !== false || strpos( $attrs, 'aria-labelledby' ) !== false ) { return $matches[0]; } $label = ''; if ( preg_match( '/placeholder=["\']([^"\']+)["\']/', $attrs, $ph ) ) { $label = $ph[1]; } elseif ( preg_match( '/name=["\']([^"\']+)["\']/', $attrs, $nm ) ) { $label = ucfirst( str_replace( array( '_', '-' ), ' ', $nm[1] ) ); } if ( $label ) { $fixed_counts['missing_labels']++; return ''; } return $matches[0]; }, $content ); } // Fix heading hierarchy. if ( in_array( 'heading_order', $fix_types, true ) ) { preg_match_all( '/]*)>(.*?)<\/h\1>/is', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ); if ( count( $matches ) > 1 ) { $offset_adjustment = 0; $prev_level = 0; foreach ( $matches as $match ) { $current_level = intval( $match[1][0] ); $full_match = $match[0][0]; $position = $match[0][1] + $offset_adjustment; $attrs = $match[2][0]; $text = $match[3][0]; if ( $prev_level > 0 && $current_level > $prev_level + 1 ) { $new_level = $prev_level + 1; $new_tag = '' . $text . ''; $content = substr_replace( $content, $new_tag, $position, strlen( $full_match ) ); $offset_adjustment += strlen( $new_tag ) - strlen( $full_match ); $fixed_counts['heading_order']++; $prev_level = $new_level; } else { $prev_level = $current_level; } } } } return $content; } /** * Fix Elementor JSON data recursively. * * @param string $json_data Elementor JSON string. * @param array $fix_types Types of fixes to apply. * @param array $fixed_counts Reference to counts array. * @return string Fixed JSON string. */ private function fix_elementor_data( $json_data, $fix_types, &$fixed_counts ) { $data = json_decode( $json_data, true ); if ( ! is_array( $data ) ) { return $json_data; } // Fix heading hierarchy if requested. if ( in_array( 'heading_order', $fix_types, true ) ) { $data = $this->fix_elementor_heading_hierarchy( $data, $fixed_counts ); } // Fix other issues (image alt, HTML content). $data = $this->fix_elementor_elements( $data, $fix_types, $fixed_counts ); return wp_json_encode( $data ); } /** * Fix heading hierarchy in Elementor data. * * @param array $data Elementor data array. * @param array $fixed_counts Reference to counts array. * @return array Fixed data. */ private function fix_elementor_heading_hierarchy( $data, &$fixed_counts ) { // First, collect all headings with their paths. $headings = array(); $this->collect_headings_with_paths( $data, $headings, array() ); if ( count( $headings ) < 2 ) { return $data; } // Find headings that skip levels and calculate correct levels. $fixes = array(); $prev_level = $headings[0]['level']; for ( $i = 1; $i < count( $headings ); $i++ ) { $curr_level = $headings[ $i ]['level']; if ( $curr_level > $prev_level + 1 ) { // This heading skips levels - should be prev_level + 1. $new_level = $prev_level + 1; $fixes[] = array( 'path' => $headings[ $i ]['path'], 'field' => $headings[ $i ]['field'], 'old_level' => $curr_level, 'new_level' => $new_level, ); $prev_level = $new_level; } else { $prev_level = $curr_level; } } // Apply fixes. foreach ( $fixes as $fix ) { $data = $this->apply_heading_fix( $data, $fix ); $fixed_counts['heading_order']++; } return $data; } /** * Collect headings with their paths for fixing. * * @param array $elements Elementor elements. * @param array $headings Reference to headings array. * @param array $path Current path in the element tree. */ private function collect_headings_with_paths( $elements, &$headings, $path ) { foreach ( $elements as $idx => $element ) { $current_path = array_merge( $path, array( $idx ) ); $widget_type = $element['widgetType'] ?? ''; $settings = $element['settings'] ?? array(); // Check heading widgets. if ( $widget_type === 'heading' ) { $tag = $settings['header_size'] ?? 'h2'; if ( preg_match( '/h([1-6])/i', $tag, $m ) ) { $headings[] = array( 'level' => intval( $m[1] ), 'path' => $current_path, 'field' => 'header_size', ); } elseif ( $tag === 'default' ) { $headings[] = array( 'level' => 2, 'path' => $current_path, 'field' => 'header_size', ); } } // Check widgets with title_size. if ( isset( $settings['title_size'] ) ) { $tag = $settings['title_size']; if ( preg_match( '/h([1-6])/i', $tag, $m ) ) { $headings[] = array( 'level' => intval( $m[1] ), 'path' => $current_path, 'field' => 'title_size', ); } } // Recurse. if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) { $this->collect_headings_with_paths( $element['elements'], $headings, array_merge( $current_path, array( 'elements' ) ) ); } } } /** * Apply a heading fix to Elementor data. * * @param array $data Elementor data (passed by reference). * @param array $fix Fix details (path, field, new_level). * @return array Modified data. */ private function apply_heading_fix( &$data, $fix ) { // Build the path to the element's settings. $path = $fix['path']; $field = $fix['field']; $new_value = 'h' . $fix['new_level']; // Navigate to the element using eval-free approach. $current = &$data; foreach ( $path as $key ) { if ( ! isset( $current[ $key ] ) ) { return $data; } $current = &$current[ $key ]; } // Update the heading level in settings. if ( isset( $current['settings'][ $field ] ) ) { $current['settings'][ $field ] = $new_value; } elseif ( $field === 'header_size' && isset( $current['settings'] ) ) { // For heading widgets, add header_size if not present. $current['settings'][ $field ] = $new_value; } return $data; } /** * Recursively fix Elementor elements. * * @param array $elements Elementor elements array. * @param array $fix_types Types of fixes to apply. * @param array $fixed_counts Reference to counts array. * @return array Fixed elements. */ private function fix_elementor_elements( $elements, $fix_types, &$fixed_counts ) { foreach ( $elements as &$element ) { $widget_type = $element['widgetType'] ?? ''; if ( ! isset( $element['settings'] ) || ! is_array( $element['settings'] ) ) { // No settings, just recurse. if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) { $element['elements'] = $this->fix_elementor_elements( $element['elements'], $fix_types, $fixed_counts ); } continue; } // Note: Elementor image widgets always render alt="" which is VALID. // No fix needed - only fix images in HTML content where alt is completely missing. // Fix settings that contain HTML. foreach ( $element['settings'] as $key => &$value ) { if ( is_string( $value ) && in_array( $key, array( 'editor', 'html', 'content', 'text', 'description', 'caption', 'before_text', 'after_text', 'prefix', 'suffix' ), true ) ) { if ( strpos( $value, '<' ) !== false ) { $value = $this->apply_bulk_fixes( $value, $fix_types, $fixed_counts ); } } } // Recurse into child elements. if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) { $element['elements'] = $this->fix_elementor_elements( $element['elements'], $fix_types, $fixed_counts ); } } return $elements; } /** * AJAX handler to count affected items for each fix type. * Counts only what can be fixed (post_content + Elementor data). */ public function count_affected() { check_ajax_referer( 'wpag_bulk_fix', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized', 'accessibility-guard' ) ) ); } $posts = get_posts( array( 'post_type' => array( 'post', 'page' ), 'posts_per_page' => -1, 'post_status' => 'publish', ) ); $counts = array( 'empty_alt' => 0, 'empty_links' => 0, 'empty_buttons' => 0, 'missing_labels' => 0, 'heading_order' => 0, ); foreach ( $posts as $post ) { // Check Elementor data first (if page uses Elementor, that's what renders). $elementor_data = get_post_meta( $post->ID, '_elementor_data', true ); if ( ! empty( $elementor_data ) ) { // Page uses Elementor - only check Elementor data. $this->count_issues_in_elementor( $elementor_data, $counts ); } else { // No Elementor - check post_content. $this->count_issues_in_content( $post->post_content, $counts ); } } wp_send_json_success( array( 'counts' => $counts ) ); } /** * Count issues in HTML content. * * @param string $content Content to check. * @param array $counts Reference to counts array. */ private function count_issues_in_content( $content, &$counts ) { if ( empty( $content ) ) { return; } // Count images without alt attribute (exclude decorative images). preg_match_all( '/]*alt=)[^>]*>/i', $content, $matches ); foreach ( $matches[0] as $img ) { // Skip decorative images (role="presentation" or aria-hidden="true"). if ( strpos( $img, 'role="presentation"' ) !== false || strpos( $img, 'aria-hidden="true"' ) !== false ) { continue; } $counts['empty_alt']++; } // Count empty links. preg_match_all( '/]*)>(\s*)<\/a>/i', $content, $matches ); foreach ( $matches[1] as $attrs ) { if ( strpos( $attrs, 'aria-label' ) === false ) { $counts['empty_links']++; } } // Count empty buttons. preg_match_all( '/]*)>(\s*)<\/button>/i', $content, $matches ); foreach ( $matches[1] as $attrs ) { if ( strpos( $attrs, 'aria-label' ) === false ) { $counts['empty_buttons']++; } } // Count inputs without labels. preg_match_all( '/]*type=["\']?(text|email|password|tel|number|search)["\']?[^>]*>/i', $content, $matches ); foreach ( $matches[0] as $input ) { if ( strpos( $input, 'aria-label' ) === false && strpos( $input, 'aria-labelledby' ) === false ) { $counts['missing_labels']++; } } // Count heading hierarchy issues. preg_match_all( '/]*>/i', $content, $matches ); $headings = $matches[1]; for ( $i = 1; $i < count( $headings ); $i++ ) { $prev = intval( $headings[ $i - 1 ] ); $curr = intval( $headings[ $i ] ); if ( $curr > $prev + 1 ) { $counts['heading_order']++; } } } /** * Count issues in Elementor JSON data. * * @param string $json_data Elementor JSON string. * @param array $counts Reference to counts array. */ private function count_issues_in_elementor( $json_data, &$counts ) { $data = json_decode( $json_data, true ); if ( ! is_array( $data ) ) { return; } // Collect all heading levels from Elementor widgets. $heading_levels = array(); $this->count_issues_in_elementor_elements( $data, $counts, $heading_levels ); // Check heading hierarchy across the whole page. if ( count( $heading_levels ) > 1 ) { for ( $i = 1; $i < count( $heading_levels ); $i++ ) { $prev = $heading_levels[ $i - 1 ]; $curr = $heading_levels[ $i ]; if ( $curr > $prev + 1 ) { $counts['heading_order']++; } } } } /** * Recursively count issues in Elementor elements. * * @param array $elements Elementor elements array. * @param array $counts Reference to counts array. * @param array $heading_levels Reference to heading levels array. */ private function count_issues_in_elementor_elements( $elements, &$counts, &$heading_levels ) { foreach ( $elements as $element ) { $widget_type = $element['widgetType'] ?? ''; $settings = $element['settings'] ?? array(); // Check for heading widgets and collect heading levels. if ( $widget_type === 'heading' ) { $tag = $settings['header_size'] ?? 'h2'; if ( preg_match( '/h([1-6])/i', $tag, $m ) ) { $heading_levels[] = intval( $m[1] ); } elseif ( $tag === 'default' ) { $heading_levels[] = 2; // Default is usually h2. } } // Check for widgets with title_size setting (icon-box, etc.). if ( isset( $settings['title_size'] ) ) { $tag = $settings['title_size']; if ( preg_match( '/h([1-6])/i', $tag, $m ) ) { $heading_levels[] = intval( $m[1] ); } } // Note: Elementor image widgets always render alt="" (empty = decorative, which is VALID). // So we DON'T count them as issues - Scanner won't flag them either. // Only count images in HTML content where alt attribute is completely missing. // Check settings that contain HTML. if ( is_array( $settings ) ) { foreach ( $settings as $key => $value ) { // Common HTML content fields in Elementor. if ( is_string( $value ) && in_array( $key, array( 'editor', 'html', 'content', 'text', 'description', 'caption', 'before_text', 'after_text', 'prefix', 'suffix' ), true ) ) { if ( strpos( $value, '<' ) !== false ) { $this->count_issues_in_content( $value, $counts ); } } } } // Recurse into child elements. if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) { $this->count_issues_in_elementor_elements( $element['elements'], $counts, $heading_levels ); } } } /** * AJAX handler to get detailed list of affected items for a fix type. */ public function get_affected_details() { check_ajax_referer( 'wpag_bulk_fix', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized', 'accessibility-guard' ) ) ); } $fix_type = isset( $_POST['fix_type'] ) ? sanitize_text_field( wp_unslash( $_POST['fix_type'] ) ) : ''; if ( empty( $fix_type ) ) { wp_send_json_error( array( 'message' => __( 'Invalid fix type.', 'accessibility-guard' ) ) ); } $posts = get_posts( array( 'post_type' => array( 'post', 'page' ), 'posts_per_page' => -1, 'post_status' => 'publish', ) ); $details = array(); foreach ( $posts as $post ) { $post_issues = array(); // Check Elementor data first (if page uses Elementor, that's what renders). $elementor_data = get_post_meta( $post->ID, '_elementor_data', true ); if ( ! empty( $elementor_data ) ) { // Page uses Elementor - only check Elementor data. $this->find_issues_in_elementor( $elementor_data, $fix_type, $post_issues ); } else { // No Elementor - check post_content. $this->find_issues_in_content( $post->post_content, $fix_type, $post_issues, 'post_content' ); } if ( ! empty( $post_issues ) ) { $details[] = array( 'post_id' => $post->ID, 'post_title' => $post->post_title, 'edit_url' => get_edit_post_link( $post->ID, 'raw' ), 'view_url' => get_permalink( $post->ID ), 'issues' => $post_issues, ); } } wp_send_json_success( array( 'fix_type' => $fix_type, 'details' => $details, 'total' => count( $details ), ) ); } /** * Find specific issue type in content. * * @param string $content Content to check. * @param string $fix_type Type of fix to look for. * @param array $issues Reference to issues array. * @param string $source Source identifier. */ private function find_issues_in_content( $content, $fix_type, &$issues, $source = 'content' ) { if ( empty( $content ) ) { return; } switch ( $fix_type ) { case 'empty_alt': preg_match_all( '/]*alt=)[^>]*>/i', $content, $matches ); foreach ( $matches[0] as $match ) { // Skip decorative images (role="presentation" or aria-hidden="true"). if ( strpos( $match, 'role="presentation"' ) !== false || strpos( $match, 'aria-hidden="true"' ) !== false ) { continue; } $snippet = $this->truncate_snippet( $match, 100 ); $issues[] = array( 'snippet' => $snippet, 'source' => $source, ); } break; case 'empty_links': preg_match_all( '/]*)>(\s*)<\/a>/i', $content, $matches, PREG_SET_ORDER ); foreach ( $matches as $match ) { if ( strpos( $match[1], 'aria-label' ) === false ) { $issues[] = array( 'snippet' => $this->truncate_snippet( $match[0], 80 ), 'source' => $source, ); } } break; case 'empty_buttons': preg_match_all( '/]*)>(\s*)<\/button>/i', $content, $matches, PREG_SET_ORDER ); foreach ( $matches as $match ) { if ( strpos( $match[1], 'aria-label' ) === false ) { $issues[] = array( 'snippet' => $this->truncate_snippet( $match[0], 80 ), 'source' => $source, ); } } break; case 'missing_labels': preg_match_all( '/]*type=["\']?(text|email|password|tel|number|search)["\']?[^>]*>/i', $content, $matches ); foreach ( $matches[0] as $match ) { if ( strpos( $match, 'aria-label' ) === false && strpos( $match, 'aria-labelledby' ) === false ) { $issues[] = array( 'snippet' => $this->truncate_snippet( $match, 80 ), 'source' => $source, ); } } break; case 'heading_order': preg_match_all( '/]*>.*?<\/h\1>/is', $content, $matches, PREG_OFFSET_CAPTURE ); $headings = $matches[1]; for ( $i = 1; $i < count( $headings ); $i++ ) { $prev = intval( $headings[ $i - 1 ][0] ); $curr = intval( $headings[ $i ][0] ); if ( $curr > $prev + 1 ) { // Get the actual heading element. preg_match( '/]*>.*?<\/h' . $curr . '>/is', $content, $full_match, 0, $headings[ $i ][1] ); $issues[] = array( 'snippet' => $this->truncate_snippet( $full_match[0] ?? "h{$prev} → h{$curr}", 80 ), 'source' => $source, 'detail' => sprintf( __( 'Skipped from h%d to h%d', 'accessibility-guard' ), $prev, $curr ), ); } } break; } } /** * Find issues in Elementor JSON data. * * @param string $json_data Elementor JSON string. * @param string $fix_type Type of fix to look for. * @param array $issues Reference to issues array. */ private function find_issues_in_elementor( $json_data, $fix_type, &$issues ) { $data = json_decode( $json_data, true ); if ( ! is_array( $data ) ) { return; } // For heading_order, collect all heading levels first. if ( $fix_type === 'heading_order' ) { $heading_levels = array(); $this->collect_elementor_headings( $data, $heading_levels ); // Check for hierarchy issues. if ( count( $heading_levels ) > 1 ) { for ( $i = 1; $i < count( $heading_levels ); $i++ ) { $prev = $heading_levels[ $i - 1 ]['level']; $curr = $heading_levels[ $i ]['level']; if ( $curr > $prev + 1 ) { $issues[] = array( 'snippet' => $heading_levels[ $i ]['title'] ?: "h{$curr}", 'source' => 'Elementor: ' . $heading_levels[ $i ]['widget'], 'detail' => sprintf( __( 'Skipped from h%d to h%d', 'accessibility-guard' ), $prev, $curr ), ); } } } return; } $this->find_issues_in_elementor_elements( $data, $fix_type, $issues ); } /** * Collect all headings from Elementor elements. * * @param array $elements Elementor elements array. * @param array $heading_levels Reference to heading levels array. */ private function collect_elementor_headings( $elements, &$heading_levels ) { foreach ( $elements as $element ) { $widget_type = $element['widgetType'] ?? ''; $settings = $element['settings'] ?? array(); // Check for heading widgets. if ( $widget_type === 'heading' ) { $tag = $settings['header_size'] ?? 'h2'; $title = $settings['title'] ?? ''; if ( preg_match( '/h([1-6])/i', $tag, $m ) ) { $heading_levels[] = array( 'level' => intval( $m[1] ), 'title' => $title, 'widget' => 'heading' ); } elseif ( $tag === 'default' ) { $heading_levels[] = array( 'level' => 2, 'title' => $title, 'widget' => 'heading' ); } } // Check for widgets with title_size setting (icon-box, etc.). if ( isset( $settings['title_size'] ) ) { $tag = $settings['title_size']; $title = $settings['title_text'] ?? $settings['title'] ?? ''; if ( preg_match( '/h([1-6])/i', $tag, $m ) ) { $heading_levels[] = array( 'level' => intval( $m[1] ), 'title' => $title, 'widget' => $widget_type ?: 'unknown' ); } } // Recurse into child elements. if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) { $this->collect_elementor_headings( $element['elements'], $heading_levels ); } } } /** * Recursively find issues in Elementor elements. * * @param array $elements Elementor elements array. * @param string $fix_type Type of fix to look for. * @param array $issues Reference to issues array. */ private function find_issues_in_elementor_elements( $elements, $fix_type, &$issues ) { foreach ( $elements as $element ) { $widget_type = $element['widgetType'] ?? $element['elType'] ?? 'unknown'; $settings = $element['settings'] ?? array(); // Note: Elementor image widgets always render alt="" (empty = decorative, which is VALID). // So we DON'T flag them as issues - Scanner won't flag them either. // Check settings that contain HTML. if ( is_array( $settings ) ) { foreach ( $settings as $key => $value ) { if ( is_string( $value ) && in_array( $key, array( 'editor', 'html', 'content', 'text', 'description', 'caption', 'before_text', 'after_text', 'prefix', 'suffix' ), true ) ) { if ( strpos( $value, '<' ) !== false ) { $this->find_issues_in_content( $value, $fix_type, $issues, 'Elementor: ' . $widget_type ); } } } } // Recurse into child elements. if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) { $this->find_issues_in_elementor_elements( $element['elements'], $fix_type, $issues ); } } } /** * Truncate snippet for display. * * @param string $snippet Snippet to truncate. * @param int $length Max length. * @return string Truncated snippet. */ private function truncate_snippet( $snippet, $length = 100 ) { // Normalize whitespace but keep HTML. $snippet = preg_replace( '/\s+/', ' ', $snippet ); $snippet = trim( $snippet ); if ( strlen( $snippet ) > $length ) { $snippet = substr( $snippet, 0, $length ) . '...'; } return $snippet; } /** * AJAX handler to create backup of post content before bulk fix. */ public function create_backup() { check_ajax_referer( 'wpag_bulk_fix', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized', 'accessibility-guard' ) ) ); } // Respect plugin settings for post types $settings = get_option( 'wpag_settings', array() ); $post_types = $settings['scan_post_types'] ?? array( 'page', 'post' ); $posts = get_posts( array( 'post_type' => $post_types, 'posts_per_page' => -1, 'post_status' => 'any', ) ); $backup_data = array( 'backup_version' => '2.0', 'plugin_version' => WPAG_VERSION, 'created' => gmdate( 'Y-m-d H:i:s' ), 'site_url' => get_site_url(), 'site_name' => get_bloginfo( 'name' ), 'post_count' => count( $posts ), 'posts' => array(), ); foreach ( $posts as $post ) { // Get all post meta $post_meta = get_post_meta( $post->ID ); $meta_backup = array(); // Filter and include important meta keys $important_meta_keys = array( '_elementor_data', '_elementor_edit_mode', '_elementor_page_settings', '_elementor_css', '_wp_page_template', '_thumbnail_id', '_edit_lock', '_edit_last', ); foreach ( $post_meta as $meta_key => $meta_values ) { // Include Elementor meta, important WP meta, and custom meta (not starting with _) if ( in_array( $meta_key, $important_meta_keys, true ) || strpos( $meta_key, '_elementor' ) === 0 || strpos( $meta_key, '_' ) !== 0 ) { $meta_backup[ $meta_key ] = $meta_values; } } $backup_data['posts'][] = array( 'ID' => $post->ID, 'post_title' => $post->post_title, 'post_name' => $post->post_name, 'post_type' => $post->post_type, 'post_content' => $post->post_content, 'post_excerpt' => $post->post_excerpt, 'post_status' => $post->post_status, 'post_author' => $post->post_author, 'post_date' => $post->post_date, 'post_date_gmt' => $post->post_date_gmt, 'post_modified' => $post->post_modified, 'post_parent' => $post->post_parent, 'menu_order' => $post->menu_order, 'comment_status' => $post->comment_status, 'ping_status' => $post->ping_status, 'guid' => $post->guid, 'post_meta' => $meta_backup, ); } wp_send_json_success( array( 'backup' => $backup_data ) ); } /** * AJAX handler to restore/import backup. */ public function restore_backup() { check_ajax_referer( 'wpag_bulk_fix', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized', 'accessibility-guard' ) ) ); } $backup_json = isset( $_POST['backup_data'] ) ? wp_unslash( $_POST['backup_data'] ) : ''; if ( empty( $backup_json ) ) { wp_send_json_error( array( 'message' => __( 'No backup data provided.', 'accessibility-guard' ) ) ); } $backup_data = json_decode( $backup_json, true ); if ( json_last_error() !== JSON_ERROR_NONE ) { wp_send_json_error( array( 'message' => __( 'Invalid JSON format.', 'accessibility-guard' ) ) ); } if ( empty( $backup_data['posts'] ) || ! is_array( $backup_data['posts'] ) ) { wp_send_json_error( array( 'message' => __( 'No posts found in backup.', 'accessibility-guard' ) ) ); } $restored = 0; $skipped = 0; $errors = array(); foreach ( $backup_data['posts'] as $post_data ) { $post_id = isset( $post_data['ID'] ) ? intval( $post_data['ID'] ) : 0; // Check if post exists $existing_post = get_post( $post_id ); if ( ! $existing_post ) { $skipped++; continue; } // Prepare post update data $update_data = array( 'ID' => $post_id, 'post_content' => $post_data['post_content'] ?? $existing_post->post_content, ); // Include optional fields if present in backup if ( isset( $post_data['post_title'] ) ) { $update_data['post_title'] = $post_data['post_title']; } if ( isset( $post_data['post_excerpt'] ) ) { $update_data['post_excerpt'] = $post_data['post_excerpt']; } if ( isset( $post_data['post_name'] ) ) { $update_data['post_name'] = $post_data['post_name']; } // Update post $result = wp_update_post( $update_data, true ); if ( is_wp_error( $result ) ) { $errors[] = sprintf( /* translators: 1: Post ID, 2: Error message */ __( 'Post %1$d: %2$s', 'accessibility-guard' ), $post_id, $result->get_error_message() ); continue; } // Restore post meta if ( ! empty( $post_data['post_meta'] ) && is_array( $post_data['post_meta'] ) ) { foreach ( $post_data['post_meta'] as $meta_key => $meta_values ) { // Delete existing meta first delete_post_meta( $post_id, $meta_key ); // Restore each meta value if ( is_array( $meta_values ) ) { foreach ( $meta_values as $meta_value ) { $meta_value = maybe_unserialize( $meta_value ); add_post_meta( $post_id, $meta_key, $meta_value ); } } } } // Clear Elementor cache for this post if ( class_exists( '\Elementor\Plugin' ) ) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } $restored++; } $message = sprintf( /* translators: 1: Restored count, 2: Skipped count */ __( 'Restored %1$d posts. Skipped %2$d (not found).', 'accessibility-guard' ), $restored, $skipped ); if ( ! empty( $errors ) ) { $message .= ' ' . __( 'Errors:', 'accessibility-guard' ) . ' ' . implode( ', ', $errors ); } wp_send_json_success( array( 'message' => $message, 'restored' => $restored, 'skipped' => $skipped, 'errors' => $errors, ) ); } /** * Render Audit History page. */ public function render_history_page() { $history = get_option( 'wpag_audit_history', array() ); $history = array_slice( $history, -30 ); // Last 30 entries ?>

%
$post_types, 'posts_per_page' => -1, 'post_status' => 'publish', ) ); $total_issues = 0; $total_errors = 0; $total_warnings = 0; $pages_with_issues = 0; foreach ( $posts as $post ) { $cache_key = 'wpag_scan_' . $post->ID; $results = get_transient( $cache_key ); if ( $results && is_array( $results ) ) { $issue_count = count( $results ); $total_issues += $issue_count; // Count by severity. foreach ( $results as $issue ) { $severity = $issue['severity'] ?? 'notice'; if ( $severity === 'error' ) { $total_errors++; } elseif ( $severity === 'warning' ) { $total_warnings++; } } if ( $issue_count > 0 ) { $pages_with_issues++; } } } $pages_scanned = count( $posts ); $compliance = $pages_scanned > 0 ? round( ( ( $pages_scanned - $pages_with_issues ) / $pages_scanned ) * 100 ) : 100; $history = get_option( 'wpag_audit_history', array() ); $history[] = array( 'timestamp' => time(), 'pages_scanned' => $pages_scanned, 'total_issues' => $total_issues, 'errors' => $total_errors, 'warnings' => $total_warnings, 'compliance' => $compliance, ); // Keep only last 90 entries. if ( count( $history ) > 90 ) { $history = array_slice( $history, -90 ); } update_option( 'wpag_audit_history', $history ); } /** * Render Color Contrast page. */ public function render_contrast_page() { ?>

' . esc_html__( 'Schedule updated!', 'accessibility-guard' ) . '

'; } $frequency = get_option( 'wpag_scan_frequency', 'disabled' ); $email_enabled = get_option( 'wpag_email_enabled', 1 ); $next_run = wp_next_scheduled( 'wpag_scheduled_scan' ); ?>

$post_types, 'posts_per_page' => -1, 'post_status' => 'publish', ) ); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=accessibility-report-' . gmdate( 'Y-m-d' ) . '.csv' ); $output = fopen( 'php://output', 'w' ); fputcsv( $output, array( 'Page Title', 'Page URL', 'Check ID', 'Message', 'Severity', 'WCAG Criterion', 'WCAG Level', 'Element', ) ); foreach ( $posts as $post ) { $cache_key = 'wpag_scan_' . $post->ID; $issues = get_transient( $cache_key ); if ( $issues && is_array( $issues ) && ! empty( $issues ) ) { foreach ( $issues as $issue ) { fputcsv( $output, array( $post->post_title, get_permalink( $post->ID ), $issue['check_id'] ?? '', $issue['message'] ?? '', $issue['severity'] ?? '', $issue['wcag'] ?? '', $issue['level'] ?? '', wp_strip_all_tags( $issue['element'] ?? '' ), ) ); } } } fclose( $output ); exit; } /** * Export scan results to PDF (HTML version). */ public function export_pdf() { check_ajax_referer( 'wpag_export_pdf', 'wpag_pdf_nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized', 'accessibility-guard' ) ); } $white_label = isset( $_POST['white_label'] ) ? true : false; $company_name = sanitize_text_field( wp_unslash( $_POST['company_name'] ?? '' ) ); $settings = get_option( 'wpag_settings', array() ); $post_types = $settings['scan_post_types'] ?? array( 'page', 'post' ); $posts = get_posts( array( 'post_type' => $post_types, 'posts_per_page' => -1, 'post_status' => 'publish', ) ); $total_issues = 0; $total_errors = 0; $total_warnings = 0; $pages_data = array(); foreach ( $posts as $post ) { $cache_key = 'wpag_scan_' . $post->ID; $issues = get_transient( $cache_key ); if ( $issues && is_array( $issues ) ) { $total_issues += count( $issues ); foreach ( $issues as $issue ) { $severity = $issue['severity'] ?? 'notice'; if ( $severity === 'error' ) { $total_errors++; } elseif ( $severity === 'warning' ) { $total_warnings++; } } if ( ! empty( $issues ) ) { $pages_data[] = array( 'title' => $post->post_title, 'url' => get_permalink( $post->ID ), 'issues' => $issues, ); } } } $pages_scanned = count( $posts ); $compliance = $pages_scanned > 0 ? round( ( ( $pages_scanned - count( $pages_data ) ) / $pages_scanned ) * 100 ) : 100; header( 'Content-Type: text/html; charset=utf-8' ); ?> <?php esc_html_e( 'Accessibility Audit Report', 'accessibility-guard' ); ?>

-

%

| WCAG (Level )

WEEK_IN_SECONDS, 'display' => __( 'Once Weekly', 'accessibility-guard' ), ); $schedules['monthly'] = array( 'interval' => MONTH_IN_SECONDS, 'display' => __( 'Once Monthly', 'accessibility-guard' ), ); return $schedules; } /** * Run scheduled scan. */ public function run_scheduled_scan() { $settings = get_option( 'wpag_settings', array() ); $post_types = $settings['scan_post_types'] ?? array( 'page', 'post' ); $posts = get_posts( array( 'post_type' => $post_types, 'posts_per_page' => -1, 'post_status' => 'publish', ) ); foreach ( $posts as $post ) { $url = get_permalink( $post->ID ); $html = WPAG_Scanner::fetch_page_html( $url ); if ( is_wp_error( $html ) ) { continue; } $scanner = new WPAG_Scanner( $html, $url, $post->ID ); $issues = $scanner->scan( true ); $summary = $scanner->get_summary(); update_post_meta( $post->ID, '_wpag_last_scan', current_time( 'mysql' ) ); update_post_meta( $post->ID, '_wpag_issue_count', count( $issues ) ); update_post_meta( $post->ID, '_wpag_error_count', $summary['error'] ); } // Save to audit history $this->save_audit_history(); // Send email notification if enabled if ( get_option( 'wpag_email_enabled', 1 ) ) { $this->send_scan_notification(); } } /** * Send email notification after scheduled scan. */ private function send_scan_notification() { $admin_email = get_option( 'admin_email' ); $site_name = get_bloginfo( 'name' ); $settings = get_option( 'wpag_settings', array() ); $post_types = $settings['scan_post_types'] ?? array( 'page', 'post' ); $posts = get_posts( array( 'post_type' => $post_types, 'posts_per_page' => -1, 'post_status' => 'publish', ) ); $total_issues = 0; $total_errors = 0; foreach ( $posts as $post ) { $cache_key = 'wpag_scan_' . $post->ID; $results = get_transient( $cache_key ); if ( $results && is_array( $results ) ) { $total_issues += count( $results ); foreach ( $results as $issue ) { if ( isset( $issue['severity'] ) && $issue['severity'] === 'error' ) { $total_errors++; } } } } /* translators: %s: site name */ $subject = sprintf( __( '[%s] Accessibility Scan Report', 'accessibility-guard' ), $site_name ); /* translators: 1: site name, 2: total issues, 3: total errors, 4: dashboard URL */ $message = sprintf( __( "Accessibility Guard has completed a scheduled scan of %1\$s.\n\n" . "Results:\n" . "- Total Issues: %2\$d\n" . "- Errors: %3\$d\n\n" . "View detailed results: %4\$s", 'accessibility-guard' ), $site_name, $total_issues, $total_errors, admin_url( 'admin.php?page=wpag-dashboard' ) ); wp_mail( $admin_email, $subject, $message ); } /** * Add Pro-only checks. * * @param array $checks Existing checks. * @return array Modified checks. */ public function add_pro_checks( $checks ) { return $checks; } /** * AJAX handler for contrast check. */ public function check_contrast() { check_ajax_referer( 'wpag_scan_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized', 'accessibility-guard' ) ) ); } $fg = sanitize_hex_color( wp_unslash( $_POST['fg'] ?? '#000000' ) ); $bg = sanitize_hex_color( wp_unslash( $_POST['bg'] ?? '#FFFFFF' ) ); $ratio = $this->calculate_contrast_ratio( $fg, $bg ); wp_send_json_success( array( 'ratio' => round( $ratio, 2 ), 'aa_normal' => $ratio >= 4.5, 'aa_large' => $ratio >= 3, 'aaa_normal' => $ratio >= 7, 'aaa_large' => $ratio >= 4.5, ) ); } /** * Calculate contrast ratio between two colors. * * @param string $fg Foreground color hex. * @param string $bg Background color hex. * @return float Contrast ratio. */ private function calculate_contrast_ratio( $fg, $bg ) { $l1 = $this->get_luminance( $fg ); $l2 = $this->get_luminance( $bg ); $lighter = max( $l1, $l2 ); $darker = min( $l1, $l2 ); return ( $lighter + 0.05 ) / ( $darker + 0.05 ); } /** * Get relative luminance of a color. * * @param string $hex Hex color. * @return float Luminance value. */ private function get_luminance( $hex ) { $hex = ltrim( $hex, '#' ); $r = hexdec( substr( $hex, 0, 2 ) ) / 255; $g = hexdec( substr( $hex, 2, 2 ) ) / 255; $b = hexdec( substr( $hex, 4, 2 ) ) / 255; $r = $r <= 0.03928 ? $r / 12.92 : pow( ( $r + 0.055 ) / 1.055, 2.4 ); $g = $g <= 0.03928 ? $g / 12.92 : pow( ( $g + 0.055 ) / 1.055, 2.4 ); $b = $b <= 0.03928 ? $b / 12.92 : pow( ( $b + 0.055 ) / 1.055, 2.4 ); return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b; } // ========================================================================= // PDF ACCESSIBILITY SCAN // ========================================================================= /** * Render PDF Accessibility Scan page. */ public function render_pdf_scan_page() { $pdfs = $this->get_all_pdfs(); $scan_results = get_option( 'wpag_pdf_scan_results', array() ); ?>

ID ] ?? null; $file_path = get_attached_file( $pdf->ID ); $file_size = file_exists( $file_path ) ? size_format( filesize( $file_path ) ) : '-'; ?>
ID ) ) ); ?> ID ) ); ?> ✓ ✗
-

  • -
  • -
  • -
  • -
  • -
  • -
'attachment', 'post_mime_type' => 'application/pdf', 'posts_per_page' => -1, 'post_status' => 'inherit', ) ); } /** * AJAX handler for scanning a single PDF. */ public function ajax_scan_pdf() { check_ajax_referer( 'wpag_pdf_scan', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized', 'accessibility-guard' ) ) ); } $pdf_id = intval( $_POST['pdf_id'] ?? 0 ); if ( ! $pdf_id ) { wp_send_json_error( array( 'message' => __( 'Invalid PDF ID', 'accessibility-guard' ) ) ); } $result = $this->scan_pdf( $pdf_id ); // Save result $all_results = get_option( 'wpag_pdf_scan_results', array() ); $all_results[ $pdf_id ] = $result; update_option( 'wpag_pdf_scan_results', $all_results ); wp_send_json_success( $result ); } /** * AJAX handler for scanning all PDFs. */ public function ajax_scan_all_pdfs() { check_ajax_referer( 'wpag_pdf_scan', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized', 'accessibility-guard' ) ) ); } $pdfs = $this->get_all_pdfs(); $all_results = array(); foreach ( $pdfs as $pdf ) { $all_results[ $pdf->ID ] = $this->scan_pdf( $pdf->ID ); } update_option( 'wpag_pdf_scan_results', $all_results ); wp_send_json_success( array( 'scanned' => count( $pdfs ) ) ); } /** * Scan a PDF for accessibility issues. * * @param int $pdf_id Attachment ID. * @return array Scan results. */ private function scan_pdf( $pdf_id ) { $file_path = get_attached_file( $pdf_id ); $issues = array(); if ( ! file_exists( $file_path ) ) { return array( 'pdf_id' => $pdf_id, 'scanned' => gmdate( 'Y-m-d H:i:s' ), 'issues' => array( array( 'code' => 'file_not_found', 'message' => __( 'PDF file not found', 'accessibility-guard' ), 'wcag' => '-', ) ), ); } // Read PDF content for analysis // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading local PDF binary $file_content = file_get_contents( $file_path, false, null, 0, 102400 ); // First 100KB if ( false === $file_content ) { return array( 'pdf_id' => $pdf_id, 'scanned' => gmdate( 'Y-m-d H:i:s' ), 'issues' => array( array( 'code' => 'read_error', 'message' => __( 'Could not read PDF file', 'accessibility-guard' ), 'wcag' => '-', ) ), ); } // Check 1: Tagged PDF (look for MarkInfo or StructTreeRoot) $is_tagged = ( strpos( $file_content, '/MarkInfo' ) !== false || strpos( $file_content, '/StructTreeRoot' ) !== false ); if ( ! $is_tagged ) { $issues[] = array( 'code' => 'not_tagged', 'message' => __( 'PDF is not tagged (no document structure for screen readers)', 'accessibility-guard' ), 'wcag' => '1.3.1', 'level' => 'A', ); } // Check 2: Document Title $has_title = preg_match( '/\/Title\s*\(([^)]+)\)/', $file_content, $title_match ) || preg_match( '/\/Title\s*<([^>]+)>/', $file_content, $title_match ); if ( ! $has_title || ( isset( $title_match[1] ) && trim( $title_match[1] ) === '' ) ) { $issues[] = array( 'code' => 'no_title', 'message' => __( 'PDF has no document title in metadata', 'accessibility-guard' ), 'wcag' => '2.4.2', 'level' => 'A', ); } // Check 3: Document Language $has_lang = ( strpos( $file_content, '/Lang' ) !== false ); if ( ! $has_lang ) { $issues[] = array( 'code' => 'no_language', 'message' => __( 'PDF has no language specified', 'accessibility-guard' ), 'wcag' => '3.1.1', 'level' => 'A', ); } // Check 4: Bookmarks/Outlines $has_bookmarks = ( strpos( $file_content, '/Outlines' ) !== false ); $file_size = filesize( $file_path ); // Only warn about bookmarks for larger PDFs (> 100KB) if ( ! $has_bookmarks && false !== $file_size && $file_size > 102400 ) { $issues[] = array( 'code' => 'no_bookmarks', 'message' => __( 'PDF has no bookmarks for navigation (recommended for longer documents)', 'accessibility-guard' ), 'wcag' => '2.4.5', 'level' => 'AA', ); } // Check 5: Text content (not image-only) $has_text = ( strpos( $file_content, '/Font' ) !== false || strpos( $file_content, 'BT' ) !== false ); if ( ! $has_text ) { $issues[] = array( 'code' => 'image_only', 'message' => __( 'PDF appears to be image-only (no selectable text)', 'accessibility-guard' ), 'wcag' => '1.4.5', 'level' => 'AA', ); } // Check 6: File size warning if ( false !== $file_size && $file_size > 10485760 ) { // > 10MB $issues[] = array( 'code' => 'large_file', 'message' => sprintf( /* translators: %s: file size */ __( 'PDF file is very large (%s) - consider optimizing for faster download', 'accessibility-guard' ), size_format( $file_size ) ), 'wcag' => '2.2.1', 'level' => 'A', ); } // Check 7: Encryption/Security (may prevent assistive tech) $is_encrypted = ( strpos( $file_content, '/Encrypt' ) !== false ); if ( $is_encrypted ) { $issues[] = array( 'code' => 'encrypted', 'message' => __( 'PDF is encrypted - may prevent assistive technology access', 'accessibility-guard' ), 'wcag' => '1.1.1', 'level' => 'A', ); } return array( 'pdf_id' => $pdf_id, 'scanned' => gmdate( 'Y-m-d H:i:s' ), 'file_size' => $file_size, 'is_tagged' => $is_tagged, 'has_title' => $has_title, 'has_lang' => $has_lang, 'issues' => $issues, ); } // ========================================================================= // CUSTOM CHECK RULES // ========================================================================= /** * Render Custom Rules page. */ public function render_custom_rules_page() { $custom_rules = get_option( 'wpag_custom_rules', array() ); ?>

()

$rule ) : ?>
/>
'#dc3545', 'warning' => '#ffc107', 'notice' => '#17a2b8', ); $color = $severity_colors[ $rule['severity'] ] ?? '#666'; ?>

  • click here<"', 'accessibility-guard' ); ?>
  • ]*>\\s*"', 'accessibility-guard' ); ?>
__( 'Unauthorized', 'accessibility-guard' ) ) ); } $rule_name = sanitize_text_field( wp_unslash( $_POST['rule_name'] ?? '' ) ); $rule_type = sanitize_text_field( wp_unslash( $_POST['rule_type'] ?? '' ) ); $rule_message = sanitize_textarea_field( wp_unslash( $_POST['rule_message'] ?? '' ) ); if ( empty( $rule_name ) || empty( $rule_type ) || empty( $rule_message ) ) { wp_send_json_error( array( 'message' => __( 'Please fill in all required fields.', 'accessibility-guard' ) ) ); } $rule = array( 'name' => $rule_name, 'description' => sanitize_textarea_field( wp_unslash( $_POST['rule_description'] ?? '' ) ), 'type' => $rule_type, 'pattern' => sanitize_text_field( wp_unslash( $_POST['rule_pattern'] ?? '' ) ), 'element' => sanitize_text_field( wp_unslash( $_POST['rule_element'] ?? '' ) ), 'attribute' => sanitize_text_field( wp_unslash( $_POST['rule_attribute'] ?? '' ) ), 'severity' => sanitize_text_field( wp_unslash( $_POST['rule_severity'] ?? 'warning' ) ), 'wcag' => sanitize_text_field( wp_unslash( $_POST['rule_wcag'] ?? '' ) ), 'message' => $rule_message, 'enabled' => true, 'created' => gmdate( 'Y-m-d H:i:s' ), ); $custom_rules = get_option( 'wpag_custom_rules', array() ); $rule_id = 'rule_' . wp_generate_password( 8, false ); $custom_rules[ $rule_id ] = $rule; update_option( 'wpag_custom_rules', $custom_rules ); wp_send_json_success( array( 'rule_id' => $rule_id ) ); } /** * AJAX handler for deleting a custom rule. */ public function ajax_delete_custom_rule() { check_ajax_referer( 'wpag_custom_rules', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized', 'accessibility-guard' ) ) ); } $rule_id = sanitize_text_field( wp_unslash( $_POST['rule_id'] ?? '' ) ); if ( empty( $rule_id ) ) { wp_send_json_error( array( 'message' => __( 'Invalid rule ID.', 'accessibility-guard' ) ) ); } $custom_rules = get_option( 'wpag_custom_rules', array() ); if ( isset( $custom_rules[ $rule_id ] ) ) { unset( $custom_rules[ $rule_id ] ); update_option( 'wpag_custom_rules', $custom_rules ); } wp_send_json_success(); } /** * AJAX handler for toggling a custom rule. */ public function ajax_toggle_custom_rule() { check_ajax_referer( 'wpag_custom_rules', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized', 'accessibility-guard' ) ) ); } $rule_id = sanitize_text_field( wp_unslash( $_POST['rule_id'] ?? '' ) ); $enabled = intval( $_POST['enabled'] ?? 1 ); $custom_rules = get_option( 'wpag_custom_rules', array() ); if ( isset( $custom_rules[ $rule_id ] ) ) { $custom_rules[ $rule_id ]['enabled'] = (bool) $enabled; update_option( 'wpag_custom_rules', $custom_rules ); } wp_send_json_success(); } /** * Add custom checks to the scan. * * @param array $checks Existing checks. * @return array Modified checks. */ public function add_custom_checks( $checks ) { $custom_rules = get_option( 'wpag_custom_rules', array() ); foreach ( $custom_rules as $rule_id => $rule ) { if ( empty( $rule['enabled'] ) ) { continue; } $checks[ 'custom_' . $rule_id ] = array( 'title' => $rule['name'], 'description' => $rule['message'], 'wcag' => $rule['wcag'] ?: 'custom', 'level' => $this->get_wcag_level( $rule['wcag'] ), 'severity' => $rule['severity'], 'callback' => function( $content ) use ( $rule ) { return $this->run_custom_check( $content, $rule ); }, ); } return $checks; } /** * Run a custom check on content. * * @param string $content The content to check. * @param array $rule The rule configuration. * @return array Array of issues found. */ private function run_custom_check( $content, $rule ) { $issues = array(); switch ( $rule['type'] ) { case 'contains': if ( stripos( $content, $rule['pattern'] ) !== false ) { $issues[] = array( 'element' => $rule['pattern'], 'message' => $rule['message'], ); } break; case 'not_contains': if ( stripos( $content, $rule['pattern'] ) === false ) { $issues[] = array( 'element' => $rule['pattern'], 'message' => $rule['message'], ); } break; case 'regex': // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- User-defined regex may be invalid $match_result = @preg_match( '#' . $rule['pattern'] . '#i', $content, $matches ); if ( $match_result === 1 ) { $issues[] = array( 'element' => $matches[0] ?? '', 'message' => $rule['message'], ); } break; case 'html_element': if ( ! empty( $rule['element'] ) && ! empty( $rule['attribute'] ) ) { $pattern = '/<' . preg_quote( $rule['element'], '/' ) . '(?![^>]*' . preg_quote( $rule['attribute'], '/' ) . '=)[^>]*>/i'; if ( preg_match( $pattern, $content, $matches ) ) { $issues[] = array( 'element' => $matches[0] ?? '', 'message' => $rule['message'], ); } } break; case 'html_attr_empty': if ( ! empty( $rule['element'] ) && ! empty( $rule['attribute'] ) ) { $pattern = '/<' . preg_quote( $rule['element'], '/' ) . '[^>]*' . preg_quote( $rule['attribute'], '/' ) . '=["\']["\'][^>]*>/i'; if ( preg_match( $pattern, $content, $matches ) ) { $issues[] = array( 'element' => $matches[0] ?? '', 'message' => $rule['message'], ); } } break; } return $issues; } /** * Get WCAG level from criterion number. * * @param string $wcag WCAG criterion. * @return string Level (A, AA, or AAA). */ private function get_wcag_level( $wcag ) { $level_a = array( '1.1.1', '1.2.1', '1.2.2', '1.2.3', '1.3.1', '1.3.2', '1.3.3', '2.1.1', '2.1.2', '2.1.4', '2.2.1', '2.2.2', '2.3.1', '2.4.1', '2.4.2', '2.4.3', '2.4.4', '2.5.1', '2.5.2', '2.5.3', '2.5.4', '3.1.1', '3.2.1', '3.2.2', '3.3.1', '3.3.2', '4.1.1', '4.1.2' ); $level_aa = array( '1.2.4', '1.2.5', '1.3.4', '1.3.5', '1.4.3', '1.4.4', '1.4.5', '1.4.10', '1.4.11', '1.4.12', '1.4.13', '2.4.5', '2.4.6', '2.4.7', '2.5.7', '2.5.8', '3.1.2', '3.2.3', '3.2.4', '3.3.3', '3.3.4', '4.1.3' ); if ( in_array( $wcag, $level_a, true ) ) { return 'A'; } if ( in_array( $wcag, $level_aa, true ) ) { return 'AA'; } return 'AAA'; } }