WP_REST_Server::READABLE, 'callback' => [ $this, 'get_dashboard' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); register_rest_route( self::NAMESPACE, '/settings', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_settings' ], 'permission_callback' => [ $this, 'can_manage' ], ], [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'update_settings' ], 'permission_callback' => [ $this, 'can_manage' ], ], ] ); register_rest_route( self::NAMESPACE, '/scans', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'list_scans' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'limit' => [ 'type' => 'integer', 'default' => 20 ], 'offset' => [ 'type' => 'integer', 'default' => 0 ], ], ], [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'create_scan' ], 'permission_callback' => [ $this, 'can_manage' ], 'args' => [ 'url' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => 'esc_url_raw', ], ], ], ] ); register_rest_route( self::NAMESPACE, '/scans/(?P\d+)', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'get_scan' ], 'permission_callback' => [ $this, 'can_manage' ], ], [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $this, 'delete_scan' ], 'permission_callback' => [ $this, 'can_manage' ], ], ] ); register_rest_route( self::NAMESPACE, '/scans/(?P\d+)/results', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'submit_results' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); register_rest_route( self::NAMESPACE, '/statement/preview', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'preview_statement' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); register_rest_route( self::NAMESPACE, '/statement', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [ $this, 'list_statements' ], 'permission_callback' => [ $this, 'can_manage' ], ], [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'save_statement' ], 'permission_callback' => [ $this, 'can_manage' ], ], ] ); } public function can_manage(): bool { return current_user_can( Menu::CAPABILITY ); } // ----------------------------------------------------------------- // Dashboard // ----------------------------------------------------------------- public function get_dashboard(): WP_REST_Response { $summary = (array) get_option( 'wpag_last_scan_summary', [] ); return new WP_REST_Response( [ 'site' => [ 'url' => home_url( '/' ), 'name' => get_bloginfo( 'name' ), ], 'lastScan' => $summary, 'totalScans' => $this->scans->count(), 'pluginVersion' => WPAG_VERSION, ] ); } // ----------------------------------------------------------------- // Settings // ----------------------------------------------------------------- public function get_settings(): WP_REST_Response { return new WP_REST_Response( $this->settings_defaults( (array) get_option( 'wpag_settings', [] ) ) ); } public function update_settings( WP_REST_Request $request ): WP_REST_Response { $incoming = $request->get_json_params(); if ( ! is_array( $incoming ) ) { return new WP_REST_Response( [ 'error' => 'invalid_payload' ], 400 ); } $current = $this->settings_defaults( (array) get_option( 'wpag_settings', [] ) ); $merged = array_replace_recursive( $current, $this->sanitize_settings( $incoming ) ); update_option( 'wpag_settings', $merged ); return new WP_REST_Response( $merged ); } /** * @param array $input * @return array */ private function sanitize_settings( array $input ): array { $out = []; if ( isset( $input['wcag_level'] ) ) { $level = strtoupper( (string) $input['wcag_level'] ); $out['wcag_level'] = in_array( $level, [ 'A', 'AA', 'AAA' ], true ) ? $level : 'AA'; } if ( isset( $input['widget'] ) && is_array( $input['widget'] ) ) { $out['widget'] = [ 'enabled' => ! empty( $input['widget']['enabled'] ), 'position' => in_array( $input['widget']['position'] ?? '', [ 'bottom-right', 'bottom-left', 'top-right', 'top-left' ], true ) ? $input['widget']['position'] : 'bottom-right', ]; } if ( isset( $input['statement'] ) && is_array( $input['statement'] ) ) { $lang = (string) ( $input['statement']['language'] ?? 'en' ); $out['statement'] = [ 'language' => in_array( $lang, StatementGenerator::SUPPORTED_LANGUAGES, true ) ? $lang : 'en', 'contact_email' => sanitize_email( (string) ( $input['statement']['contact_email'] ?? '' ) ), ]; } return $out; } /** * @param array $stored * @return array */ private function settings_defaults( array $stored ): array { $defaults = [ 'wcag_level' => 'AA', 'widget' => [ 'enabled' => false, 'position' => 'bottom-right', ], 'statement' => [ 'language' => $this->guess_language(), 'contact_email' => get_option( 'admin_email' ), ], ]; return array_replace_recursive( $defaults, $stored ); } private function guess_language(): string { $locale = strtolower( substr( get_locale(), 0, 2 ) ); return in_array( $locale, StatementGenerator::SUPPORTED_LANGUAGES, true ) ? $locale : 'en'; } // ----------------------------------------------------------------- // Scans // ----------------------------------------------------------------- public function list_scans( WP_REST_Request $request ): WP_REST_Response { $limit = (int) $request->get_param( 'limit' ); $offset = (int) $request->get_param( 'offset' ); return new WP_REST_Response( [ 'items' => $this->scans->list( $limit ?: 20, $offset ), 'total' => $this->scans->count(), ] ); } public function create_scan( WP_REST_Request $request ): WP_REST_Response|WP_Error { $url = (string) $request->get_param( 'url' ); if ( '' === $url || ! wp_http_validate_url( $url ) ) { return new WP_Error( 'wpag_invalid_url', __( 'Please provide a valid URL.', 'accessibility-guard-eaa-toolkit' ), [ 'status' => 400 ] ); } if ( ! $this->is_same_origin( $url ) ) { return new WP_Error( 'wpag_cross_origin', __( 'Only URLs from this site can be scanned (browser sandbox).', 'accessibility-guard-eaa-toolkit' ), [ 'status' => 400 ] ); } $settings = $this->settings_defaults( (array) get_option( 'wpag_settings', [] ) ); $created = $this->scans->create( $url, (string) $settings['wcag_level'] ); return new WP_REST_Response( [ 'id' => $created['id'], 'uuid' => $created['uuid'], 'url' => $url, ], 201 ); } public function get_scan( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = (int) $request['id']; $scan = $this->scans->find( $id ); if ( ! $scan ) { return new WP_Error( 'wpag_not_found', __( 'Scan not found.', 'accessibility-guard-eaa-toolkit' ), [ 'status' => 404 ] ); } return new WP_REST_Response( array_merge( $scan, [ 'issues' => $this->scans->issues_for( $id ), 'breakdown' => $this->scans->impact_breakdown_for( $id ), 'comparison' => $this->comparison_for_scan( $scan ), ] ) ); } public function delete_scan( WP_REST_Request $request ): WP_REST_Response { $id = (int) $request['id']; $this->scans->delete( $id ); return new WP_REST_Response( [ 'deleted' => true ] ); } /** * Receives axe-core JSON results from the browser scanner and persists them. */ public function submit_results( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = (int) $request['id']; $payload = $request->get_json_params(); if ( ! is_array( $payload ) ) { return new WP_Error( 'wpag_invalid_payload', __( 'Invalid payload.', 'accessibility-guard-eaa-toolkit' ), [ 'status' => 400 ] ); } if ( ! $this->scans->find( $id ) ) { return new WP_Error( 'wpag_not_found', __( 'Scan not found.', 'accessibility-guard-eaa-toolkit' ), [ 'status' => 404 ] ); } if ( ! empty( $payload['error'] ) ) { $this->scans->fail( $id, (string) $payload['error'] ); return new WP_REST_Response( [ 'status' => 'failed' ] ); } $violations = is_array( $payload['violations'] ?? null ) ? $payload['violations'] : []; $passes = is_array( $payload['passes'] ?? null ) ? count( $payload['passes'] ) : 0; $issues = []; foreach ( $violations as $v ) { $nodes = is_array( $v['nodes'] ?? null ) ? $v['nodes'] : []; foreach ( $nodes as $node ) { $selector = ''; if ( isset( $node['target'] ) && is_array( $node['target'] ) ) { $first = $node['target'][0] ?? ''; $selector = is_array( $first ) ? implode( ' > ', $first ) : (string) $first; } $issues[] = [ 'rule_id' => (string) ( $v['id'] ?? '' ), 'impact' => (string) ( $v['impact'] ?? $node['impact'] ?? 'minor' ), 'selector' => $selector, 'html' => (string) ( $node['html'] ?? '' ), 'help_url' => (string) ( $v['helpUrl'] ?? '' ), ]; } } $total = count( $issues ) + $passes; $score = $total > 0 ? (int) round( ( $passes / $total ) * 100 ) : 100; $this->scans->complete( $id, $score, $issues, [ 'engine' => $payload['testEngine'] ?? null, 'runner' => $payload['testRunner'] ?? null, 'env' => $payload['testEnvironment'] ?? null, ] ); return new WP_REST_Response( [ 'status' => 'completed', 'score' => $score, 'issuesCount' => count( $issues ), ] ); } private function is_same_origin( string $url ): bool { $site = wp_parse_url( home_url( '/' ) ); $test = wp_parse_url( $url ); return isset( $site['host'], $test['host'] ) && strcasecmp( $site['host'], $test['host'] ) === 0; } /** * @param array $scan * @return array|null */ private function comparison_for_scan( array $scan ): ?array { $previous = $this->scans->previous_completed_for_url( (string) $scan['url'], (int) $scan['id'] ); if ( ! $previous ) { return null; } $current_score = isset( $scan['score'] ) ? (int) $scan['score'] : null; $previous_score = isset( $previous['score'] ) ? (int) $previous['score'] : null; $current_issues = (int) ( $scan['issuesCount'] ?? 0 ); $previous_issues = (int) ( $previous['issuesCount'] ?? 0 ); return [ 'previousScanId' => (int) $previous['id'], 'previousDate' => $previous['completedAt'] ?? $previous['createdAt'], 'previousScore' => $previous_score, 'currentScore' => $current_score, 'scoreDelta' => null !== $current_score && null !== $previous_score ? $current_score - $previous_score : null, 'previousIssues' => $previous_issues, 'currentIssues' => $current_issues, 'issuesDelta' => $current_issues - $previous_issues, ]; } // ----------------------------------------------------------------- // Statement // ----------------------------------------------------------------- public function preview_statement( WP_REST_Request $request ): WP_REST_Response { $settings = $this->settings_defaults( (array) get_option( 'wpag_settings', [] ) ); $language = (string) ( $request->get_param( 'language' ) ?: $settings['statement']['language'] ); $summary = (array) get_option( 'wpag_last_scan_summary', [] ); $status = 'partial'; if ( ! empty( $summary['score'] ) ) { $status = $summary['score'] >= 95 ? 'compliant' : ( $summary['score'] < 60 ? 'non-compliant' : 'partial' ); } $html = $this->statement_generator->render( $language, [ 'site_name' => (string) get_bloginfo( 'name' ), 'site_url' => (string) home_url( '/' ), 'contact_email' => (string) ( $settings['statement']['contact_email'] ?? get_option( 'admin_email' ) ), 'status' => $status, 'score' => $summary['score'] ?? null, 'issues' => $summary['issues'] ?? null, 'last_scan' => isset( $summary['at'] ) ? gmdate( 'Y-m-d', strtotime( (string) $summary['at'] ) ) : null, ] ); return new WP_REST_Response( [ 'language' => $language, 'status' => $status, 'html' => $html, ] ); } public function list_statements(): WP_REST_Response { return new WP_REST_Response( [ 'items' => $this->statements->list_recent( 20 ) ] ); } public function save_statement( WP_REST_Request $request ): WP_REST_Response { $settings = $this->settings_defaults( (array) get_option( 'wpag_settings', [] ) ); $language = (string) ( $request->get_param( 'language' ) ?: $settings['statement']['language'] ); $summary = (array) get_option( 'wpag_last_scan_summary', [] ); $status = 'partial'; if ( ! empty( $summary['score'] ) ) { $status = $summary['score'] >= 95 ? 'compliant' : ( $summary['score'] < 60 ? 'non-compliant' : 'partial' ); } $id = $this->statements->save( $language, $status ); return new WP_REST_Response( [ 'id' => $id, 'language' => $language, 'status' => $status, ], 201 ); } }