admin_url( 'admin-ajax.php' ), 'action' => self::ACTION, 'nonce' => wp_create_nonce( self::ACTION ), 'forms' => array_values( $forms ), 'editLinks' => $edit_links, 'healthUrl' => current_user_can( 'manage_accessible_forms_settings' ) ? Admin\Management::url( 'health' ) : '', 'report' => ! is_preview() && ! Admin\TestPreview::active(), 'privateTest' => Admin\TestPreview::active(), // With plain permalinks every page shares the path "/", so the // viewed post tells pages apart. 'objectId' => is_singular() ? get_queried_object_id() : 0, ) ); } /** * Issue codes the browser check can report, with short admin wording. * * @return array Code => label. */ public static function codes() { return array( 'name_missing' => __( 'Control has no accessible name', 'accessible-forms-by-accessible-org' ), 'name_mismatch' => __( 'Accessible name does not match the visible label', 'accessible-forms-by-accessible-org' ), 'label_hidden' => __( 'Label hidden or too small', 'accessible-forms-by-accessible-org' ), 'focus_none' => __( 'No visible focus indicator', 'accessible-forms-by-accessible-org' ), 'focus_low_contrast' => __( 'Focus indicator hard to see', 'accessible-forms-by-accessible-org' ), 'contrast_text' => __( 'Low text contrast', 'accessible-forms-by-accessible-org' ), 'contrast_border' => __( 'Field border hard to see', 'accessible-forms-by-accessible-org' ), 'target_small' => __( 'Click or tap target too small', 'accessible-forms-by-accessible-org' ), 'obscured' => __( 'Covered by another element', 'accessible-forms-by-accessible-org' ), 'required_marker' => __( 'Required marker missing or hidden', 'accessible-forms-by-accessible-org' ), 'ref_missing' => __( 'Label or description reference is broken', 'accessible-forms-by-accessible-org' ), 'duplicate_id' => __( 'Duplicate ID on the page', 'accessible-forms-by-accessible-org' ), 'injected_markup' => __( 'Extra paragraph or line-break tags inside the form', 'accessible-forms-by-accessible-org' ), 'heading_missing' => __( 'Form heading missing or hidden', 'accessible-forms-by-accessible-org' ), 'text_clipped' => __( 'Text is cut off', 'accessible-forms-by-accessible-org' ), 'spacing_clip' => __( 'Text is cut off with increased text spacing', 'accessible-forms-by-accessible-org' ), 'form_overflow' => __( 'Form overflows its space with increased text spacing', 'accessible-forms-by-accessible-org' ), ); } /** * AJAX: store one form's result for one page. */ public static function save() { check_ajax_referer( self::ACTION, 'nonce' ); $form_id = absint( wp_unslash( $_POST['form_id'] ?? 0 ) ); if ( ! self::can_check( $form_id ) ) { wp_send_json_error( array( 'message' => __( 'You cannot check this form.', 'accessible-forms-by-accessible-org' ) ), 403 ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- clean_path() keeps only URL characters of a same-site path; sanitize_text_field() would strip the percent-encoded letters of non-ASCII page slugs. $path = self::clean_path( wp_sanitize_redirect( (string) wp_unslash( $_POST['path'] ?? '' ) ) ); if ( '' === $path ) { wp_send_json_error( array( 'message' => __( 'The page address is not valid.', 'accessible-forms-by-accessible-org' ) ), 400 ); } $json = sanitize_textarea_field( wp_unslash( $_POST['issues'] ?? '[]' ) ); if ( strlen( $json ) > 20000 ) { $json = '[]'; } $raw_issues = json_decode( $json, true ); $issues = self::clean_issues( is_array( $raw_issues ) ? $raw_issues : array() ); $unverified = min( 999, absint( wp_unslash( $_POST['unverified'] ?? 0 ) ) ); $post_id = absint( wp_unslash( $_POST['object_id'] ?? 0 ) ); if ( $post_id && ! current_user_can( 'read_post', $post_id ) ) { $post_id = 0; } if ( ! self::store( $form_id, $path, $issues, $unverified, $post_id ) ) { wp_send_json_error( array( 'message' => __( 'The check could not be saved. Reload this page to check again.', 'accessible-forms-by-accessible-org' ) ), 503 ); } wp_send_json_success( array( 'stored' => true ) ); } /** * Reduce a submitted address to a same-site path without query or fragment. * * @param string $raw Submitted path. * @return string Clean path, or '' when unusable. */ public static function clean_path( $raw ) { $raw = (string) $raw; if ( '' === $raw || '/' !== $raw[0] || str_starts_with( $raw, '//' ) ) { return ''; } $path = (string) wp_parse_url( $raw, PHP_URL_PATH ); if ( '' === $path || '/' !== $path[0] || str_starts_with( $path, '//' ) ) { return ''; } $path = preg_replace( '#[^A-Za-z0-9/_.~%-]#', '', $path ); if ( strlen( $path ) > 200 ) { return ''; } return $path; } /** * Keep only known issue codes and short plain-text control labels. * * @param array $raw Submitted issues. * @return array List of array( 'code' => string, 'label' => string ). */ public static function clean_issues( array $raw ) { $codes = self::codes(); $issues = array(); foreach ( $raw as $item ) { if ( ! is_array( $item ) ) { continue; } $code = sanitize_key( (string) ( $item['code'] ?? '' ) ); if ( ! isset( $codes[ $code ] ) ) { continue; } $label = sanitize_text_field( (string) ( $item['label'] ?? '' ) ); $issues[] = array( 'code' => $code, 'label' => mb_substr( $label, 0, 80 ), ); if ( count( $issues ) >= self::MAX_ISSUES ) { break; } } return $issues; } /** * Store a result, keeping storage bounded. * * @param int $form_id Form post ID. * @param string $path Clean page path. * @param array $issues Clean issues. * @param int $unverified Checks that could not be completed. * @param int $post_id Viewed post, 0 when unknown. */ public static function store( $form_id, $path, array $issues, $unverified = 0, $post_id = 0 ) { $all = self::prune( self::raw() ); $key = self::page_key( $path, $post_id ); $pages = $all[ $form_id ] ?? array(); $pages[ $key ] = array( 'time' => time(), 'path' => $path, 'post' => (int) $post_id, 'issues' => $issues, 'unverified' => (int) $unverified, 'context' => self::context( $form_id, $post_id ), ); uasort( $pages, array( self::class, 'newest_first' ) ); $all[ $form_id ] = array_slice( $pages, 0, self::MAX_PAGES, true ); if ( count( $all ) > self::MAX_FORMS ) { uasort( $all, array( self::class, 'newest_form_first' ) ); $all = array_slice( $all, 0, self::MAX_FORMS, true ); } return Persistence::option( self::OPTION, $all, false ); } /** Evidence applies to the checked published form, page and theme configuration. */ public static function context( $form_id, $post_id, $schema = null ) { $theme = wp_get_theme(); // Resolve lazy style lookups before fingerprinting; their cached post ID is not a theme setting. $custom_css = wp_get_custom_css(); $global_styles = function_exists( 'wp_get_global_styles' ) ? wp_get_global_styles() : array(); $mods = get_theme_mods(); if ( is_array( $mods ) ) { unset( $mods['custom_css_post_id'] ); } return hash( 'sha256', wp_json_encode( array( VERSION, get_bloginfo( 'version' ), $schema ?? Schema::get_live( $form_id ), $theme->get_stylesheet(), $theme->get( 'Version' ), $mods, get_option( 'active_plugins', array() ), is_multisite() ? get_site_option( 'active_sitewide_plugins', array() ) : array(), Extensions::filter( 'accessible_forms_health_versions', array() ), Spam::provider(), $post_id ? get_post_field( 'post_content', $post_id, 'raw' ) : '', $post_id ? array( get_post_field( 'post_title', $post_id, 'raw' ), get_post_status( $post_id ), get_post_meta( $post_id, '_wp_page_template', true ) ) : array(), $custom_css, $global_styles, ) ) ); } /** * Storage key for one page: its path, plus the post ID when known, so * pages still differ under plain permalinks (where every path is "/"). * * @param string $path Clean path. * @param int $post_id Viewed post, 0 when unknown. * @return string */ public static function page_key( $path, $post_id = 0 ) { return $post_id ? $path . '#' . (int) $post_id : $path; } /** * Stored results, oldest entries removed. * * @return array Form ID => ( path => result ). */ public static function results() { return self::prune( self::raw() ); } /** * Daily cleanup of expired results. */ public static function purge() { $raw = self::raw(); $pruned = self::prune( $raw ); if ( $pruned === $raw ) { return; } if ( $pruned ) { update_option( self::OPTION, $pruned, false ); } else { delete_option( self::OPTION ); } } /** * Remove a deleted form's results. * * @param int $post_id Post being deleted. */ public static function forget_form( $post_id ) { $all = self::raw(); if ( ! isset( $all[ $post_id ] ) ) { return; } unset( $all[ $post_id ] ); update_option( self::OPTION, $all, false ); } /** * The option value, shape-checked. * * @return array */ private static function raw() { $value = get_option( self::OPTION, array() ); return is_array( $value ) ? $value : array(); } /** * Drop expired results and empty forms. * * @param array $all Stored results. * @return array */ private static function prune( array $all ) { $cutoff = time() - self::MAX_AGE; foreach ( $all as $form_id => $pages ) { if ( ! is_array( $pages ) ) { unset( $all[ $form_id ] ); continue; } foreach ( $pages as $path => $result ) { if ( ! is_array( $result ) || (int) ( $result['time'] ?? 0 ) < $cutoff ) { unset( $pages[ $path ] ); } } if ( $pages ) { $all[ $form_id ] = $pages; } else { unset( $all[ $form_id ] ); } } return $all; } /** * Sort callback: newest result first. * * @param array $a Result. * @param array $b Result. * @return int */ private static function newest_first( $a, $b ) { return (int) $b['time'] <=> (int) $a['time']; } /** * Sort callback: form with the newest result first. * * @param array $a Pages. * @param array $b Pages. * @return int */ private static function newest_form_first( $a, $b ) { return max( array_column( $b, 'time' ) ) <=> max( array_column( $a, 'time' ) ); } /** * Flat rows for the Health screen, newest first. * * @return array */ public static function rows() { $rows = array(); foreach ( self::results() as $form_id => $pages ) { foreach ( $pages as $key => $result ) { $rows[] = array( 'form_id' => (int) $form_id, 'path' => (string) ( $result['path'] ?? strtok( (string) $key, '#' ) ), 'post' => (int) ( $result['post'] ?? 0 ), 'time' => (int) $result['time'], 'issues' => is_array( $result['issues'] ?? null ) ? $result['issues'] : array(), 'unverified' => (int) ( $result['unverified'] ?? 0 ), 'stale' => ! hash_equals( (string) ( $result['context'] ?? '' ), self::context( $form_id, $result['post'] ?? 0 ) ), ); } } usort( $rows, array( self::class, 'newest_first' ) ); return $rows; } /** * A stored path as a link on this site. * * @param string $path Clean path. * @return string */ public static function page_url( $path ) { $home = wp_parse_url( home_url() ); $origin = ( $home['scheme'] ?? 'http' ) . '://' . ( $home['host'] ?? '' ); if ( ! empty( $home['port'] ) ) { $origin .= ':' . (int) $home['port']; } return $origin . $path; } /** * The most common issues in a result, as short text. * * @param array $issues Stored issues. * @return string */ public static function top_issues( array $issues ) { $codes = self::codes(); $groups = array(); foreach ( $issues as $issue ) { $code = $issue['code']; if ( ! isset( $groups[ $code ] ) ) { $groups[ $code ] = array(); } if ( '' !== $issue['label'] ) { $groups[ $code ][] = $issue['label']; } } $parts = array(); foreach ( array_slice( $groups, 0, 3, true ) as $code => $labels ) { $labels = array_unique( $labels ); $parts[] = $labels ? sprintf( /* translators: 1: issue description, 2: comma-separated control labels. */ __( '%1$s (%2$s)', 'accessible-forms-by-accessible-org' ), $codes[ $code ], implode( ', ', array_slice( $labels, 0, 3 ) ) ) : $codes[ $code ]; } return implode( '; ', $parts ); } /** * The results table on the plugin Health screen. */ public static function render_health_table() { $rows = self::rows(); echo '

' . esc_html__( 'Live page checks', 'accessible-forms-by-accessible-org' ) . '

'; echo '

' . esc_html__( 'When someone who can edit a form views a published page that shows it, their browser checks the form after the theme and other plugins have loaded. Visitors never run the check or see its notice. Results stay in this site\'s database for 30 days and are never sent anywhere else.', 'accessible-forms-by-accessible-org' ) . '

'; if ( ! $rows ) { echo '

' . esc_html__( 'No pages have been checked yet. To check a page, visit it while logged in.', 'accessible-forms-by-accessible-org' ) . '

'; return; } echo '

' . esc_html__( 'Scroll horizontally to see all columns.', 'accessible-forms-by-accessible-org' ) . '

'; echo '
'; foreach ( array( __( 'Form', 'accessible-forms-by-accessible-org' ), __( 'Page', 'accessible-forms-by-accessible-org' ), __( 'Checked', 'accessible-forms-by-accessible-org' ), __( 'Result', 'accessible-forms-by-accessible-org' ), __( 'Top issues', 'accessible-forms-by-accessible-org' ), ) as $heading ) { echo ''; } echo ''; foreach ( $rows as $row ) { $title = get_the_title( $row['form_id'] ); if ( '' === $title ) { /* translators: %d: form ID. */ $title = sprintf( __( 'Form #%d', 'accessible-forms-by-accessible-org' ), $row['form_id'] ); } $count = count( $row['issues'] ); if ( $count ) { $result = sprintf( /* translators: %d: number of problems found. */ _n( '%d problem', '%d problems', $count, 'accessible-forms-by-accessible-org' ), $count ); } else { $result = $row['unverified'] ? __( 'No detected problems; check incomplete', 'accessible-forms-by-accessible-org' ) : __( 'All automated checks passed', 'accessible-forms-by-accessible-org' ); } if ( $row['stale'] ) { $result .= ' ยท ' . __( 'Changed since this check; check again', 'accessible-forms-by-accessible-org' ); } if ( $row['unverified'] ) { $result .= ' ' . sprintf( /* translators: %d: number of checks that could not be completed automatically. */ _n( '(%d not verified)', '(%d not verified)', $row['unverified'], 'accessible-forms-by-accessible-org' ), $row['unverified'] ); } $link = self::page_url( $row['path'] ); $text = $row['path']; if ( $row['post'] && get_post( $row['post'] ) ) { $link = (string) get_permalink( $row['post'] ); $name = get_the_title( $row['post'] ); $text = '' !== $name ? $name : $row['path']; } echo ''; } echo '
' . esc_html__( 'Latest live page check for each form and page', 'accessible-forms-by-accessible-org' ) . '
' . esc_html( $heading ) . '
' . esc_html( $title ) . '' . esc_html( $text ) . '' . esc_html( wp_date( 'Y-m-d H:i', $row['time'] ) ) . '' . esc_html( $result ) . '' . esc_html( $count ? self::top_issues( $row['issues'] ) : __( 'None', 'accessible-forms-by-accessible-org' ) ) . '
'; echo '

' . esc_html__( 'To check a page again, visit it while logged in. The result here updates automatically.', 'accessible-forms-by-accessible-org' ) . '

'; } /** * Site Health test: problems found by the latest live page checks. * * @return array */ public static function site_test() { $failing = array(); $rows = self::rows(); $unverified = 0; $stale = false; foreach ( $rows as $row ) { $stale = $stale || $row['stale']; $unverified += (int) ( $row['unverified'] ?? 0 ); if ( $row['issues'] ) { $failing[] = $row; } } $result = array( 'label' => __( 'Recent live page checks found no form accessibility problems', 'accessible-forms-by-accessible-org' ), 'status' => 'good', 'badge' => array( 'label' => __( 'Accessibility', 'accessible-forms-by-accessible-org' ), 'color' => 'blue', ), 'description' => '

' . esc_html__( 'When someone who can edit a form visits a page that shows it, their browser checks the form with the site\'s theme and plugins loaded.', 'accessible-forms-by-accessible-org' ) . '

', 'actions' => '

' . esc_html__( 'Review live page checks', 'accessible-forms-by-accessible-org' ) . '

', 'test' => 'accessible_forms_live_check', ); if ( ! $rows || $unverified || $stale ) { $result['status'] = 'recommended'; $result['label'] = ! $rows ? __( 'Form accessibility has not been checked on a live page yet', 'accessible-forms-by-accessible-org' ) : ( $stale ? __( 'Forms or page settings changed since the last live check', 'accessible-forms-by-accessible-org' ) : __( 'Some live page checks could not be completed automatically', 'accessible-forms-by-accessible-org' ) ); } if ( $failing ) { $result['status'] = 'recommended'; $result['label'] = sprintf( /* translators: %d: number of pages. */ _n( 'The theme or another plugin breaks form accessibility on %d page', 'The theme or another plugin breaks form accessibility on %d pages', count( $failing ), 'accessible-forms-by-accessible-org' ), count( $failing ) ); } return $result; } }