q( '/html' ); $body = $c->q( '//body' ); $root = $html ? $html[0] : null; $bod = $body ? $body[0] : null; if ( $root ) { $lang = trim( $root->getAttribute( 'lang' ) ); if ( '' === $lang ) { $lang = trim( $root->getAttribute( 'xml:lang' ) ); } if ( '' === $lang ) { $c->add( 'html-lang-missing', $root ); } elseif ( ! preg_match( '/^([a-z]{2,3}|x|i)(-[A-Za-z0-9]{1,8})*$/', $lang ) ) { $c->add( 'html-lang-invalid', $root, array( 'lang' => $lang ) ); } } $titles = $c->q( '/html/head/title' ); if ( ! $titles || '' === Html::clean( $titles[0]->textContent ) ) { if ( $root ) { $head = $c->q( '/html/head' ); $c->add( 'title-missing', $head ? $head[0] : $root ); } } if ( $bod ) { $this->headings( $c, $bod ); if ( ! $c->q( "//main | //*[@role='main']" ) ) { $c->add( 'landmark-main-missing', $bod ); } $this->skip_link( $c, $bod ); } $navs = $c->q( "//nav | //*[@role='navigation']" ); if ( count( $navs ) > 1 ) { foreach ( $navs as $nav ) { if ( '' === trim( $nav->getAttribute( 'aria-label' ) ) && '' === trim( $nav->getAttribute( 'aria-labelledby' ) ) && ! Html::is_hidden( $nav ) ) { $c->add( 'landmark-nav-unlabelled', $nav ); } } } foreach ( $c->q( "//li[not(ancestor::ul or ancestor::ol or ancestor::menu or ancestor::*[@role='list' or @role='menu' or @role='listbox' or @role='tablist' or @role='tree' or @role='group'])]" ) as $li ) { if ( ! Html::is_hidden( $li ) ) { $c->add( 'list-orphan-li', $li ); } } foreach ( $c->q( '//table' ) as $table ) { $role = strtolower( $table->getAttribute( 'role' ) ); if ( 'presentation' === $role || 'none' === $role || Html::is_hidden( $table ) ) { continue; } $rows = $c->q( './/tr', $table ); $ths = $c->q( './/th', $table ); $tds = $c->q( './/td', $table ); if ( ! $ths && count( $rows ) >= 2 && count( $tds ) >= 2 && ! $c->q( ".//*[@role='columnheader' or @role='rowheader']", $table ) ) { $c->add( 'table-no-headers', $table ); } } foreach ( $c->q( '//iframe' ) as $frame ) { if ( '' !== trim( $frame->getAttribute( 'title' ) ) || '' !== trim( $frame->getAttribute( 'aria-label' ) ) || '' !== trim( $frame->getAttribute( 'aria-labelledby' ) ) ) { continue; } if ( 'true' === $frame->getAttribute( 'aria-hidden' ) || '-1' === $frame->getAttribute( 'tabindex' ) || Html::is_hidden( $frame ) ) { continue; } $c->add( 'iframe-title-missing', $frame ); } foreach ( $c->q( "//meta[@name='viewport']" ) as $meta ) { $content = strtolower( $meta->getAttribute( 'content' ) ); $bad = false; if ( preg_match( '/user-scalable\s*=\s*(no|0)/', $content ) ) { $bad = true; } if ( preg_match( '/maximum-scale\s*=\s*([0-9.]+)/', $content, $m ) && (float) $m[1] < 2 ) { $bad = true; } if ( $bad ) { $c->add( 'viewport-zoom-disabled', $meta ); } } foreach ( $c->q( "//meta[translate(@http-equiv,'REFSH','refsh')='refresh']" ) as $meta ) { if ( preg_match( '/^\s*(\d+)/', $meta->getAttribute( 'content' ), $m ) && (int) $m[1] > 0 ) { $c->add( 'meta-refresh', $meta, array( 'seconds' => (int) $m[1] ) ); } } $this->references( $c ); } /** * Headings. * * @param Context $c Context. * @param \DOMElement $bod Body. * @return void */ private function headings( Context $c, \DOMElement $bod ) { $heads = $c->q( '//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6 or @role="heading"]' ); $h1s = 0; $prev = 0; foreach ( $heads as $h ) { if ( Html::is_hidden( $h ) || 'true' === $h->getAttribute( 'aria-hidden' ) ) { continue; } $tag = strtolower( $h->tagName ); $level = (int) substr( $tag, 1 ); if ( 'h' !== $tag[0] || strlen( $tag ) !== 2 ) { $level = (int) $h->getAttribute( 'aria-level' ); if ( $level < 1 ) { $level = 2; } } if ( 1 === $level ) { ++$h1s; } if ( '' === trim( Html::clean( Html::text_of( $h ) ) ) && '' === trim( $h->getAttribute( 'aria-label' ) ) ) { $c->add( 'heading-empty', $h ); continue; } if ( $prev > 0 && $level > $prev + 1 ) { $c->add( 'heading-skipped', $h, array( 'from' => $prev, 'to' => $level, ) ); } $prev = $level; } if ( 0 === $h1s ) { $c->add( 'h1-missing', $bod ); } } /** * Skip link. * * @param Context $c Context. * @param \DOMElement $bod Body. * @return void */ private function skip_link( Context $c, \DOMElement $bod ) { // WordPress 6.4+ adds its own skip link with JavaScript, so look for its script. if ( $c->xp->query( "//script[contains(@id,'skip-link')]" )->length ) { return; } $links = $c->q( "//body//a[starts-with(@href,'#') and string-length(@href) > 1]" ); $n = 0; foreach ( $links as $a ) { ++$n; if ( $n > 12 ) { break; } $text = strtolower( Html::clean( Html::text_of( $a ) ) . ' ' . $a->getAttribute( 'aria-label' ) ); if ( preg_match( '/skip|jump to|go to (the )?(main|content)|main content/', $text ) ) { return; } } $c->add( 'skip-link-missing', $bod ); } /** * ARIA and label references. * * @param Context $c Context. * @return void */ private function references( Context $c ) { $ids = array(); $counts = array(); foreach ( $c->q( '//*[@id]' ) as $el ) { $id = $el->getAttribute( 'id' ); $ids[ $id ] = true; $counts[ $id ] = isset( $counts[ $id ] ) ? $counts[ $id ] + 1 : 1; } $referenced = array(); foreach ( $c->q( '//*[@aria-labelledby or @aria-describedby]' ) as $el ) { foreach ( array( 'aria-labelledby', 'aria-describedby' ) as $attr ) { if ( ! $el->hasAttribute( $attr ) ) { continue; } foreach ( preg_split( '/\s+/', trim( $el->getAttribute( $attr ) ) ) as $id ) { if ( '' === $id ) { continue; } $referenced[ $id ] = true; if ( ! isset( $ids[ $id ] ) && ! Html::is_hidden( $el ) ) { $c->add( 'aria-ref-missing-id', $el, array( 'attribute' => $attr, 'id' => $id, ) ); } } } } foreach ( $c->q( '//label[@for]' ) as $label ) { $for = $label->getAttribute( 'for' ); if ( '' !== $for ) { $referenced[ $for ] = true; if ( ! isset( $ids[ $for ] ) && ! Html::is_hidden( $label ) ) { $c->add( 'aria-ref-missing-id', $label, array( 'attribute' => 'for', 'id' => $for, ) ); } } } foreach ( $referenced as $id => $_ ) { if ( isset( $counts[ $id ] ) && $counts[ $id ] > 1 ) { $dups = $c->q( '//*[@id=' . Html::xpath_literal( $id ) . ']' ); if ( count( $dups ) > 1 ) { $c->add( 'id-duplicate-referenced', $dups[1], array( 'id' => $id ) ); } } } } }