ID ); // Runs after the status changed, so the recount no longer sees the post. } } /** * The post a saved page check belongs to (0 for archives, the front page of posts and so on). * Checks saved before 1.1.0 have no post ID stored, so read it from the address. * * @param array $scan Saved check. * @return int */ public static function post_of( $scan ) { if ( isset( $scan['post_id'] ) ) { return (int) $scan['post_id']; } $id = url_to_postid( $scan['url'] ); if ( ! $id ) { wp_parse_str( (string) wp_parse_url( $scan['url'], PHP_URL_QUERY ), $q ); $id = (int) ( $q['p'] ?? $q['page_id'] ?? 0 ); } return (int) $id; } /** * scheme://host[:port] of an address, for postMessage. * * @param string $url Address. * @return string */ public static function origin( $url ) { $p = wp_parse_url( $url ); return ( $p['scheme'] ?? 'https' ) . '://' . ( $p['host'] ?? '' ) . ( isset( $p['port'] ) ? ':' . $p['port'] : '' ); } /** * Drop the saved page checks of a post (trashed, deleted or unpublished). * * @param int $post_id Post ID. */ public static function forget_post( $post_id ) { $post_id = (int) $post_id; $scans = get_option( self::OPTION, array() ); $scans = is_array( $scans ) ? $scans : array(); $keep = array_filter( $scans, function ( $scan ) use ( $post_id ) { return self::post_of( $scan ) !== $post_id; } ); if ( count( $keep ) !== count( $scans ) ) { update_option( self::OPTION, $keep, false ); } // Its content-check problems leave the list too, whether or not it had a page check. AriAccess_Issues::changed(); } /** * Capability needed to scan pages and see issues. * * @return string */ public static function cap() { /** * Filter the capability for running checks and seeing reports. * * @param string $cap Capability. */ return (string) apply_filters( 'ariaccess_check_capability', 'edit_others_posts' ); } /** * Whether this request is a scan. * * @return bool */ public static function is_scan() { return isset( $_GET['ariaccess-scan'] ) && current_user_can( self::cap() ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } /** * Add "Check accessibility" to the admin bar on the front end. * * @param WP_Admin_Bar $bar Admin bar. */ public static function admin_bar( $bar ) { if ( is_admin() || ! current_user_can( self::cap() ) ) { return; } $url = add_query_arg( 'ariaccess-scan', '1', self::current_url() ); $bar->add_node( array( 'id' => 'ariaccess-scan', 'title' => '' . esc_html__( 'Check accessibility', 'ariaccess' ), 'href' => esc_url( $url ), 'meta' => array( 'title' => __( 'Run the AriAccess page check on this page', 'ariaccess' ) ), ) ); } /** * Current front-end URL without our flag. * * @return string */ private static function current_url() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; return remove_query_arg( array( 'ariaccess-scan', 'ariaccess-auto' ), home_url( $uri ) ); } /** * axe-core rule tags for a standard. * * @param string $standard Standard key. * @return string[] */ public static function tags( $standard ) { switch ( $standard ) { case 'no-private': return array( 'wcag2a', 'wcag2aa' ); case 'wcag22aa': return array( 'wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa' ); default: return array( 'wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa' ); } } /** * Load axe and the scanner UI on scan requests. */ public static function enqueue() { if ( ! self::is_scan() ) { return; } $locale = substr( (string) get_locale(), 0, 2 ); $map = array( 'nb' => 'nb', 'nn' => 'nb', 'no' => 'nb', 'da' => 'da', 'sv' => 'sv', 'de' => 'de', ); $axe_locale = ''; if ( isset( $map[ $locale ] ) ) { $file = ARIACCESS_DIR . 'assets/vendor/axe/locales/' . $map[ $locale ] . '.json'; if ( is_readable( $file ) ) { $axe_locale = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents } } wp_enqueue_script( 'ariaccess-axe', ARIACCESS_URL . 'assets/vendor/axe/axe.min.js', array(), '4.14.0', true ); wp_enqueue_script( 'ariaccess-page-scan', ARIACCESS_URL . 'assets/js/page-scan.js', array( 'ariaccess-axe' ), ARIACCESS_VERSION, true ); wp_enqueue_style( 'ariaccess-page-scan', ARIACCESS_URL . 'assets/css/page-scan.css', array(), ARIACCESS_VERSION ); $standard = AriAccess_Settings::get( 'checker', 'standard' ); $labels = AriAccess_Rules::standards(); wp_add_inline_script( 'ariaccess-page-scan', 'window.ariaccessScan=' . wp_json_encode( array( 'tags' => self::tags( $standard ), 'standard' => $labels[ $standard ], 'url' => self::current_url(), 'title' => wp_get_document_title(), 'rest' => esc_url_raw( rest_url( 'ariaccess/v1/page-scan' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'report' => admin_url( 'admin.php?page=ariaccess&tab=issues' ), 'admin' => self::origin( admin_url() ), 'auto' => isset( $_GET['ariaccess-auto'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only switches the scanner to silent mode. 'axeLocale' => $axe_locale ? json_decode( $axe_locale ) : null, 'i18n' => array( 'title' => __( 'AriAccess page check', 'ariaccess' ), 'running' => __( 'Checking this page…', 'ariaccess' ), /* translators: 1: number of problems, 2: number of places. */ 'found' => __( '%1$d problems in %2$d places', 'ariaccess' ), 'none' => __( 'No problems found by the automated check.', 'ariaccess' ), 'caveat' => __( 'Automated checks find roughly a third of accessibility problems. Also test with the keyboard (Tab, Enter, Escape) and a screen reader.', 'ariaccess' ), 'review' => __( 'Needs a human check', 'ariaccess' ), 'show' => __( 'Show', 'ariaccess' ), 'learn' => __( 'How to fix', 'ariaccess' ), 'close' => __( 'Close page check', 'ariaccess' ), 'saved' => __( 'Result saved to the AriAccess report.', 'ariaccess' ), 'openReport' => __( 'Open report', 'ariaccess' ), 'hidden' => __( 'This element is not visible right now.', 'ariaccess' ), 'impact' => array( 'critical' => __( 'Critical', 'ariaccess' ), 'serious' => __( 'Serious', 'ariaccess' ), 'moderate' => __( 'Moderate', 'ariaccess' ), 'minor' => __( 'Minor', 'ariaccess' ), ), 'failed' => __( 'The check could not run on this page.', 'ariaccess' ), 'saveFailed' => __( 'The result could not be saved to the report. The list above is still correct; try again later.', 'ariaccess' ), ), ) ) . ';', 'before' ); } /** * REST route for saving a scan summary. */ public static function routes() { register_rest_route( 'ariaccess/v1', '/page-scan', array( 'methods' => 'POST', 'permission_callback' => function () { return current_user_can( self::cap() ); }, 'callback' => array( __CLASS__, 'save' ), 'args' => array( 'url' => array( 'type' => 'string', 'required' => true, ), 'violations' => array( 'type' => 'array', 'default' => array(), ), 'review' => array( 'type' => 'array', 'default' => array(), ), ), ) ); } /** * Save a scan summary. * * @param WP_REST_Request $req Request. * @return WP_REST_Response|WP_Error */ public static function save( $req ) { $url = esc_url_raw( (string) $req->get_param( 'url' ) ); if ( '' === $url || strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ) !== strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ) { return new WP_Error( 'ariaccess_bad_url', 'URL must be on this site.', array( 'status' => 400 ) ); } $violations = self::clean_items( $req->get_param( 'violations' ), true ); $review = self::clean_items( $req->get_param( 'review' ), false ); $scans = self::all(); $key = md5( untrailingslashit( $url ) ); $scans[ $key ] = array( 'url' => $url, 'post_id' => (int) url_to_postid( $url ), 'title' => sanitize_text_field( (string) $req->get_param( 'title' ) ), 'time' => time(), 'standard' => AriAccess_Settings::get( 'checker', 'standard' ), 'violations' => $violations, 'review' => $review, 'incomplete' => count( $review ) ? count( $review ) : max( 0, (int) $req->get_param( 'incomplete' ) ), 'passes' => max( 0, (int) $req->get_param( 'passes' ) ), ); uasort( $scans, function ( $a, $b ) { return $b['time'] - $a['time']; } ); $scans = array_slice( $scans, 0, self::MAX, true ); update_option( self::OPTION, $scans, false ); /** * Fires after a page scan is saved. * * @param array $scan Scan. */ do_action( 'ariaccess_page_scanned', $scans[ $key ] ); AriAccess_Issues::changed(); AriAccess_Issues::record(); return rest_ensure_response( array( 'saved' => true ) ); } /** * Sanitise axe results sent by the browser. * * @param mixed $items List of { id, impact, help, description, helpUrl, nodes, targets, fix, wcag }. * @param bool $use_impact Keep the impact (violations) or not (items to check by hand). * @return array id => item */ private static function clean_items( $items, $use_impact ) { $out = array(); foreach ( (array) $items as $v ) { if ( ! is_array( $v ) || empty( $v['id'] ) ) { continue; } $id = sanitize_key( $v['id'] ); $out[ $id ] = array( 'impact' => $use_impact && in_array( $v['impact'] ?? '', array( 'critical', 'serious', 'moderate', 'minor' ), true ) ? $v['impact'] : 'minor', 'help' => self::plain( $v['help'] ?? '', 200 ), 'description' => self::plain( $v['description'] ?? '', 300 ), 'fix' => self::plain( $v['fix'] ?? '', 400 ), 'url' => esc_url_raw( $v['helpUrl'] ?? '' ), 'nodes' => max( 0, (int) ( $v['nodes'] ?? 0 ) ), 'targets' => array_slice( array_values( array_filter( array_map( function ( $sel ) { return self::plain( $sel, 160 ); }, (array) ( $v['targets'] ?? array() ) ) ) ), 0, 5 ), 'wcag' => array_values( array_filter( array_map( 'sanitize_text_field', (array) ( $v['wcag'] ?? array() ) ) ) ), ); if ( count( $out ) >= 100 ) { break; } } return $out; } /** * Plain text that keeps "
  • "-style tag names as text (axe messages mention them), stored safely. * Escape on output. * * @param mixed $text Text. * @param int $max Longest length. * @return string */ public static function plain( $text, $max ) { $text = is_scalar( $text ) ? (string) $text : ''; $text = htmlspecialchars_decode( sanitize_text_field( htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ) ), ENT_QUOTES ); return mb_substr( $text, 0, $max ); } /** * All saved page scans, newest first. * * @return array */ public static function all() { $scans = get_option( self::OPTION, array() ); if ( ! is_array( $scans ) ) { return array(); } // A check of a page that is gone or no longer public no longer applies. return array_filter( $scans, function ( $scan ) { $id = self::post_of( $scan ); return ! $id || 'publish' === get_post_status( $id ); } ); } }