issues = array(); $prev = libxml_use_internal_errors( true ); $this->doc = new DOMDocument(); // Force UTF-8. Without this, libxml assumes ISO-8859-1 and mangles accented // characters — which on a French site means most of the page. $loaded = $this->doc->loadHTML( '' . $html, LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET ); libxml_clear_errors(); libxml_use_internal_errors( $prev ); if ( ! $loaded ) { return new WP_Error( 'adnvaa_parse_failed', __( 'The page HTML could not be parsed.', 'adinav-accessibility-audit-eaa' ) ); } $this->xp = new DOMXPath( $this->doc ); $this->check_images(); $this->check_form_labels(); $this->check_control_names(); $this->check_generic_links(); $this->check_document(); $this->check_headings(); $this->check_iframes(); $this->check_tables(); $this->check_duplicate_ids(); $this->check_tabindex(); $this->check_aria_hidden_focusable(); $this->check_viewport(); $this->check_meta_refresh(); $this->check_media(); $this->check_autocomplete(); $this->check_landmarks(); $this->check_inline_contrast(); usort( $this->issues, static function ( $a, $b ) { $sa = ADNVAA_Rules::severity_rank( $a['severity'] ); $sb = ADNVAA_Rules::severity_rank( $b['severity'] ); return $sa === $sb ? strcmp( $a['rule'], $b['rule'] ) : $sa - $sb; } ); return $this->issues; } // ---------------------------------------------------------------- rules -- private function check_images() { foreach ( $this->xp->query( '//img' ) as $img ) { if ( $this->is_hidden_from_at( $img ) ) { continue; } if ( ! $img->hasAttribute( 'alt' ) ) { // An inside a link/button that already has a name elsewhere is // still a fault, but it is the control-name rule's job to shout about // the blocking case. Here we report the missing attribute plainly. $this->add( 'img-missing-alt', $img ); continue; } $alt = trim( $img->getAttribute( 'alt' ) ); if ( '' === $alt ) { continue; // Explicitly decorative. Correct and intentional. } if ( preg_match( '/\.(jpe?g|png|gif|webp|svg|avif|bmp)$/i', $alt ) ) { $this->add( 'img-alt-is-filename', $img, sprintf( 'alt="%s"', $alt ) ); continue; } // alt that merely repeats the file name without the extension. $src = $img->getAttribute( 'src' ); if ( $src ) { $base = pathinfo( wp_parse_url( $src, PHP_URL_PATH ) ?: '', PATHINFO_FILENAME ); if ( $base && 0 === strcasecmp( $base, $alt ) ) { $this->add( 'img-alt-is-filename', $img, sprintf( 'alt="%s"', $alt ) ); } } } } private function check_form_labels() { $controls = $this->xp->query( '//input[not(@type) or (translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz") != "hidden"' . ' and translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz") != "submit"' . ' and translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz") != "button"' . ' and translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz") != "reset"' . ' and translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz") != "image")]' . ' | //select | //textarea' ); foreach ( $controls as $ctrl ) { if ( $this->is_hidden_from_at( $ctrl ) ) { continue; } if ( '' !== $this->accessible_name( $ctrl ) ) { continue; } // A placeholder is deliberately NOT accepted as a name: it vanishes on // input and several screen readers never announce it. WCAG agrees. $this->add( 'input-missing-label', $ctrl ); } } private function check_control_names() { // Links that are actual links (have href) and buttons. $controls = $this->xp->query( '//a[@href] | //button | //input[translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="image"]' ); foreach ( $controls as $ctrl ) { if ( $this->is_hidden_from_at( $ctrl ) ) { continue; } if ( '' === $this->accessible_name( $ctrl ) ) { $this->add( 'control-no-accessible-name', $ctrl ); } } } private function check_generic_links() { foreach ( $this->xp->query( '//a[@href]' ) as $a ) { if ( $this->is_hidden_from_at( $a ) ) { continue; } $name = $this->accessible_name( $a ); if ( '' === $name ) { continue; // Already reported as nameless; do not double-count. } $norm = trim( mb_strtolower( $name ), " \t\n\r\0\x0B.:;!?→>»…" ); if ( in_array( $norm, ADNVAA_Rules::GENERIC_LINK_TEXT, true ) ) { $this->add( 'link-generic-text', $a, sprintf( '"%s"', $name ) ); } } } private function check_document() { $html = $this->xp->query( '//html' )->item( 0 ); if ( $html && '' === trim( (string) $html->getAttribute( 'lang' ) ) ) { $this->add( 'html-missing-lang', $html ); } $title = $this->xp->query( '//head/title' )->item( 0 ); if ( ! $title || '' === trim( $title->textContent ) ) { $this->add( 'missing-page-title', $title ?: $html ); } } private function check_headings() { $headings = $this->xp->query( '//h1|//h2|//h3|//h4|//h5|//h6' ); $last = 0; $h1 = 0; $seen = false; foreach ( $headings as $h ) { if ( $this->is_hidden_from_at( $h ) ) { continue; } $level = (int) substr( $h->nodeName, 1 ); if ( '' === trim( $this->accessible_name( $h ) ) ) { $this->add( 'empty-heading', $h ); continue; } if ( 1 === $level ) { $h1++; } if ( $seen && $level > $last + 1 ) { $this->add( 'heading-level-skipped', $h, sprintf( /* translators: 1: previous heading level, 2: this heading level */ __( 'h%1$d is followed by h%2$d', 'adinav-accessibility-audit-eaa' ), $last, $level ) ); } $last = $level; $seen = true; } if ( 0 === $h1 && $headings->length > 0 ) { $this->add( 'no-h1', $this->xp->query( '//body' )->item( 0 ) ); } } private function check_iframes() { foreach ( $this->xp->query( '//iframe' ) as $f ) { if ( $this->is_hidden_from_at( $f ) ) { continue; } $name = trim( $f->getAttribute( 'title' ) ) . trim( $f->getAttribute( 'aria-label' ) ); if ( '' === $name && '' === trim( $f->getAttribute( 'aria-labelledby' ) ) ) { $this->add( 'iframe-no-title', $f ); } } } private function check_tables() { foreach ( $this->xp->query( '//table' ) as $t ) { $role = strtolower( $t->getAttribute( 'role' ) ); if ( 'presentation' === $role || 'none' === $role ) { continue; // Declared as layout. Honour the declaration. } if ( $this->is_hidden_from_at( $t ) ) { continue; } // A table with no rows is not a data table worth complaining about. if ( 0 === $this->xp->query( './/tr', $t )->length ) { continue; } if ( 0 === $this->xp->query( './/th', $t )->length ) { $this->add( 'table-no-headers', $t ); } } } private function check_duplicate_ids() { $seen = array(); foreach ( $this->xp->query( '//*[@id]' ) as $el ) { $id = $el->getAttribute( 'id' ); if ( '' === trim( $id ) ) { continue; } if ( isset( $seen[ $id ] ) ) { $this->add( 'duplicate-id', $el, sprintf( 'id="%s"', $id ) ); } $seen[ $id ] = true; } } private function check_tabindex() { foreach ( $this->xp->query( '//*[@tabindex]' ) as $el ) { if ( (int) $el->getAttribute( 'tabindex' ) > 0 ) { $this->add( 'positive-tabindex', $el, sprintf( 'tabindex="%s"', $el->getAttribute( 'tabindex' ) ) ); } } } /** * Reported once per aria-hidden CONTAINER, not once per trapped element. * * A single aria-hidden mobile menu can contain a thousand links. Emitting a * thousand rows is technically accurate and completely useless: it buries the * twenty-three broken "add to cart" buttons that actually cost the business * money. There is one bug here — one wrong attribute on one wrapper — the * developer fixes it in one place, so we report it in one place and say how * many controls it swallows. */ private function check_aria_hidden_focusable() { $focusable = './/a[@href] | .//button | .//input[not(@type) or translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")!="hidden"] | .//select | .//textarea | .//*[@tabindex]'; foreach ( $this->xp->query( '//*[@aria-hidden="true"]' ) as $el ) { // Skip nested aria-hidden containers: the outermost one is what gets fixed. $ancestor_hidden = false; $p = $el->parentNode; while ( $p instanceof DOMElement ) { if ( 'true' === strtolower( $p->getAttribute( 'aria-hidden' ) ) ) { $ancestor_hidden = true; break; } $p = $p->parentNode; } if ( $ancestor_hidden ) { continue; } $trapped = 0; foreach ( $this->xp->query( $focusable, $el ) as $inner ) { // tabindex="-1" removes it from the tab order, resolving the conflict. if ( $inner->hasAttribute( 'tabindex' ) && (int) $inner->getAttribute( 'tabindex' ) < 0 ) { continue; } if ( $inner->hasAttribute( 'disabled' ) ) { continue; } $trapped++; } if ( $trapped > 0 ) { $this->add( 'aria-hidden-focusable', $el, sprintf( /* translators: %d: number of keyboard-focusable controls inside the hidden container */ _n( 'This aria-hidden="true" container still holds %d keyboard-focusable control.', 'This aria-hidden="true" container still holds %d keyboard-focusable controls.', $trapped, 'adinav-accessibility-audit-eaa' ), $trapped ) ); } } } private function check_viewport() { foreach ( $this->xp->query( '//meta[translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="viewport"]' ) as $m ) { $content = strtolower( str_replace( ' ', '', $m->getAttribute( 'content' ) ) ); if ( false !== strpos( $content, 'user-scalable=no' ) || false !== strpos( $content, 'user-scalable=0' ) ) { $this->add( 'viewport-zoom-disabled', $m, 'user-scalable=no' ); continue; } if ( preg_match( '/maximum-scale=([0-9.]+)/', $content, $m2 ) && (float) $m2[1] < 2 ) { $this->add( 'viewport-zoom-disabled', $m, 'maximum-scale=' . $m2[1] ); } } } private function check_meta_refresh() { foreach ( $this->xp->query( '//meta[translate(@http-equiv,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="refresh"]' ) as $m ) { $content = trim( $m->getAttribute( 'content' ) ); // content="0; url=…" is an immediate redirect — annoying but not a WCAG // timing failure, because nothing is on screen long enough to be lost. if ( preg_match( '/^\s*([0-9.]+)/', $content, $sec ) && (float) $sec[1] > 0 ) { $this->add( 'meta-refresh', $m, sprintf( 'content="%s"', $content ) ); } } } private function check_media() { foreach ( $this->xp->query( '//video' ) as $v ) { if ( 0 === $this->xp->query( './/track[translate(@kind,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="captions" or translate(@kind,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="subtitles"]', $v )->length ) { $this->add( 'video-no-captions', $v ); } } foreach ( $this->xp->query( '//video[@autoplay] | //audio[@autoplay]' ) as $m ) { if ( $m->hasAttribute( 'muted' ) ) { continue; // Silent autoplay does not talk over a screen reader. } $this->add( 'media-autoplays', $m ); } } /** * WCAG 1.3.5 (AA). Only fires where the field's purpose is unambiguous from * its type or its name/id, so we never guess at what a field is for. */ private function check_autocomplete() { $tokens = array( 'email' => array( 'email', 'e-mail', 'mail', 'courriel' ), 'tel' => array( 'tel', 'phone', 'telephone', 'mobile' ), 'given-name' => array( 'firstname', 'first-name', 'first_name', 'fname', 'prenom' ), 'family-name'=> array( 'lastname', 'last-name', 'last_name', 'lname', 'surname', 'nom' ), 'postal-code'=> array( 'zip', 'zipcode', 'postcode', 'postal', 'cp' ), 'street-address' => array( 'address', 'street', 'adresse', 'rue' ), ); foreach ( $this->xp->query( '//input' ) as $in ) { $type = strtolower( $in->getAttribute( 'type' ) ); if ( in_array( $type, array( 'hidden', 'submit', 'button', 'reset', 'image', 'checkbox', 'radio', 'file' ), true ) ) { continue; } if ( '' !== trim( $in->getAttribute( 'autocomplete' ) ) ) { continue; } if ( $this->is_hidden_from_at( $in ) ) { continue; } $expected = ''; if ( 'email' === $type ) { $expected = 'email'; } elseif ( 'tel' === $type ) { $expected = 'tel'; } else { $needle = strtolower( $in->getAttribute( 'name' ) . ' ' . $in->getAttribute( 'id' ) ); if ( '' === trim( $needle ) ) { continue; } foreach ( $tokens as $token => $hints ) { foreach ( $hints as $hint ) { if ( false !== strpos( $needle, $hint ) ) { $expected = $token; break 2; } } } } if ( '' === $expected ) { continue; // Purpose not determinable from the markup. Stay silent. } $this->add( 'autocomplete-missing', $in, sprintf( /* translators: %s: the autocomplete token that should be used */ __( 'expected autocomplete="%s"', 'adinav-accessibility-audit-eaa' ), $expected ) ); } } private function check_landmarks() { $main = $this->xp->query( '//main | //*[translate(@role,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="main"]' ); if ( 0 === $main->length ) { $this->add( 'no-main-landmark', $this->xp->query( '//body' )->item( 0 ) ); } } /** * Only judges contrast where BOTH colours are declared on the same inline style. * That is a small share of a real page — and it is the share we can be certain * about without a rendering engine. The report says so out loud rather than * letting a clean result imply a pass. */ private function check_inline_contrast() { foreach ( $this->xp->query( '//*[@style]' ) as $el ) { $style = strtolower( $el->getAttribute( 'style' ) ); if ( ! preg_match( '/(?:^|;)\s*color\s*:\s*([^;]+)/', $style, $fg ) ) { continue; } if ( ! preg_match( '/background(?:-color)?\s*:\s*([^;]+)/', $style, $bg ) ) { continue; } $c1 = self::parse_color( $fg[1] ); $c2 = self::parse_color( $bg[1] ); if ( ! $c1 || ! $c2 ) { continue; } $ratio = self::contrast_ratio( $c1, $c2 ); // 3:1 is permitted for large text. We cannot always know the rendered size, // so we only assert failure below 3:1 unless the inline style proves the // text is small. Erring toward silence, on purpose. $threshold = 3.0; if ( preg_match( '/font-size\s*:\s*([0-9.]+)px/', $style, $fs ) ) { $px = (float) $fs[1]; $bold = (bool) preg_match( '/font-weight\s*:\s*(bold|[7-9]00)/', $style ); $large = $px >= 24 || ( $bold && $px >= 18.66 ); $threshold = $large ? 3.0 : 4.5; } if ( $ratio < $threshold ) { $this->add( 'contrast-inline-fail', $el, sprintf( /* translators: 1: measured contrast ratio, 2: required ratio */ __( 'measured %1$s:1, requires %2$s:1', 'adinav-accessibility-audit-eaa' ), number_format( $ratio, 2 ), number_format( $threshold, 1 ) ) ); } } } // ------------------------------------------------------------- helpers -- /** * A deliberately conservative accessible-name computation. It follows the * priority order of the ACCNAME spec closely enough for static analysis: * aria-labelledby → aria-label → native label / content → title. */ private function accessible_name( DOMElement $el ) { // 1. aria-labelledby $ref = trim( $el->getAttribute( 'aria-labelledby' ) ); if ( '' !== $ref ) { $text = ''; foreach ( preg_split( '/\s+/', $ref ) as $id ) { $target = $this->element_by_id( $id ); if ( $target ) { $text .= ' ' . $target->textContent; } } if ( '' !== trim( $text ) ) { return $this->squash( $text ); } } // 2. aria-label $label = trim( $el->getAttribute( 'aria-label' ) ); if ( '' !== $label ) { return $this->squash( $label ); } $tag = strtolower( $el->nodeName ); // 3. Native labelling for form controls. if ( in_array( $tag, array( 'input', 'select', 'textarea' ), true ) ) { $id = trim( $el->getAttribute( 'id' ) ); if ( '' !== $id ) { $escaped = str_replace( '"', '', $id ); $for = $this->xp->query( sprintf( '//label[@for="%s"]', $escaped ) ); if ( $for->length && '' !== trim( $for->item( 0 )->textContent ) ) { return $this->squash( $for->item( 0 )->textContent ); } } // Wrapping