post_type, self::post_types(), true ) ) {
return;
}
if ( in_array( $post->post_status, array( 'auto-draft', 'trash', 'inherit' ), true ) ) {
return;
}
self::scan_post( $post_id );
}
/**
* Render a post's content the way visitors see it.
*
* @param WP_Post $post Post.
* @return string
*/
public static function render( $post ) {
$previous_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
$GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
setup_postdata( $post );
self::$rendering = true;
$html = apply_filters( 'the_content', $post->post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
self::$rendering = false;
$GLOBALS['post'] = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
if ( $previous_post ) {
setup_postdata( $previous_post );
}
return (string) $html;
}
/**
* True while the checker renders content (fixes that add visible text stay off then).
*
* @var bool
*/
public static $rendering = false;
/**
* True while a bulk check runs, so the issue index is rebuilt once at the end, not per post.
*
* @var bool
*/
public static $batch = false;
/**
* Scan one post and store the result.
*
* @param int $post_id Post id.
* @return array Scan record.
*/
public static function scan_post( $post_id ) {
$post = get_post( $post_id );
if ( ! $post ) {
return array();
}
$html = self::render( $post );
if ( AriAccess_Settings::get( 'fixes', 'media_alt' ) ) {
$html = AriAccess_Fixes::fill_media_alt( $html );
}
$issues = self::analyze( $html );
$record = array(
'time' => time(),
'version' => ARIACCESS_VERSION,
'issues' => $issues,
'counts' => self::count_types( $issues, self::dismissed( $post_id ) ),
);
update_post_meta( $post_id, self::META, $record );
/**
* Fires after a post has been scanned. AriAccess Pro stores audit history here.
*
* @param int $post_id Post id.
* @param array $record Scan record.
*/
do_action( 'ariaccess_post_scanned', $post_id, $record );
if ( ! self::$batch ) {
AriAccess_Issues::changed();
}
return $record;
}
/**
* Stored scan for a post.
*
* @param int $post_id Post id.
* @return array|null
*/
public static function get_scan( $post_id ) {
$scan = get_post_meta( $post_id, self::META, true );
return is_array( $scan ) ? $scan : null;
}
/**
* Rules dismissed for a post.
*
* @param int $post_id Post id.
* @return string[]
*/
public static function dismissed( $post_id ) {
$d = get_post_meta( $post_id, self::DISMISSED, true );
return is_array( $d ) ? $d : array();
}
/**
* Who dismissed which rule in a post, why and when.
*
* @param int $post_id Post id.
* @return array rule => { reason, user, time }
*/
public static function dismiss_log( $post_id ) {
$l = get_post_meta( $post_id, self::DISMISS_LOG, true );
return is_array( $l ) ? $l : array();
}
/**
* Dismiss or restore a rule for a post.
*
* @param int $post_id Post id.
* @param string $rule Rule id.
* @param bool $dismiss Dismiss (true) or restore (false).
* @param string $reason Reason key (see AriAccess_Issues::reasons()).
*/
public static function set_dismissed( $post_id, $rule, $dismiss, $reason = 'not_problem' ) {
$d = self::dismissed( $post_id );
$log = self::dismiss_log( $post_id );
if ( $dismiss ) {
$d[] = $rule;
$log[ $rule ] = array(
'reason' => (string) $reason,
'user' => get_current_user_id(),
'time' => time(),
);
} else {
$d = array_diff( $d, array( $rule ) );
unset( $log[ $rule ] );
}
$d = array_values( array_unique( $d ) );
update_post_meta( $post_id, self::DISMISSED, $d );
update_post_meta( $post_id, self::DISMISS_LOG, $log );
$scan = self::get_scan( $post_id );
if ( $scan ) {
$scan['counts'] = self::count_types( $scan['issues'], $d );
update_post_meta( $post_id, self::META, $scan );
}
}
/**
* Count issues by type, leaving out dismissed rules.
*
* @param array $issues rule => [count, samples].
* @param string[] $dismissed Dismissed rule ids.
* @return array
*/
public static function count_types( $issues, $dismissed = array() ) {
$counts = array(
'error' => 0,
'warning' => 0,
'notice' => 0,
);
foreach ( $issues as $rule_id => $issue ) {
$rule = AriAccess_Rules::get( $rule_id );
if ( ! $rule || in_array( $rule_id, $dismissed, true ) ) {
continue;
}
$counts[ $rule['type'] ] += (int) $issue['count'];
}
return $counts;
}
/**
* Analyse an HTML fragment.
*
* @param string $html HTML.
* @return array rule => [ 'count' => int, 'samples' => string[] ]
*/
public static function analyze( $html ) {
self::$issues = array();
$html = trim( (string) $html );
if ( '' === $html ) {
return array();
}
$doc = new DOMDocument();
$prev = libxml_use_internal_errors( true );
$doc->loadHTML( '
' . $html . '
', LIBXML_NONET | LIBXML_COMPACT );
libxml_clear_errors();
libxml_use_internal_errors( $prev );
$xp = new DOMXPath( $doc );
$root = $doc->getElementById( 'ariaccess-root' );
if ( ! $root ) {
return array();
}
self::check_images( $xp, $root );
self::check_links( $xp, $root );
self::check_headings( $xp, $root );
self::check_tables( $xp, $root );
self::check_media( $xp, $root );
self::check_forms( $xp, $root );
self::check_contrast( $xp, $root );
self::check_misc( $xp, $root );
/**
* Filter the issues found in a fragment (add your own checks here).
*
* @param array $issues rule => [count, samples].
* @param DOMXPath $xp XPath over the fragment.
* @param DOMElement $root Wrapper element.
*/
$issues = apply_filters( 'ariaccess_analyze', self::$issues, $xp, $root );
return $issues;
}
/**
* Record one issue.
*
* @param string $rule Rule id.
* @param DOMElement|null $el Element.
* @param string $note Optional text instead of the element description.
*/
public static function add( $rule, $el = null, $note = '' ) {
if ( ! isset( self::$issues[ $rule ] ) ) {
self::$issues[ $rule ] = array(
'count' => 0,
'samples' => array(),
);
}
self::$issues[ $rule ]['count']++;
if ( count( self::$issues[ $rule ]['samples'] ) < self::SAMPLES ) {
self::$issues[ $rule ]['samples'][] = '' !== $note ? $note : self::describe( $el );
}
}
/**
* Short human description of an element.
*
* @param DOMElement|null $el Element.
* @return string
*/
public static function describe( $el ) {
if ( ! $el instanceof DOMElement ) {
return '';
}
$tag = strtolower( $el->nodeName );
$text = self::clean( $el->textContent );
if ( 'img' === $tag ) {
$src = $el->getAttribute( 'src' );
$path = (string) wp_parse_url( $src, PHP_URL_PATH );
return 'img: ' . self::cut( '' !== $path ? basename( $path ) : $src, 80 );
}
if ( 'iframe' === $tag || 'video' === $tag || 'audio' === $tag ) {
$src = $el->getAttribute( 'src' );
if ( '' === $src ) {
$source = $el->getElementsByTagName( 'source' )->item( 0 );
$src = $source ? $source->getAttribute( 'src' ) : '';
}
return $tag . ': ' . self::cut( $src, 90 );
}
if ( 'a' === $tag ) {
return 'a: "' . self::cut( $text, 60 ) . '" → ' . self::cut( $el->getAttribute( 'href' ), 60 );
}
if ( in_array( $tag, array( 'input', 'select', 'textarea' ), true ) ) {
$bits = array_filter( array( $el->getAttribute( 'type' ), $el->getAttribute( 'name' ), $el->getAttribute( 'placeholder' ) ) );
return $tag . ( $bits ? ' (' . self::cut( implode( ', ', $bits ), 70 ) . ')' : '' );
}
return $tag . ( '' !== $text ? ': "' . self::cut( $text, 90 ) . '"' : '' );
}
/**
* Collapse whitespace.
*
* @param string $s Text.
* @return string
*/
private static function clean( $s ) {
return trim( preg_replace( '/\s+/u', ' ', html_entity_decode( (string) $s, ENT_QUOTES, 'UTF-8' ) ) );
}
/**
* Cut a string.
*
* @param string $s Text.
* @param int $len Max length.
* @return string
*/
private static function cut( $s, $len ) {
$s = (string) $s;
return mb_strlen( $s ) > $len ? mb_substr( $s, 0, $len - 1 ) . '…' : $s;
}
/**
* Accessible name of an element (simplified accessible-name computation).
*
* @param DOMElement $el Element.
* @param DOMXPath $xp XPath.
* @return string
*/
public static function accessible_name( $el, $xp ) {
$label = trim( $el->getAttribute( 'aria-label' ) );
if ( '' !== $label ) {
return $label;
}
$labelledby = trim( $el->getAttribute( 'aria-labelledby' ) );
if ( '' !== $labelledby ) {
$parts = array();
foreach ( preg_split( '/\s+/', $labelledby ) as $id ) {
$ref = $xp->query( '//*[@id="' . self::xq( $id ) . '"]' )->item( 0 );
if ( $ref ) {
$parts[] = self::clean( $ref->textContent );
}
}
$name = trim( implode( ' ', $parts ) );
if ( '' !== $name ) {
return $name;
}
}
return self::subtree_text( $el );
}
/**
* Text of a subtree, counting image alt text and skipping aria-hidden parts.
*
* @param DOMNode $node Node.
* @return string
*/
private static function subtree_text( $node ) {
$out = '';
foreach ( $node->childNodes as $child ) {
if ( XML_TEXT_NODE === $child->nodeType ) {
$out .= $child->nodeValue;
} elseif ( $child instanceof DOMElement ) {
if ( 'true' === $child->getAttribute( 'aria-hidden' ) ) {
continue;
}
$tag = strtolower( $child->nodeName );
if ( 'img' === $tag ) {
$out .= ' ' . $child->getAttribute( 'alt' ) . ' ';
} elseif ( 'svg' === $tag ) {
$title = $child->getElementsByTagName( 'title' )->item( 0 );
$out .= ' ' . trim( $child->getAttribute( 'aria-label' ) ) . ' ' . ( $title ? $title->textContent : '' ) . ' ';
} else {
$aria = trim( $child->getAttribute( 'aria-label' ) );
$out .= '' !== $aria ? ' ' . $aria . ' ' : ' ' . self::subtree_text( $child ) . ' ';
}
}
}
return self::clean( $out );
}
/**
* Escape a value for an XPath string literal in double quotes.
*
* @param string $s Value.
* @return string
*/
private static function xq( $s ) {
return str_replace( '"', '', (string) $s );
}
/**
* Images.
*
* @param DOMXPath $xp XPath.
* @param DOMElement $root Root.
*/
private static function check_images( $xp, $root ) {
$suspicious = '/^(img|image|images|picture|photo|foto|bilde|bild|kuva|graphic|logo|icon|ikon|banner|untitled|screenshot|skjermbilde|dsc|pxl|placeholder|alt|alt text|\s*)$|\.(jpe?g|png|gif|webp|avif|svg|heic)$|^(img|dsc|pxl|screenshot|whatsapp image)[\s_-]?\d/i';
foreach ( $xp->query( './/img', $root ) as $img ) {
if ( 'true' === $img->getAttribute( 'aria-hidden' ) || 'presentation' === $img->getAttribute( 'role' ) || 'none' === $img->getAttribute( 'role' ) ) {
continue;
}
if ( ! $img->hasAttribute( 'alt' ) ) {
if ( '' === trim( $img->getAttribute( 'aria-label' ) ) && '' === trim( $img->getAttribute( 'aria-labelledby' ) ) ) {
self::add( 'img_alt_missing', $img );
}
continue;
}
$alt = self::clean( $img->getAttribute( 'alt' ) );
if ( '' === $alt ) {
continue; // Decorative (linked images are checked with links).
}
if ( preg_match( $suspicious, $alt ) ) {
self::add( 'img_alt_suspicious', $img, 'img alt="' . self::cut( $alt, 60 ) . '"' );
} elseif ( mb_strlen( $alt ) > 150 ) {
self::add( 'img_alt_long', $img, 'img alt="' . self::cut( $alt, 70 ) . '" (' . mb_strlen( $alt ) . ')' );
}
}
}
/**
* Link-text words that say nothing out of context, in the Nordic languages, English and German.
*
* @return string[]
*/
public static function ambiguous_words() {
$words = array(
// English.
'click here', 'click', 'here', 'read more', 'more', 'learn more', 'link', 'this link', 'continue', 'continue reading', 'details', 'more info', 'more information', 'download', 'this page', 'go', 'see more', 'view', 'info',
// Norwegian bokmål / nynorsk.
'her', 'klikk her', 'trykk her', 'klikk', 'les mer', 'les meir', 'mer', 'meir', 'lenke', 'denne lenken', 'denne lenka', 'mer info', 'mer informasjon', 'meir informasjon', 'last ned', 'se mer', 'sjå meir', 'gå', 'les videre', 'les vidare',
// Swedish.
'här', 'klicka här', 'läs mer', 'länk', 'mer information', 'ladda ner', 'se mer',
// Danish.
'klik her', 'læs mere', 'mere', 'mere info', 'hent', 'se mere',
// Finnish.
'tästä', 'klikkaa tästä', 'lue lisää', 'lisää', 'linkki', 'lisätietoja',
// German.
'hier', 'hier klicken', 'mehr', 'weiterlesen', 'mehr erfahren',
);
$extra = (string) AriAccess_Settings::get( 'checker', 'ambiguous_extra' );
foreach ( preg_split( '/[\r\n,]+/', $extra ) as $w ) {
$w = mb_strtolower( trim( $w ) );
if ( '' !== $w ) {
$words[] = $w;
}
}
return array_unique( $words );
}
/**
* Links.
*
* @param DOMXPath $xp XPath.
* @param DOMElement $root Root.
*/
private static function check_links( $xp, $root ) {
$ambiguous = self::ambiguous_words();
$new_window = AriAccess_Settings::get( 'fixes', 'new_window' );
foreach ( $xp->query( './/a[@href]', $root ) as $a ) {
if ( 'true' === $a->getAttribute( 'aria-hidden' ) ) {
continue;
}
$name = self::accessible_name( $a, $xp );
$href = trim( $a->getAttribute( 'href' ) );
if ( '' === $name && '' === trim( $a->getAttribute( 'title' ) ) ) {
if ( $a->getElementsByTagName( 'img' )->length ) {
self::add( 'link_image_no_text', $a, 'a → ' . self::cut( $href, 80 ) );
} else {
self::add( 'link_empty', $a, 'a → ' . self::cut( $href, 80 ) );
}
continue;
}
$plain = mb_strtolower( trim( preg_replace( '/[\s.:!?…»«"\'→>()\[\]]+/u', ' ', $name ) ) );
if ( in_array( $plain, $ambiguous, true ) ) {
self::add( 'link_ambiguous', $a );
} elseif ( preg_match( '#^(https?://|www\.)\S{12,}$#i', $name ) ) {
self::add( 'link_url_text', $a );
}
if ( '_blank' === $a->getAttribute( 'target' ) && 'off' === $new_window ) {
if ( ! preg_match( '/new (tab|window)|ny fane|nytt vindu|nytt vindauge|ny flik|nytt fönster|nyt vindue|ny fane|uusi välilehti|uuteen|neuen? (tab|fenster)/iu', $name . ' ' . $a->getAttribute( 'title' ) ) ) {
self::add( 'link_new_window', $a );
}
}
$path = (string) wp_parse_url( $href, PHP_URL_PATH );
if ( preg_match( '/\.(pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf)$/i', $path ) ) {
self::add( 'link_document', $a );
}
}
}
/**
* Headings, including "fake" bold headings.
*
* @param DOMXPath $xp XPath.
* @param DOMElement $root Root.
*/
private static function check_headings( $xp, $root ) {
$prev = 1; // The page title is the H1.
$count = 0;
foreach ( $xp->query( './/h1|.//h2|.//h3|.//h4|.//h5|.//h6', $root ) as $h ) {
$count++;
$level = (int) substr( $h->nodeName, 1 );
if ( '' === self::accessible_name( $h, $xp ) ) {
self::add( 'heading_empty', $h, $h->nodeName . ' (empty)' );
continue;
}
if ( $level > $prev + 1 ) {
self::add( 'heading_skipped', $h, 'h' . $prev . ' → ' . self::describe( $h ) );
}
$prev = $level;
}
$words = str_word_count( wp_strip_all_tags( $root->textContent ) );
if ( 0 === $count && $words > 400 ) {
/* translators: %d: number of words. */
self::add( 'heading_missing', null, sprintf( __( '%d words, no headings', 'ariaccess' ), $words ) );
}
foreach ( $xp->query( './/p', $root ) as $p ) {
$elements = array();
$loose = '';
foreach ( $p->childNodes as $child ) {
if ( $child instanceof DOMElement ) {
$elements[] = $child;
} else {
$loose .= $child->nodeValue;
}
}
if ( 1 !== count( $elements ) || '' !== trim( $loose ) || ! in_array( strtolower( $elements[0]->nodeName ), array( 'strong', 'b' ), true ) ) {
continue;
}
$text = self::clean( $p->textContent );
$len = mb_strlen( $text );
if ( $len >= 3 && $len <= 80 && ! preg_match( '/[.,;!?]$/u', $text ) && $xp->query( 'following-sibling::*[1][self::p or self::ul or self::ol]', $p )->length ) {
self::add( 'heading_fake', $p );
}
}
}
/**
* Tables.
*
* @param DOMXPath $xp XPath.
* @param DOMElement $root Root.
*/
private static function check_tables( $xp, $root ) {
foreach ( $xp->query( './/table', $root ) as $table ) {
$role = $table->getAttribute( 'role' );
if ( 'presentation' === $role || 'none' === $role ) {
continue;
}
$rows = $xp->query( './/tr', $table )->length;
$ths = $xp->query( './/th', $table );
if ( 0 === $ths->length ) {
if ( $rows > 1 ) {
self::add( 'table_no_header', $table, 'table: ' . $rows . ' rows' );
}
continue;
}
foreach ( $ths as $th ) {
if ( '' === self::accessible_name( $th, $xp ) ) {
self::add( 'table_empty_header', $th, 'th (empty)' );
}
}
}
}
/**
* Frames, audio and video.
*
* @param DOMXPath $xp XPath.
* @param DOMElement $root Root.
*/
private static function check_media( $xp, $root ) {
foreach ( $xp->query( './/iframe', $root ) as $frame ) {
if ( 'true' === $frame->getAttribute( 'aria-hidden' ) ) {
continue;
}
$src = $frame->getAttribute( 'src' );
if ( '' === trim( $frame->getAttribute( 'title' ) ) && '' === trim( $frame->getAttribute( 'aria-label' ) ) ) {
self::add( 'iframe_no_title', $frame );
}
if ( preg_match( '/[?&](autoplay|auto_play)=(1|true)/i', $src ) && ! preg_match( '/[?&]mute(d)?=(1|true)/i', $src ) ) {
self::add( 'media_autoplay', $frame );
}
if ( preg_match( '#(youtube(-nocookie)?\.com|youtu\.be|vimeo\.com|dailymotion\.com|tiktok\.com)#i', $src ) ) {
self::add( 'video_no_captions', $frame, __( 'Embedded video: check captions are on and correct', 'ariaccess' ) . ' (' . self::cut( $src, 60 ) . ')' );
}
}
foreach ( $xp->query( './/video|.//audio', $root ) as $media ) {
$tag = strtolower( $media->nodeName );
if ( $media->hasAttribute( 'autoplay' ) && ! ( $media->hasAttribute( 'muted' ) && $media->hasAttribute( 'controls' ) ) ) {
self::add( 'media_autoplay', $media );
}
if ( 'video' === $tag && ! $xp->query( './track[@kind="captions" or @kind="subtitles"]', $media )->length ) {
if ( ! ( $media->hasAttribute( 'muted' ) && ! $media->hasAttribute( 'controls' ) ) ) { // Silent background video has nothing to caption.
self::add( 'video_no_captions', $media );
}
}
if ( 'audio' === $tag ) {
self::add( 'audio_transcript', $media );
}
}
foreach ( $xp->query( './/blink|.//marquee', $root ) as $el ) {
self::add( 'blink_marquee', $el );
}
}
/**
* Form fields and buttons.
*
* @param DOMXPath $xp XPath.
* @param DOMElement $root Root.
*/
private static function check_forms( $xp, $root ) {
foreach ( $xp->query( './/input|.//select|.//textarea', $root ) as $field ) {
$type = strtolower( $field->getAttribute( 'type' ) );
if ( in_array( $type, array( 'hidden', 'submit', 'button', 'reset' ), true ) ) {
continue;
}
if ( 'image' === $type ) {
if ( '' === trim( $field->getAttribute( 'alt' ) ) && '' === trim( $field->getAttribute( 'aria-label' ) ) ) {
self::add( 'button_empty', $field );
}
continue;
}
if ( 'true' === $field->getAttribute( 'aria-hidden' ) ) {
continue;
}
if ( '' !== trim( $field->getAttribute( 'aria-label' ) ) || '' !== trim( $field->getAttribute( 'aria-labelledby' ) ) || '' !== trim( $field->getAttribute( 'title' ) ) ) {
continue;
}
$id = $field->getAttribute( 'id' );
if ( '' !== $id && $xp->query( '//label[@for="' . self::xq( $id ) . '"]' )->length ) {
continue;
}
if ( $xp->query( 'ancestor::label', $field )->length ) {
continue;
}
self::add( 'form_no_label', $field );
}
foreach ( $xp->query( './/button|.//*[@role="button"]', $root ) as $button ) {
if ( '' === self::accessible_name( $button, $xp ) && '' === trim( $button->getAttribute( 'title' ) ) ) {
self::add( 'button_empty', $button, self::describe( $button ) . ( $button->getAttribute( 'class' ) ? ' .' . self::cut( $button->getAttribute( 'class' ), 50 ) : '' ) );
}
}
}
/**
* Read a CSS property from an inline style attribute.
*
* @param DOMElement $el Element.
* @param string $prop Property.
* @return string
*/
private static function style_prop( $el, $prop ) {
$style = $el->getAttribute( 'style' );
if ( '' === $style ) {
return '';
}
if ( preg_match( '/(?:^|;)\s*' . preg_quote( $prop, '/' ) . '\s*:\s*([^;]+)/i', $style, $m ) ) {
return trim( $m[1] );
}
return '';
}
/**
* Text colour set on an element (inline style or block colour class).
*
* @param DOMElement $el Element.
* @return string
*/
private static function own_color( $el ) {
$c = self::style_prop( $el, 'color' );
if ( '' !== $c ) {
return $c;
}
if ( preg_match_all( '/(?:^|\s)has-([a-z0-9-]+?)-color(?=\s|$)/', $el->getAttribute( 'class' ), $all ) ) {
foreach ( $all[1] as $slug ) {
if ( 'text' === $slug || 'link' === $slug || preg_match( '/-background$/', $slug ) ) {
continue;
}
$hex = AriAccess_Color::palette_color( $slug );
return $hex ? $hex : '';
}
}
return '';
}
/**
* Background colour set on an element.
*
* @param DOMElement $el Element.
* @return string
*/
private static function own_background( $el ) {
$bg = self::style_prop( $el, 'background-color' );
if ( '' === $bg ) {
$bg = self::style_prop( $el, 'background' );
// Only a plain colour counts; gradients and images are unknown.
if ( '' !== $bg && ! AriAccess_Color::parse( $bg ) ) {
return 'unknown';
}
}
if ( '' !== $bg ) {
return $bg;
}
if ( preg_match( '/(?:^|\s)has-([a-z0-9-]+)-background-color(?:\s|$)/', $el->getAttribute( 'class' ), $m ) ) {
$hex = AriAccess_Color::palette_color( $m[1] );
return $hex ? $hex : 'unknown';
}
if ( preg_match( '/(?:^|\s)has-[a-z0-9-]+-gradient-background(?:\s|$)/', $el->getAttribute( 'class' ) ) || 'img' === strtolower( $el->nodeName ) || preg_match( '/wp-block-cover(\s|$)/', $el->getAttribute( 'class' ) ) ) {
return 'unknown';
}
return '';
}
/**
* Theme default text and background colours (theme.json global styles).
*
* @return array [ text, background ]
*/
public static function theme_colors() {
$text = '';
$bg = '';
if ( function_exists( 'wp_get_global_styles' ) ) {
$styles = wp_get_global_styles( array( 'color' ) );
$text = isset( $styles['text'] ) ? (string) $styles['text'] : '';
$bg = isset( $styles['background'] ) ? (string) $styles['background'] : '';
}
// Presets come back as var:preset|color|slug or var(--wp--preset--color--slug).
foreach ( array( 'text', 'bg' ) as $var ) {
if ( preg_match( '/^var:preset\|color\|([a-z0-9-]+)$/', $$var, $m ) ) {
$$var = 'var(--wp--preset--color--' . $m[1] . ')';
}
}
$colors = array(
'' !== $text ? $text : '#000000',
'' !== $bg ? $bg : '#ffffff',
);
/**
* Filter the colours assumed for text and background when content sets only one of them.
*
* @param array $colors [ text, background ].
*/
return apply_filters( 'ariaccess_theme_colors', $colors );
}
/**
* Colour contrast for text where the content sets colours.
*
* @param DOMXPath $xp XPath.
* @param DOMElement $root Root.
*/
private static function check_contrast( $xp, $root ) {
list( $theme_text, $theme_bg ) = self::theme_colors();
$query = './/*[@style[contains(., "color") or contains(., "background")] or contains(@class, "-color") or contains(@class, "has-background")]';
$seen = array();
foreach ( $xp->query( $query, $root ) as $el ) {
if ( '' === self::clean( $el->textContent ) ) {
continue;
}
// Text colour: own, or nearest ancestor with one, or the theme's.
$fg = '';
$node = $el;
while ( $node instanceof DOMElement && $node !== $root->parentNode ) {
$fg = self::own_color( $node );
if ( '' !== $fg ) {
break;
}
$node = $node->parentNode;
}
// Background: own, or nearest ancestor with one, or the theme's.
$bg = '';
$node = $el;
while ( $node instanceof DOMElement && $node !== $root->parentNode ) {
$bg = self::own_background( $node );
if ( '' !== $bg ) {
break;
}
$node = $node->parentNode;
}
if ( '' === $fg && '' === $bg ) {
continue;
}
if ( 'unknown' === $bg ) {
continue;
}
$fg = '' !== $fg ? $fg : $theme_text;
$bg = '' !== $bg ? $bg : $theme_bg;
$ratio = AriAccess_Color::ratio( $fg, $bg );
if ( null === $ratio ) {
continue;
}
$min = self::is_large_text( $el ) ? 3.0 : 4.5;
if ( $ratio + 0.005 < $min ) {
$key = $fg . '|' . $bg . '|' . self::clean( $el->textContent );
if ( isset( $seen[ $key ] ) ) {
continue;
}
$seen[ $key ] = true;
self::add( 'contrast_low', $el, sprintf( '%s — %s / %s = %s:1 (%s:1)', self::cut( self::describe( $el ), 60 ), $fg, $bg, number_format( $ratio, 2 ), $min ) );
}
}
}
/**
* Whether an element is "large text" (24px, or 18.66px bold) by its own styles or a size class.
*
* @param DOMElement $el Element.
* @return bool
*/
private static function is_large_text( $el ) {
if ( preg_match( '/^h[1-3]$/i', $el->nodeName ) ) {
return true;
}
$size = self::style_prop( $el, 'font-size' );
$px = self::to_px( $size );
$bold = preg_match( '/^(bold|[6-9]00)$/', self::style_prop( $el, 'font-weight' ) ) || in_array( strtolower( $el->nodeName ), array( 'strong', 'b' ), true );
if ( $px ) {
return $px >= 24 || ( $bold && $px >= 18.66 );
}
return (bool) preg_match( '/has-(large|x-large|xx-large|huge)-font-size/', $el->getAttribute( 'class' ) );
}
/**
* Convert a CSS length to px (assumes 16px root).
*
* @param string $v Length.
* @return float
*/
private static function to_px( $v ) {
if ( preg_match( '/^([\d.]+)(px|rem|em|pt)?$/', trim( $v ), $m ) ) {
$n = (float) $m[1];
switch ( isset( $m[2] ) ? $m[2] : 'px' ) {
case 'rem':
case 'em':
return $n * 16;
case 'pt':
return $n * 4 / 3;
default:
return $n;
}
}
return 0.0;
}
/**
* Duplicate ids, tabindex and tiny text.
*
* @param DOMXPath $xp XPath.
* @param DOMElement $root Root.
*/
private static function check_misc( $xp, $root ) {
$ids = array();
foreach ( $xp->query( './/*[@id]', $root ) as $el ) {
$id = $el->getAttribute( 'id' );
if ( '' === $id ) {
continue;
}
if ( isset( $ids[ $id ] ) ) {
if ( 1 === $ids[ $id ] ) {
self::add( 'duplicate_id', $el, 'id="' . self::cut( $id, 60 ) . '"' );
}
$ids[ $id ]++;
} else {
$ids[ $id ] = 1;
}
}
if ( ! AriAccess_Settings::get( 'fixes', 'tabindex' ) ) {
foreach ( $xp->query( './/*[@tabindex]', $root ) as $el ) {
if ( (int) $el->getAttribute( 'tabindex' ) > 0 ) {
self::add( 'tabindex_positive', $el );
}
}
}
foreach ( $xp->query( './/*[@style[contains(., "font-size")]]', $root ) as $el ) {
$px = self::to_px( self::style_prop( $el, 'font-size' ) );
if ( $px > 0 && $px < 12 && '' !== self::clean( $el->textContent ) ) {
self::add( 'text_small', $el, self::cut( self::describe( $el ), 70 ) . ' (' . round( $px, 1 ) . 'px)' );
}
}
}
/**
* Site-wide totals from stored scans.
*
* @return array
*/
public static function summary() {
global $wpdb;
$types = self::post_types();
$empty = array(
'posts' => 0,
'scanned' => 0,
'clean' => 0,
'counts' => array(
'error' => 0,
'warning' => 0,
'notice' => 0,
),
'rules' => array(),
'last' => 0,
);
if ( ! $types ) {
return $empty;
}
$in = implode( ',', array_fill( 0, count( $types ), '%s' ) );
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ($in)", $types ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, 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( self::META ), $types ) ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders,WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$sum = $empty;
$sum['posts'] = $total;
foreach ( (array) $rows as $row ) { // get_results() returns null on a database error.
$scan = maybe_unserialize( $row->meta_value );
if ( ! is_array( $scan ) || ! isset( $scan['issues'] ) ) {
continue;
}
$sum['scanned']++;
$sum['last'] = max( $sum['last'], (int) $scan['time'] );
$dismissed = self::dismissed( (int) $row->ID );
$has_error = false;
foreach ( $scan['issues'] as $rule_id => $issue ) {
$rule = AriAccess_Rules::get( $rule_id );
if ( ! $rule || in_array( $rule_id, $dismissed, true ) ) {
continue;
}
$sum['counts'][ $rule['type'] ] += (int) $issue['count'];
if ( ! isset( $sum['rules'][ $rule_id ] ) ) {
$sum['rules'][ $rule_id ] = array(
'count' => 0,
'posts' => array(),
);
}
$sum['rules'][ $rule_id ]['count'] += (int) $issue['count'];
$sum['rules'][ $rule_id ]['posts'][] = (int) $row->ID;
if ( 'error' === $rule['type'] ) {
$has_error = true;
}
}
if ( ! $has_error ) {
$sum['clean']++;
}
}
$order = array(
'error' => 0,
'warning' => 1,
'notice' => 2,
);
uksort(
$sum['rules'],
function ( $a, $b ) use ( $sum, $order ) {
$ra = AriAccess_Rules::get( $a );
$rb = AriAccess_Rules::get( $b );
if ( $order[ $ra['type'] ] !== $order[ $rb['type'] ] ) {
return $order[ $ra['type'] ] - $order[ $rb['type'] ];
}
return count( $sum['rules'][ $b ]['posts'] ) - count( $sum['rules'][ $a ]['posts'] );
}
);
return $sum;
}
}