label */ public static function severities() { return array( 'must' => __( 'Must fix', 'ariaccess' ), 'review' => __( 'Should review', 'ariaccess' ), 'hand' => __( 'Check by hand', 'ariaccess' ), ); } /** * Severity of a content rule type (error, warning, notice). * * @param string $type Rule type. * @return string */ public static function from_type( $type ) { $map = array( 'error' => 'must', 'warning' => 'review', 'notice' => 'hand', ); return isset( $map[ $type ] ) ? $map[ $type ] : 'review'; } /** * Severity of an axe impact. * * @param string $impact critical|serious|moderate|minor. * @return string */ public static function from_impact( $impact ) { return in_array( $impact, array( 'critical', 'serious' ), true ) ? 'must' : 'review'; } /** * Reasons a person can give for dismissing. * * @return array key => label */ public static function reasons() { return array( 'not_problem' => __( 'Checked: not a problem here', 'ariaccess' ), 'fixed' => __( 'Fixed another way', 'ariaccess' ), 'accepted' => __( 'Accepted for now (list it in the statement)', 'ariaccess' ), ); } /** * axe rules that find the same thing as a content rule. Used to show them as one problem. * * @return array axe id => content rule id */ public static function axe_map() { return array( 'image-alt' => 'img_alt_missing', 'input-image-alt' => 'img_alt_missing', 'role-img-alt' => 'img_alt_missing', 'link-name' => 'link_empty', 'empty-heading' => 'heading_empty', 'heading-order' => 'heading_skipped', 'frame-title' => 'iframe_no_title', 'label' => 'form_no_label', 'select-name' => 'form_no_label', 'button-name' => 'button_empty', 'color-contrast' => 'contrast_low', 'duplicate-id' => 'duplicate_id', 'duplicate-id-active' => 'duplicate_id', 'tabindex' => 'tabindex_positive', 'blink' => 'blink_marquee', 'marquee' => 'blink_marquee', 'no-autoplay-audio' => 'media_autoplay', 'video-caption' => 'video_no_captions', 'empty-table-header' => 'table_empty_header', ); } /** * Plain-language names for common axe rules that have no content-check twin. * axe's own wording stays in "why" and the help link. * * @return array axe id => title */ public static function axe_titles() { return array( 'list' => __( 'List contains items that are not list items', 'ariaccess' ), 'listitem' => __( 'List item outside a list', 'ariaccess' ), 'definition-list' => __( 'Definition list built wrongly', 'ariaccess' ), 'dlitem' => __( 'Definition list item outside a list', 'ariaccess' ), 'html-has-lang' => __( 'Page language not set', 'ariaccess' ), 'html-lang-valid' => __( 'Page language code is not valid', 'ariaccess' ), 'valid-lang' => __( 'Language code on part of the page is not valid', 'ariaccess' ), 'document-title' => __( 'Page has no title', 'ariaccess' ), 'link-in-text-block' => __( 'Links in text only stand out by colour', 'ariaccess' ), 'meta-viewport' => __( 'Zooming is turned off on phones', 'ariaccess' ), 'nested-interactive' => __( 'Clickable element inside another clickable element', 'ariaccess' ), 'aria-hidden-focus' => __( 'Hidden element can still get keyboard focus', 'ariaccess' ), 'aria-allowed-attr' => __( 'ARIA attribute not allowed on this element', 'ariaccess' ), 'aria-required-attr' => __( 'ARIA role is missing a required attribute', 'ariaccess' ), 'aria-valid-attr-value' => __( 'ARIA attribute has a wrong value', 'ariaccess' ), 'aria-prohibited-attr' => __( 'ARIA label used where it is not allowed', 'ariaccess' ), 'scrollable-region-focusable' => __( 'Scrolling area cannot be reached with the keyboard', 'ariaccess' ), 'target-size' => __( 'Touch target too small', 'ariaccess' ), 'autocomplete-valid' => __( 'Form field has a wrong autocomplete value', 'ariaccess' ), 'td-headers-attr' => __( 'Table cell points to headers that do not exist', 'ariaccess' ), 'th-has-data-cells' => __( 'Table header has no cells under it', 'ariaccess' ), ); } /* --------------------------------------------------------------------- * Rows * ------------------------------------------------------------------- */ /** * Forget cached rows and refresh the menu badge. Call after any check or dismissal. */ public static function changed() { delete_transient( self::CACHE ); // Recounted on the next admin page (see AriAccess_Admin::menu()), so a bulk import does not rebuild every row per post. delete_option( self::BADGE ); } /** * Open must-fix count for the menu badge. Stored, and recounted only after something changed. * * @return int */ public static function badge() { $must = get_option( self::BADGE, false ); if ( false === $must ) { $must = self::counts()['open']['must']; update_option( self::BADGE, (int) $must, false ); } return (int) $must; } /** * Every row, open and dismissed. * * @return array[] */ public static function all_rows() { $cached = get_transient( self::CACHE ); if ( is_array( $cached ) ) { return $cached; } $content = self::content_rows(); $seen = array(); foreach ( $content as $r ) { $seen[ $r['page_key'] . '|' . $r['rule'] ] = true; } $rows = $content; foreach ( self::page_rows() as $r ) { // The content check already reports this on this page, with a more exact place: show it once. if ( isset( $seen[ $r['page_key'] . '|' . $r['group'] ] ) ) { continue; } $rows[] = $r; } set_transient( self::CACHE, $rows, HOUR_IN_SECONDS ); return $rows; } /** * Rows from the content check (one per post and rule). * * @return array[] */ private static function content_rows() { global $wpdb; $types = AriAccess_Checker::post_types(); if ( ! $types ) { return array(); } $standard = AriAccess_Settings::get( 'checker', 'standard' ); $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $results = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_title, p.post_type, m.meta_value FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s WHERE p.post_status = 'publish' AND p.post_type IN ($in)", array_merge( array( AriAccess_Checker::META ), $types ) ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = array(); foreach ( (array) $results as $r ) { $scan = maybe_unserialize( $r->meta_value ); if ( ! is_array( $scan ) || empty( $scan['issues'] ) ) { continue; } $id = (int) $r->ID; $dismissed = AriAccess_Checker::dismissed( $id ); $log = AriAccess_Checker::dismiss_log( $id ); foreach ( $scan['issues'] as $rule_id => $issue ) { $rule = AriAccess_Rules::get( $rule_id ); if ( ! $rule ) { continue; } $is_dismissed = in_array( $rule_id, $dismissed, true ); $rows[] = array( 'key' => 'c:' . $id . ':' . $rule_id, 'source' => 'content', 'post_id' => $id, 'url' => '', 'page' => '' !== $r->post_title ? $r->post_title : __( '(no title)', 'ariaccess' ), 'page_type' => $r->post_type, 'page_key' => 'p' . $id, 'rule' => $rule_id, 'group' => $rule_id, 'problem' => $rule['title'], 'severity' => self::from_type( $rule['type'] ), 'count' => max( 1, (int) $issue['count'] ), 'samples' => array_slice( (array) $issue['samples'], 0, 5 ), 'why' => $rule['why'], 'fix' => $rule['fix'], 'wcag' => $rule['wcag'], 'required' => AriAccess_Rules::in_standard( $rule, $standard ), 'help' => '', 'time' => (int) $scan['time'], 'dismissed' => $is_dismissed ? ( isset( $log[ $rule_id ] ) ? $log[ $rule_id ] : array( 'reason' => '', 'user' => 0, 'time' => 0 ) ) : null, ); } } return $rows; } /** * Rows from page checks (one per checked URL and axe rule), plus "check by hand" items. * * @return array[] */ private static function page_rows() { $standard = AriAccess_Settings::get( 'checker', 'standard' ); $criteria = AriAccess_Rules::criteria( $standard ); $dismissed = self::page_dismissed(); $map = self::axe_map(); $titles = self::axe_titles(); $rows = array(); foreach ( AriAccess_Page_Scan::all() as $hash => $scan ) { $post_id = AriAccess_Page_Scan::post_of( $scan ); $items = array(); foreach ( (array) $scan['violations'] as $axe_id => $v ) { $items[ $axe_id ] = array( $v, self::from_impact( $v['impact'] ) ); } foreach ( (array) ( $scan['review'] ?? array() ) as $axe_id => $v ) { if ( ! isset( $items[ $axe_id ] ) ) { $items[ $axe_id ] = array( $v, 'hand' ); } } foreach ( $items as $axe_id => $item ) { list( $v, $severity ) = $item; $key = 'u:' . $hash . ':' . $axe_id; $wcag = array_values( (array) ( $v['wcag'] ?? array() ) ); $rows[] = array( 'key' => $key, 'source' => 'page', 'post_id' => $post_id, 'url' => $scan['url'], 'page' => '' !== $scan['title'] ? $scan['title'] : $scan['url'], 'page_type' => '', 'page_key' => $post_id ? 'p' . $post_id : 'u' . $hash, 'rule' => 'axe:' . $axe_id, 'group' => isset( $map[ $axe_id ] ) ? $map[ $axe_id ] : 'axe:' . $axe_id, 'problem' => isset( $titles[ $axe_id ] ) ? $titles[ $axe_id ] : ( '' !== trim( (string) $v['help'] ) ? $v['help'] : $axe_id ), 'severity' => $severity, 'count' => max( 1, (int) $v['nodes'] ), 'samples' => array_slice( (array) ( $v['targets'] ?? array() ), 0, 5 ), 'why' => (string) ( $v['description'] ?? '' ), 'fix' => (string) ( $v['fix'] ?? '' ), 'wcag' => $wcag, 'required' => (bool) array_intersect( $wcag, $criteria ), 'help' => (string) ( $v['url'] ?? '' ), 'time' => (int) $scan['time'], 'dismissed' => isset( $dismissed[ $key ] ) ? $dismissed[ $key ] : null, ); } } return $rows; } /** * Filter, group and page the rows. * * @param array $args status (open|dismissed), severity, source, rule, search, group (problem|page), page, per_page. * @return array { items, total, pages } */ public static function query( $args = array() ) { $args = wp_parse_args( $args, array( 'status' => 'open', 'severity' => '', 'source' => '', 'rule' => '', 'search' => '', 'pagekey' => '', 'group' => 'problem', 'page' => 1, 'per_page' => 20, ) ); $rows = array_values( array_filter( self::all_rows(), function ( $r ) use ( $args ) { if ( ( 'dismissed' === $args['status'] ) !== ( null !== $r['dismissed'] ) ) { return false; } if ( '' !== $args['severity'] && $r['severity'] !== $args['severity'] ) { return false; } if ( '' !== $args['source'] && $r['source'] !== $args['source'] ) { return false; } if ( '' !== $args['rule'] && $r['group'] !== $args['rule'] ) { return false; } if ( '' !== $args['pagekey'] && $r['page_key'] !== $args['pagekey'] ) { return false; } if ( '' !== $args['search'] ) { $hay = $r['page'] . ' ' . $r['problem'] . ' ' . implode( ' ', $r['samples'] ); if ( false === stripos( $hay, $args['search'] ) ) { return false; } } return true; } ) ); $rank = array_flip( array_keys( self::severities() ) ); $groups = array(); foreach ( $rows as $r ) { $gk = 'page' === $args['group'] ? $r['page_key'] : $r['group']; if ( ! isset( $groups[ $gk ] ) ) { $groups[ $gk ] = array( 'key' => $gk, 'label' => 'page' === $args['group'] ? $r['page'] : $r['problem'], 'severity' => $r['severity'], 'rows' => array(), 'places' => 0, 'pages' => array(), 'must' => 0, ); } $g = &$groups[ $gk ]; $g['rows'][] = $r; $g['places'] += $r['count']; $g['must'] += 'must' === $r['severity'] ? 1 : 0; $g['pages'][ $r['page_key'] ] = true; if ( $rank[ $r['severity'] ] < $rank[ $g['severity'] ] ) { $g['severity'] = $r['severity']; } unset( $g ); } foreach ( $groups as &$g ) { $g['pages'] = count( $g['pages'] ); usort( $g['rows'], function ( $a, $b ) use ( $rank ) { return array( $rank[ $a['severity'] ], strtolower( $a['page'] ), strtolower( $a['problem'] ) ) <=> array( $rank[ $b['severity'] ], strtolower( $b['page'] ), strtolower( $b['problem'] ) ); } ); } unset( $g ); $groups = array_values( $groups ); $by_page = 'page' === $args['group']; usort( $groups, function ( $a, $b ) use ( $rank, $by_page ) { // Pages: most must-fix problems first. Problems: the ones on most pages first, so one fix clears most. $ka = $by_page ? array( $rank[ $a['severity'] ], -$a['must'], -count( $a['rows'] ), $a['label'] ) : array( $rank[ $a['severity'] ], -$a['pages'], -$a['places'], $a['label'] ); $kb = $by_page ? array( $rank[ $b['severity'] ], -$b['must'], -count( $b['rows'] ), $b['label'] ) : array( $rank[ $b['severity'] ], -$b['pages'], -$b['places'], $b['label'] ); return $ka <=> $kb; } ); $per = max( 1, (int) $args['per_page'] ); $total = count( $groups ); $pages = max( 1, (int) ceil( $total / $per ) ); $page = min( max( 1, (int) $args['page'] ), $pages ); return array( 'items' => array_slice( $groups, ( $page - 1 ) * $per, $per ), 'total' => $total, 'rows' => count( $rows ), 'pages' => $pages, 'page' => $page, ); } /** * Counts that agree everywhere. A "problem" is one problem on one page. * * @return array */ public static function counts() { $c = array( 'open' => array_fill_keys( array_keys( self::severities() ), 0 ), 'dismissed' => 0, 'pages_to_fix' => 0, 'pages_open' => 0, 'places' => 0, ); $fix = array(); $open = array(); foreach ( self::all_rows() as $r ) { if ( null !== $r['dismissed'] ) { $c['dismissed']++; continue; } $c['open'][ $r['severity'] ]++; $c['places'] += $r['count']; $open[ $r['page_key'] ] = true; if ( 'must' === $r['severity'] ) { $fix[ $r['page_key'] ] = true; } } $c['pages_to_fix'] = count( $fix ); $c['pages_open'] = count( $open ); return $c; } /** * Pages with the most must-fix problems. * * @param int $limit How many. * @return array[] { page, post_id, url, must, review } */ public static function top_pages( $limit = 5 ) { $pages = array(); foreach ( self::all_rows() as $r ) { if ( null !== $r['dismissed'] || 'hand' === $r['severity'] ) { continue; } $k = $r['page_key']; if ( ! isset( $pages[ $k ] ) ) { $pages[ $k ] = array( 'key' => $k, 'page' => $r['page'], 'post_id' => $r['post_id'], 'url' => $r['url'], 'must' => 0, 'review' => 0, ); } $pages[ $k ][ $r['severity'] ]++; } $pages = array_values( array_filter( $pages, function ( $p ) { return $p['must'] > 0; } ) ); usort( $pages, function ( $a, $b ) { return array( -$a['must'], -$a['review'], $a['page'] ) <=> array( -$b['must'], -$b['review'], $b['page'] ); } ); return array_slice( $pages, 0, $limit ); } /** * Open problems, one line per problem type, for the statement. * * @return string[] */ public static function statement_lines() { $by = array(); foreach ( self::all_rows() as $r ) { $accepted = null !== $r['dismissed'] && 'accepted' === ( $r['dismissed']['reason'] ?? '' ); if ( ( null !== $r['dismissed'] && ! $accepted ) || 'hand' === $r['severity'] ) { continue; } if ( ! isset( $by[ $r['group'] ] ) ) { $by[ $r['group'] ] = array( 'problem' => $r['problem'], 'pages' => array(), ); } $by[ $r['group'] ]['pages'][ $r['page_key'] ] = true; } $lines = array(); foreach ( $by as $b ) { $n = count( $b['pages'] ); /* translators: 1: problem, 2: number of pages. */ $lines[] = sprintf( _n( '%1$s (%2$s page)', '%1$s (%2$s pages)', $n, 'ariaccess' ), $b['problem'], number_format_i18n( $n ) ); } return $lines; } /* --------------------------------------------------------------------- * Dismissals * ------------------------------------------------------------------- */ /** * Dismissed page-check rows. * * @return array key => { reason, user, time } */ public static function page_dismissed() { $d = get_option( self::PAGE_DISMISSED, array() ); return is_array( $d ) ? $d : array(); } /** * Dismiss or restore a row by key. * * @param string $key Row key (c:post:rule or u:hash:axe). * @param bool $dismiss True to dismiss, false to restore. * @param string $reason Reason key. * @return bool|WP_Error */ public static function set_dismissed( $key, $dismiss, $reason = '' ) { $parts = explode( ':', (string) $key, 3 ); if ( 3 !== count( $parts ) ) { return new WP_Error( 'ariaccess_bad_key', 'Unknown issue.' ); } if ( $dismiss && ! isset( self::reasons()[ $reason ] ) ) { $reason = 'not_problem'; } if ( 'c' === $parts[0] ) { $post_id = (int) $parts[1]; if ( ! $post_id || ! AriAccess_Rules::get( $parts[2] ) ) { return new WP_Error( 'ariaccess_bad_key', 'Unknown issue.' ); } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new WP_Error( 'ariaccess_forbidden', 'Not allowed.' ); } AriAccess_Checker::set_dismissed( $post_id, $parts[2], $dismiss, $reason ); } elseif ( 'u' === $parts[0] && preg_match( '/^[a-f0-9]{32}$/', $parts[1] ) && preg_match( '/^[a-z0-9_-]+$/', $parts[2] ) ) { $scans = AriAccess_Page_Scan::all(); if ( ! isset( $scans[ $parts[1] ] ) ) { return new WP_Error( 'ariaccess_bad_key', 'Unknown issue.' ); } $scan_post = AriAccess_Page_Scan::post_of( $scans[ $parts[1] ] ); if ( $scan_post && ! current_user_can( 'edit_post', $scan_post ) ) { return new WP_Error( 'ariaccess_forbidden', 'Not allowed.' ); } $all = self::page_dismissed(); if ( $dismiss ) { $all[ $key ] = array( 'reason' => $reason, 'user' => get_current_user_id(), 'time' => time(), ); } else { unset( $all[ $key ] ); } update_option( self::PAGE_DISMISSED, $all, false ); } else { return new WP_Error( 'ariaccess_bad_key', 'Unknown issue.' ); } self::changed(); return true; } /* --------------------------------------------------------------------- * History * ------------------------------------------------------------------- */ /** * Record today's counts (one entry per day, the last one wins). */ public static function record() { $c = self::counts(); $history = self::history(); $day = wp_date( 'Y-m-d' ); $entry = array( 'day' => $day, 'must' => $c['open']['must'], 'review' => $c['open']['review'], 'hand' => $c['open']['hand'], 'pages' => $c['pages_to_fix'], ); if ( $history && end( $history )['day'] === $day ) { array_pop( $history ); } $history[] = $entry; update_option( self::HISTORY, array_slice( $history, -60 ), false ); } /** * Daily counts, oldest first. * * @return array[] */ public static function history() { $h = get_option( self::HISTORY, array() ); return is_array( $h ) ? array_values( $h ) : array(); } }