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