".
* sanitize_text_field() would strip those angle-bracketed tokens, garbling the title.
* This helper validates UTF-8 and removes control characters but preserves all printable text.
*/
private static function sanitize_issue_text( string $text ): string {
$text = wp_check_invalid_utf8( $text );
// Remove ASCII control characters (except tab/newline/carriage-return).
$text = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text );
return substr( trim( $text ), 0, 1000 );
}
public static function register(): void {
add_action( 'rest_api_init', [ self::class, 'register_routes' ] );
}
public static function register_routes(): void {
// GET /wp-json/accessmate/v1/settings
register_rest_route( self::$ns, '/settings', [
'methods' => WP_REST_Server::READABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_get_settings' ],
]);
// POST /wp-json/accessmate/v1/settings
register_rest_route( self::$ns, '/settings', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_save_settings' ],
'args' => [
'ai_provider' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'none' ],
'ai_key' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' ],
'ai_model' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' ],
'scan_schedule' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'weekly' ],
'default_rulesets' => [
'type' => 'array',
'items' => [ 'type' => 'string' ],
'default' => [ 'wcag22aa' ],
],
'email_alerts' => [ 'type' => 'boolean', 'default' => true ],
],
]);
// POST /wp-json/accessmate/v1/ai/test — test connection with current form values (unsaved)
register_rest_route( self::$ns, '/ai/test', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_ai_test' ],
'args' => [
'ai_provider' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => true ],
'ai_key' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' ],
'ai_model' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '' ],
],
]);
// POST /wp-json/accessmate/v1/ai/proxy
register_rest_route( self::$ns, '/ai/proxy', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_ai_proxy' ],
'args' => [
'task' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => true ],
'params' => [ 'type' => 'object', 'default' => [] ],
],
]);
// GET /wp-json/accessmate/v1/scan-history
register_rest_route( self::$ns, '/scan-history', [
'methods' => WP_REST_Server::READABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_scan_history' ],
]);
// GET /wp-json/accessmate/v1/widget-config
register_rest_route( self::$ns, '/widget-config', [
'methods' => WP_REST_Server::READABLE,
'permission_callback' => '__return_true', // public — widget reads this on front end
'callback' => [ self::class, 'handle_get_widget_config' ],
]);
// POST /wp-json/accessmate/v1/widget-config
register_rest_route( self::$ns, '/widget-config', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_save_widget_config' ],
'args' => [
'enabled' => [ 'type' => 'boolean', 'default' => false ],
'hide_powered_by' => [ 'type' => 'boolean', 'default' => true ],
'color' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_hex_color', 'default' => '#3858e9' ],
'position' => [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => 'bottom-right',
'enum' => [ 'top-left', 'top-right', 'bottom-left', 'bottom-right' ],
],
'button_size' => [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => 'medium',
'enum' => [ 'small', 'medium', 'large' ],
],
'features' => [ 'type' => 'array', 'default' => [] ],
'offset_vertical' => [ 'type' => 'integer', 'default' => 15 ],
'offset_horizontal' => [ 'type' => 'integer', 'default' => 15 ],
],
]);
// POST /wp-json/accessmate/v1/scan
register_rest_route( self::$ns, '/scan', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_scan' ],
'args' => [
'url' => [
'type' => 'string',
'sanitize_callback' => 'esc_url_raw',
'default' => '',
],
],
]);
// GET /wp-json/accessmate/v1/issues
register_rest_route( self::$ns, '/issues', [
'methods' => WP_REST_Server::READABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_issues' ],
]);
// GET /wp-json/accessmate/v1/report
register_rest_route( self::$ns, '/report', [
'methods' => WP_REST_Server::READABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_report' ],
]);
// POST /wp-json/accessmate/v1/axe-scan — store browser-computed axe-core results
register_rest_route( self::$ns, '/axe-scan', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_axe_scan' ],
'args' => [
'url' => [ 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', 'required' => true ],
'score' => [ 'type' => 'integer', 'default' => 0 ],
'grade' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'F' ],
'passed' => [ 'type' => 'integer', 'default' => 0 ],
'failed' => [ 'type' => 'integer', 'default' => 0 ],
'issues' => [ 'type' => 'array', 'default' => [] ],
'rulesets' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ], 'default' => [] ],
'passed_checks' => [ 'type' => 'array', 'default' => [] ],
],
]);
// GET /wp-json/accessmate/v1/scannable-urls
register_rest_route( self::$ns, '/scannable-urls', [
'methods' => WP_REST_Server::READABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_scannable_urls' ],
]);
// POST /wp-json/accessmate/v1/scan-page
register_rest_route( self::$ns, '/scan-page', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_scan_page' ],
'args' => [
'url' => [
'type' => 'string',
'sanitize_callback' => 'esc_url_raw',
'required' => true,
],
],
]);
// GET /wp-json/accessmate/v1/page-scans
register_rest_route( self::$ns, '/page-scans', [
'methods' => WP_REST_Server::READABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_page_scans' ],
]);
// ── Scheduled-scan due/lock/queue state (Pro only — see AccessMate_Pro) ──
// WP-Cron only marks a scan "due"; an admin session claims it here and
// drives it through the same axe-core pipeline as Bulk/Individual scans.
// GET /wp-json/accessmate/v1/scheduled-scan/status
register_rest_route( self::$ns, '/scheduled-scan/status', [
'methods' => WP_REST_Server::READABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_scheduled_scan_status' ],
]);
// POST /wp-json/accessmate/v1/scheduled-scan/claim
register_rest_route( self::$ns, '/scheduled-scan/claim', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_scheduled_scan_claim' ],
]);
// POST /wp-json/accessmate/v1/scheduled-scan/progress
register_rest_route( self::$ns, '/scheduled-scan/progress', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_scheduled_scan_progress' ],
'args' => [
'token' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => true ],
'cursor' => [ 'type' => 'integer', 'required' => true ],
],
]);
// POST /wp-json/accessmate/v1/scheduled-scan/complete
register_rest_route( self::$ns, '/scheduled-scan/complete', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_scheduled_scan_complete' ],
'args' => [
'token' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => true ],
],
]);
// GET /wp-json/accessmate/v1/media-scan — last persisted scan, if any
register_rest_route( self::$ns, '/media-scan', [
'methods' => WP_REST_Server::READABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_media_scan_get' ],
]);
// POST /wp-json/accessmate/v1/media-scan/start — begins a fresh chunked scan
register_rest_route( self::$ns, '/media-scan/start', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_media_scan_start' ],
]);
// POST /wp-json/accessmate/v1/media-scan/chunk — scans the next batch
register_rest_route( self::$ns, '/media-scan/chunk', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_media_scan_chunk' ],
]);
// POST /wp-json/accessmate/v1/media-alt-text
register_rest_route( self::$ns, '/media-alt-text', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_media_alt_text' ],
'args' => [
'attachment_id' => [ 'type' => 'integer', 'required' => true ],
'alt_text' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'required' => true ],
],
]);
// GET /wp-json/accessmate/v1/statement
register_rest_route( self::$ns, '/statement', [
'methods' => WP_REST_Server::READABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_get_statement' ],
]);
// POST /wp-json/accessmate/v1/statement
register_rest_route( self::$ns, '/statement', [
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => fn() => current_user_can( 'manage_options' ),
'callback' => [ self::class, 'handle_save_statement' ],
]);
}
public static function handle_scan( WP_REST_Request $request ): WP_REST_Response {
$url = $request->get_param( 'url' );
if ( empty( $url ) ) {
$url = get_site_url();
}
if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
return new WP_REST_Response( [ 'error' => __( 'Invalid URL provided.', 'accessmate' ) ], 400 );
}
if ( wp_parse_url( $url, PHP_URL_HOST ) !== wp_parse_url( home_url(), PHP_URL_HOST ) ) {
return new WP_REST_Response( [ 'error' => __( 'URL must belong to this site.', 'accessmate' ) ], 400 );
}
$result = AccessMate_Scanner::run( $url );
$status = isset( $result['error'] ) ? 422 : 200;
return new WP_REST_Response( $result, $status );
}
public static function handle_issues( WP_REST_Request $request ): WP_REST_Response {
$scan = get_option( 'accessmate_last_scan', [] );
// Always return an array — never null.
$issues = $scan['issues'] ?? [];
return new WP_REST_Response( $issues, 200 );
}
public static function handle_report( WP_REST_Request $request ): WP_REST_Response {
$report = AccessMate_Report::get();
return new WP_REST_Response( $report, 200 );
}
public static function handle_get_settings( WP_REST_Request $request ): WP_REST_Response {
return new WP_REST_Response( AccessMate_Settings::get(), 200 );
}
public static function handle_save_settings( WP_REST_Request $request ): WP_REST_Response {
$provider = $request->get_param( 'ai_provider' );
$raw_key = $request->get_param( 'ai_key' );
$model = $request->get_param( 'ai_model' );
$schedule = $request->get_param( 'scan_schedule' );
$default_rulesets = array_map( 'sanitize_text_field', (array) $request->get_param( 'default_rulesets' ) );
$email_alerts = (bool) $request->get_param( 'email_alerts' );
$valid_providers = array_merge( [ 'none' ], array_keys( AccessMate_Providers::all() ) );
$valid_schedules = [ 'daily', 'weekly', 'monthly' ];
if ( ! in_array( $provider, $valid_providers, true ) ) {
return new WP_REST_Response( [ 'error' => __( 'Invalid provider.', 'accessmate' ) ], 400 );
}
if ( ! in_array( $schedule, $valid_schedules, true ) ) {
return new WP_REST_Response( [ 'error' => __( 'Invalid schedule.', 'accessmate' ) ], 400 );
}
// Daily scans and email alerts are Pro-only — downgrade silently rather than error,
// since a stale/unsaved form (e.g. after a licence lapses) shouldn't block saving.
if ( ! AccessMate_Licensing::is_premium() ) {
if ( $schedule === 'daily' ) {
$schedule = 'weekly';
}
$email_alerts = false;
}
// Reject if a key-requiring provider is selected but no key is provided or stored.
if ( $provider !== 'none' ) {
$provider_def = AccessMate_Providers::get( $provider );
if ( $provider_def && $provider_def['auth'] !== null ) {
$current = get_option( 'accessmate_settings', [] );
if ( empty( $raw_key ) && empty( $current['ai_key'] ) ) {
return new WP_REST_Response(
[ 'error' => __( 'An API key is required for the selected provider.', 'accessmate' ) ],
400
);
}
}
}
$old_schedule = AccessMate_Settings::get()['scan_schedule'] ?? 'weekly';
AccessMate_Settings::save( $provider, $raw_key, $model, $schedule, $default_rulesets, $email_alerts );
// Reschedule cron if the scan frequency changed.
if ( $schedule !== $old_schedule ) {
AccessMate_Scheduler::reschedule( $schedule );
}
return new WP_REST_Response( [ 'saved' => true ], 200 );
}
public static function handle_scan_history( WP_REST_Request $request ): WP_REST_Response {
return new WP_REST_Response( AccessMate_Report::get_history(), 200 );
}
public static function handle_get_widget_config( WP_REST_Request $request ): WP_REST_Response {
$defaults = AccessMate_Widget_Injector::defaults();
$config = get_option( 'accessmate_widget_config', $defaults );
return new WP_REST_Response( array_merge( $defaults, (array) $config ), 200 );
}
public static function handle_save_widget_config( WP_REST_Request $request ): WP_REST_Response {
$allowed_features = [ 'font', 'contrast', 'dyslexia', 'grayscale', 'reading-guide', 'animations', 'line-height', 'cursor', 'links', 'screen-mask' ];
$features = array_values( array_filter(
array_map( 'sanitize_text_field', (array) $request->get_param( 'features' ) ),
fn( $f ) => in_array( $f, $allowed_features, true )
));
$valid_sizes = [ 'small', 'medium', 'large' ];
$button_size = sanitize_text_field( $request->get_param( 'button_size' ) ?? 'medium' );
if ( ! in_array( $button_size, $valid_sizes, true ) ) {
$button_size = 'medium';
}
update_option( 'accessmate_widget_config', [
'enabled' => (bool) $request->get_param( 'enabled' ),
'color' => sanitize_hex_color( $request->get_param( 'color' ) ) ?? '#3858e9',
'position' => sanitize_text_field( $request->get_param( 'position' ) ),
'button_size' => $button_size,
'features' => $features,
'hide_powered_by' => (bool) $request->get_param( 'hide_powered_by' ),
'offset_vertical' => max( 0, min( 500, (int) $request->get_param( 'offset_vertical' ) ) ),
'offset_horizontal' => max( 0, min( 500, (int) $request->get_param( 'offset_horizontal' ) ) ),
]);
return new WP_REST_Response( [ 'saved' => true ], 200 );
}
public static function handle_axe_scan( WP_REST_Request $request ): WP_REST_Response {
$url = $request->get_param( 'url' );
$page_title = sanitize_text_field( (string) ( $request->get_param( 'page_title' ) ?? '' ) );
$score = (int) $request->get_param( 'score' );
$grade = $request->get_param( 'grade' );
$passed = (int) $request->get_param( 'passed' );
$failed = (int) $request->get_param( 'failed' );
$rulesets = array_map( 'sanitize_text_field', (array) $request->get_param( 'rulesets' ) );
$rulesets = array_values( array_unique( array_intersect( $rulesets, AccessMate_Settings::VALID_RULESETS ) ) );
// Sanitise the issues array coming from the browser.
$clean_issues = [];
$allowed_severities = [ 'critical', 'serious', 'warning', 'notice' ];
$allowed_categories = [ 'wcag', 'best-practice' ];
foreach ( (array) $request->get_param( 'issues' ) as $iss ) {
if ( ! is_array( $iss ) ) continue;
$sev = sanitize_text_field( $iss['severity'] ?? 'notice' );
$cat = sanitize_text_field( $iss['category'] ?? 'wcag' );
// Validate help_url: must be a Deque University or W3C URL.
$raw_help_url = $iss['help_url'] ?? '';
$allowed_hosts = [ 'dequeuniversity.com', 'www.w3.org', 'www.dequeuniversity.com' ];
$parsed_host = wp_parse_url( $raw_help_url, PHP_URL_HOST );
$safe_help_url = in_array( $parsed_host, $allowed_hosts, true )
? esc_url_raw( $raw_help_url )
: '';
$clean_nodes = [];
foreach ( (array) ( $iss['nodes'] ?? [] ) as $node ) {
if ( ! is_array( $node ) ) continue;
$clean_nodes[] = [
'element' => self::sanitize_issue_text( $node['element'] ?? '' ),
'selector' => sanitize_text_field( $node['selector'] ?? '' ),
];
}
$clean_issues[] = [
'id' => sanitize_text_field( $iss['id'] ?? '' ),
'rule' => sanitize_text_field( $iss['rule'] ?? '' ),
'wcag' => sanitize_text_field( $iss['wcag'] ?? '' ),
'wcag_level' => sanitize_text_field( $iss['wcag_level'] ?? '' ),
'wcag_standard' => sanitize_text_field( $iss['wcag_standard'] ?? '' ),
'severity' => in_array( $sev, $allowed_severities, true ) ? $sev : 'notice',
'category' => in_array( $cat, $allowed_categories, true ) ? $cat : 'wcag',
'title' => self::sanitize_issue_text( $iss['title'] ?? '' ),
'description' => self::sanitize_issue_text( $iss['description'] ?? '' ),
'element' => self::sanitize_issue_text( $iss['element'] ?? '' ),
'selector' => sanitize_text_field( $iss['selector'] ?? '' ),
'nodes' => $clean_nodes,
'count' => (int) ( $iss['count'] ?? 1 ),
'fixed' => false,
'ai_fixable' => (bool) ( $iss['ai_fixable'] ?? false ),
'help_url' => $safe_help_url,
];
}
// Sanitise the passed-check summaries coming from the browser.
$allowed_help_hosts = [ 'dequeuniversity.com', 'www.w3.org', 'www.dequeuniversity.com' ];
$clean_passed_checks = [];
foreach ( (array) $request->get_param( 'passed_checks' ) as $chk ) {
if ( ! is_array( $chk ) ) continue;
$raw_help_url = $chk['help_url'] ?? '';
$parsed_host = wp_parse_url( $raw_help_url, PHP_URL_HOST );
$clean_passed_checks[] = [
'id' => sanitize_text_field( $chk['id'] ?? '' ),
'title' => self::sanitize_issue_text( $chk['title'] ?? '' ),
'description' => self::sanitize_issue_text( $chk['description'] ?? '' ),
'help_url' => in_array( $parsed_host, $allowed_help_hosts, true ) ? esc_url_raw( $raw_help_url ) : '',
'count' => (int) ( $chk['count'] ?? 0 ),
];
}
// Resolved once here and persisted below so later reads (handle_page_scans)
// don't need to re-run url_to_postid()/get_post_type() for every stored
// scan on every request.
$post_type = self::resolve_post_type_for_url( $url );
// CPTs are Pro-only and blocked outright for Free — Pages + Posts are unlimited.
if ( ! AccessMate_Licensing::is_premium() && $post_type && ! in_array( $post_type, self::FREE_ALLOWED_TYPES, true ) ) {
return new WP_REST_Response(
[ 'error' => __( 'Custom post types are available in the Pro version only.', 'accessmate' ) ],
403
);
}
$result = [
'url' => $url,
'page_title' => $page_title,
'scanned_at' => current_time( 'c' ),
'score' => $score,
'grade' => $grade,
'issues' => $clean_issues,
'passed' => $passed,
'failed' => $failed,
'rulesets' => $rulesets,
'passed_checks' => $clean_passed_checks,
'post_type' => $post_type,
];
// Persist as last scan (Dashboard) and per-page scan (PageScanner).
update_option( 'accessmate_last_scan', $result );
AccessMate_Site_Scanner::store_axe_scan( $url, $result );
AccessMate_Scanner::append_history( $result );
return new WP_REST_Response( [ 'saved' => true ], 200 );
}
public static function handle_scannable_urls( WP_REST_Request $request ): WP_REST_Response {
$urls = AccessMate_Site_Scanner::get_scannable_urls();
$search = strtolower( trim( (string) $request->get_param( 'search' ) ) );
if ( $search !== '' ) {
$urls = array_values( array_filter( $urls, static function ( $item ) use ( $search ) {
return strpos( strtolower( $item['label'] ), $search ) !== false
|| strpos( strtolower( $item['url'] ), $search ) !== false;
} ) );
}
return new WP_REST_Response( $urls, 200 );
}
public static function handle_scan_page( WP_REST_Request $request ): WP_REST_Response {
$url = $request->get_param( 'url' );
if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
return new WP_REST_Response( [ 'error' => __( 'Invalid URL.', 'accessmate' ) ], 400 );
}
if ( wp_parse_url( $url, PHP_URL_HOST ) !== wp_parse_url( home_url(), PHP_URL_HOST ) ) {
return new WP_REST_Response( [ 'error' => __( 'URL must belong to this site.', 'accessmate' ) ], 400 );
}
$result = AccessMate_Site_Scanner::scan_page( $url );
$status = isset( $result['error'] ) ? 422 : 200;
return new WP_REST_Response( $result, $status );
}
public static function handle_page_scans( WP_REST_Request $request ): WP_REST_Response {
$scans = AccessMate_Site_Scanner::get_page_scans(); // URL-keyed associative array
$search = strtolower( trim( (string) $request->get_param( 'search' ) ) );
if ( $search !== '' ) {
$scans = array_filter( $scans, static function ( $scan ) use ( $search ) {
return strpos( strtolower( $scan['url'] ?? '' ), $search ) !== false
|| strpos( strtolower( $scan['title'] ?? '' ), $search ) !== false;
} );
}
// Sort by most issues first — preserve URL keys so response serialises as a JSON object.
uasort( $scans, static fn( $a, $b ) => ( $b['failed'] ?? 0 ) <=> ( $a['failed'] ?? 0 ) );
if ( ! AccessMate_Licensing::is_premium() ) {
$scans = array_filter( $scans, static function ( $scan, $scan_url ): bool {
// Scans saved after this change carry their type already, avoiding a
// url_to_postid() lookup per row on every request; older scans (saved
// before 'post_type' existed) fall back to resolving it on the fly.
$post_type = array_key_exists( 'post_type', $scan )
? $scan['post_type']
: self::resolve_post_type_for_url( $scan_url );
// CPT scan — Pro only. Non-post URLs (home, blog, categories) and Pages/Posts pass through unlimited.
return ! ( $post_type && ! in_array( $post_type, self::FREE_ALLOWED_TYPES, true ) );
}, ARRAY_FILTER_USE_BOTH );
}
return new WP_REST_Response( $scans, 200 );
}
public static function handle_scheduled_scan_status( WP_REST_Request $request ): WP_REST_Response {
if ( ! class_exists( 'AccessMate_Pro' ) ) {
return new WP_REST_Response( [ 'status' => 'unavailable' ], 200 );
}
return new WP_REST_Response( AccessMate_Pro::get_scheduled_scan_status(), 200 );
}
public static function handle_scheduled_scan_claim( WP_REST_Request $request ): WP_REST_Response {
if ( ! class_exists( 'AccessMate_Pro' ) ) {
return new WP_REST_Response( [ 'claimed' => false, 'status' => 'unavailable' ], 200 );
}
return new WP_REST_Response( AccessMate_Pro::try_claim_scheduled_scan(), 200 );
}
public static function handle_scheduled_scan_progress( WP_REST_Request $request ): WP_REST_Response {
if ( ! class_exists( 'AccessMate_Pro' ) ) {
return new WP_REST_Response( [ 'ok' => false, 'reason' => 'unavailable' ], 200 );
}
$token = (string) $request->get_param( 'token' );
$cursor = (int) $request->get_param( 'cursor' );
return new WP_REST_Response( AccessMate_Pro::record_scheduled_scan_progress( $token, $cursor ), 200 );
}
public static function handle_scheduled_scan_complete( WP_REST_Request $request ): WP_REST_Response {
if ( ! class_exists( 'AccessMate_Pro' ) ) {
return new WP_REST_Response( [ 'ok' => false, 'reason' => 'unavailable' ], 200 );
}
$token = (string) $request->get_param( 'token' );
return new WP_REST_Response( AccessMate_Pro::complete_scheduled_scan( $token ), 200 );
}
public static function handle_media_scan_get( WP_REST_Request $request ): WP_REST_Response {
if ( ! class_exists( 'AccessMate_Pro' ) ) {
return new WP_REST_Response( [ 'status' => 'unavailable' ], 200 );
}
return new WP_REST_Response( AccessMate_Pro::get_media_scan(), 200 );
}
public static function handle_media_scan_start( WP_REST_Request $request ): WP_REST_Response {
if ( ! class_exists( 'AccessMate_Pro' ) ) {
return new WP_REST_Response( [ 'status' => 'unavailable' ], 200 );
}
return new WP_REST_Response( AccessMate_Pro::start_media_scan(), 200 );
}
public static function handle_media_scan_chunk( WP_REST_Request $request ): WP_REST_Response {
if ( ! class_exists( 'AccessMate_Pro' ) ) {
return new WP_REST_Response( [ 'status' => 'unavailable' ], 200 );
}
return new WP_REST_Response( AccessMate_Pro::scan_media_chunk(), 200 );
}
public static function handle_media_alt_text( WP_REST_Request $request ): WP_REST_Response {
if ( ! class_exists( 'AccessMate_Pro' ) ) {
return new WP_REST_Response( [ 'ok' => false, 'reason' => 'unavailable' ], 200 );
}
$attachment_id = (int) $request->get_param( 'attachment_id' );
$alt_text = (string) $request->get_param( 'alt_text' );
return new WP_REST_Response( AccessMate_Pro::update_attachment_alt_text( $attachment_id, $alt_text ), 200 );
}
public static function handle_ai_test( WP_REST_Request $request ): WP_REST_Response {
$provider = $request->get_param( 'ai_provider' );
$raw_key = $request->get_param( 'ai_key' );
$model = $request->get_param( 'ai_model' );
if ( ! AccessMate_Providers::is_valid( $provider ) ) {
return new WP_REST_Response( [ 'error' => __( 'Invalid provider.', 'accessmate' ) ], 400 );
}
$result = AccessMate_AI_Proxy::test( $provider, $raw_key, $model );
$status = isset( $result['error'] ) ? 400 : 200;
return new WP_REST_Response( $result, $status );
}
public static function handle_ai_proxy( WP_REST_Request $request ): WP_REST_Response {
$task = $request->get_param( 'task' );
$params = (array) ( $request->get_param( 'params' ) ?? [] );
// Sanitise each param value — preserve HTML for fields that carry element snippets.
foreach ( $params as $k => $v ) {
if ( is_string( $v ) ) {
$params[ $k ] = in_array( $k, [ 'html', 'element' ], true )
? wp_kses_post( $v )
: sanitize_text_field( $v );
}
}
$result = AccessMate_AI_Proxy::call( $task, $params );
$status = isset( $result['error'] ) ? 400 : 200;
return new WP_REST_Response( $result, $status );
}
/**
* Maps the site's default WCAG ruleset (Settings tab) onto the closest
* matching Accessibility Statement conformance standard, so a brand-new
* statement's default isn't out of sync with what Settings already says
* the site targets. 'best-practice' isn't a formal conformance standard,
* so it's skipped in favor of any accompanying WCAG/Section 508 standard;
* if that's the only selected ruleset, fall back to the plugin's default.
*/
private static function default_conformance_standard(): string {
$atomic_to_statement = [
'wcag22aa' => 'wcag22-aa',
'wcag21aaa' => 'wcag21-aaa',
'wcag21aa' => 'wcag21-aa',
'wcag21a' => 'wcag21-a',
'wcag20aaa' => 'wcag20-aaa',
'wcag20aa' => 'wcag20-aa',
'wcag20a' => 'wcag20-a',
'section508' => 'section508',
];
foreach ( AccessMate_Settings::get()['default_rulesets'] as $ruleset ) {
if ( isset( $atomic_to_statement[ $ruleset ] ) ) {
return $atomic_to_statement[ $ruleset ];
}
}
return 'wcag21-aa';
}
private static function statement_defaults(): array {
return [
'status' => 'none',
'page_id' => 0,
'page_url' => '',
'edit_url' => '',
'updated_at' => '',
'basic_info' => [ 'org_name' => '', 'website' => '', 'website_name' => '' ],
'efforts' => [ 'selected' => [], 'additional' => [] ],
'conformance' => [ 'standard' => self::default_conformance_standard(), 'custom_standard' => '', 'level' => 'partially-conformant', 'additional_notes' => '' ],
'tech_specs' => [ 'browsers' => [ 'Chrome', 'Firefox', 'Safari', 'Edge' ], 'operating_systems' => [ 'Windows', 'macOS' ], 'assistive_tech' => [], 'technology_reliance' => [ 'HTML', 'CSS', 'JavaScript' ] ],
'feedback' => [ 'phone' => '', 'email' => '', 'address' => '', 'response_duration' => '2 business days' ],
];
}
public static function handle_get_statement( WP_REST_Request $request ): WP_REST_Response {
$stored = get_option( 'accessmate_statement', [] );
$data = array_merge( self::statement_defaults(), (array) $stored );
return new WP_REST_Response( $data, 200 );
}
public static function handle_save_statement( WP_REST_Request $request ): WP_REST_Response {
$body = $request->get_json_params();
if ( ! is_array( $body ) ) {
return new WP_REST_Response( [ 'error' => __( 'Invalid request body.', 'accessmate' ) ], 400 );
}
$stored = (array) get_option( 'accessmate_statement', self::statement_defaults() );
$publish = ! empty( $body['publish'] );
// Sanitise basic_info.
$basic = (array) ( $body['basic_info'] ?? [] );
$stored['basic_info'] = [
'org_name' => sanitize_text_field( $basic['org_name'] ?? '' ),
'website' => esc_url_raw( $basic['website'] ?? '' ),
'website_name' => sanitize_text_field( $basic['website_name'] ?? '' ),
];
// Sanitise efforts.
$efforts = (array) ( $body['efforts'] ?? [] );
$stored['efforts'] = [
'selected' => array_map( 'sanitize_text_field', (array) ( $efforts['selected'] ?? [] ) ),
'additional' => array_map( 'sanitize_text_field', (array) ( $efforts['additional'] ?? [] ) ),
];
// Sanitise conformance.
$conformance = (array) ( $body['conformance'] ?? [] );
$allowed_standards = [
'wcag22-aa',
'wcag21-aaa', 'wcag21-aa', 'wcag21-a',
'wcag20-aaa', 'wcag20-aa', 'wcag20-a',
'section508', 'other',
];
$allowed_levels = [ 'fully-conformant', 'partially-conformant', 'non-conformant', 'not-assessed' ];
$std = sanitize_text_field( $conformance['standard'] ?? 'wcag21-aa' );
$lvl = sanitize_text_field( $conformance['level'] ?? 'partially-conformant' );
$stored['conformance'] = [
'standard' => in_array( $std, $allowed_standards, true ) ? $std : 'wcag21-aa',
'custom_standard' => sanitize_text_field( $conformance['custom_standard'] ?? '' ),
'level' => in_array( $lvl, $allowed_levels, true ) ? $lvl : 'partially-conformant',
'additional_notes' => sanitize_textarea_field( $conformance['additional_notes'] ?? '' ),
];
// Sanitise tech_specs.
$ts = (array) ( $body['tech_specs'] ?? [] );
$stored['tech_specs'] = [
'browsers' => array_map( 'sanitize_text_field', (array) ( $ts['browsers'] ?? [] ) ),
'operating_systems' => array_map( 'sanitize_text_field', (array) ( $ts['operating_systems'] ?? [] ) ),
'assistive_tech' => array_map( 'sanitize_text_field', (array) ( $ts['assistive_tech'] ?? [] ) ),
'technology_reliance'=> array_map( 'sanitize_text_field', (array) ( $ts['technology_reliance']?? [] ) ),
];
// Sanitise feedback.
$fb = (array) ( $body['feedback'] ?? [] );
$email = sanitize_email( $fb['email'] ?? '' );
if ( empty( $email ) ) {
return new WP_REST_Response(
[ 'error' => __( 'A valid e-mail address is required in the Feedback section.', 'accessmate' ) ],
400
);
}
$stored['feedback'] = [
'phone' => preg_replace( '/[^0-9+\-() .]/', '', sanitize_text_field( $fb['phone'] ?? '' ) ),
'email' => $email,
'address' => sanitize_textarea_field( $fb['address'] ?? '' ),
'response_duration' => sanitize_text_field( $fb['response_duration'] ?? '2 business days' ),
];
if ( $publish ) {
$page_content = self::generate_statement_html( $stored );
$page_id = (int) ( $stored['page_id'] ?? 0 );
if ( $page_id > 0 && get_post( $page_id ) ) {
wp_update_post( [
'ID' => $page_id,
'post_content' => $page_content,
'post_status' => 'publish',
] );
} else {
$page_id = wp_insert_post( [
'post_title' => __( 'Accessibility Statement', 'accessmate' ),
'post_content' => $page_content,
'post_status' => 'publish',
'post_type' => 'page',
] );
}
if ( is_wp_error( $page_id ) ) {
return new WP_REST_Response( [ 'error' => $page_id->get_error_message() ], 500 );
}
$stored['page_id'] = $page_id;
$stored['page_url'] = get_permalink( $page_id );
$stored['edit_url'] = get_edit_post_link( $page_id, 'raw' );
$stored['status'] = 'published';
$stored['updated_at'] = current_time( 'F j, Y' );
}
update_option( 'accessmate_statement', $stored );
return new WP_REST_Response( $stored, 200 );
}
private static function generate_statement_html( array $data ): string {
$org = esc_html( $data['basic_info']['org_name'] ?? '' );
$site = esc_html( $data['basic_info']['website_name']?? '' );
$website = esc_url( $data['basic_info']['website'] ?? '' );
$std_labels = [
'wcag22-aa' => 'WCAG 2.2 Level AA',
'wcag21-aaa' => 'WCAG 2.1 Level AAA',
'wcag21-aa' => 'WCAG 2.1 Level AA',
'wcag21-a' => 'WCAG 2.1 Level A',
'wcag20-aaa' => 'WCAG 2.0 Level AAA',
'wcag20-aa' => 'WCAG 2.0 Level AA',
'wcag20-a' => 'WCAG 2.0 Level A',
'section508' => 'Section 508',
];
$conformance_standard = esc_html(
$data['conformance']['standard'] === 'other'
? ( $data['conformance']['custom_standard'] ?? '' )
: ( $std_labels[ $data['conformance']['standard'] ] ?? $data['conformance']['standard'] )
);
$level_labels = [
'fully-conformant' => 'Fully conformant',
'partially-conformant' => 'Partially conformant',
'non-conformant' => 'Non conformant',
'not-assessed' => 'Not assessed',
];
$conformance_level = esc_html( $level_labels[ $data['conformance']['level'] ] ?? $data['conformance']['level'] );
// No leading heading here — the page template already renders the post title
// (e.g. "Accessibility Statement"), so a duplicate heading here would repeat it.
$html = "{$org} is committed to ensuring digital accessibility for people with disabilities. ";
$html .= "We continually improve the user experience for everyone and apply the relevant accessibility standards.
\n";
$html .= "Conformance Status
\n";
$html .= "The Web Content Accessibility Guidelines (WCAG) define requirements for designers and developers to improve accessibility for people with disabilities. ";
$html .= "It defines three levels of conformance: Level A, Level AA, and Level AAA. ";
$html .= "{$site} is {$conformance_level} with {$conformance_standard}.
\n";
if ( ! empty( $data['conformance']['additional_notes'] ) ) {
$html .= '' . esc_html( $data['conformance']['additional_notes'] ) . "
\n";
}
$all_efforts = array_merge(
(array) ( $data['efforts']['selected'] ?? [] ),
(array) ( $data['efforts']['additional'] ?? [] )
);
if ( ! empty( $all_efforts ) ) {
$html .= "Measures to Support Accessibility
\n";
$html .= "{$org} takes the following measures to ensure accessibility:
\n\n";
foreach ( $all_efforts as $effort ) {
$html .= '- ' . esc_html( $effort ) . "
\n";
}
$html .= "
\n";
}
$ts = $data['tech_specs'] ?? [];
$tech_sections = [
'Compatible browsers' => $ts['browsers'] ?? [],
'Compatible operating systems' => $ts['operating_systems'] ?? [],
'Compatible assistive technologies'=> $ts['assistive_tech'] ?? [],
'Technology reliance' => $ts['technology_reliance'] ?? [],
];
$has_tech = array_filter( $tech_sections );
if ( ! empty( $has_tech ) ) {
$html .= "Technical Specifications
\n";
$html .= "Accessibility relies on the following technologies to work with the particular combination of web browser and any assistive technologies or plugins installed on your computer:
\n";
foreach ( $tech_sections as $label => $items ) {
if ( empty( $items ) ) continue;
$html .= '' . esc_html( $label ) . ': ' . esc_html( implode( ', ', $items ) ) . "
\n";
}
}
$fb = $data['feedback'] ?? [];
$html .= "Feedback
\n";
$html .= "We welcome your feedback on the accessibility of {$site}. Please let us know if you experience accessibility barriers.
\n";
if ( ! empty( $fb['phone'] ) ) $html .= 'Phone: ' . esc_html( $fb['phone'] ) . "
\n";
if ( ! empty( $fb['email'] ) ) $html .= 'E-mail: ' . esc_html( $fb['email'] ) . "
\n";
if ( ! empty( $fb['address'] ) ) $html .= 'Postal address: ' . nl2br( esc_html( $fb['address'] ) ) . "
\n";
if ( ! empty( $fb['response_duration'] ) ) {
$html .= 'We try to respond to feedback within ' . esc_html( $fb['response_duration'] ) . ".
\n";
}
return $html;
}
}