]+charset/i', substr( $html, 0, 4096 ) ) ? '' : ''; $loaded = $doc->loadHTML( $prefix . $html, LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET | LIBXML_COMPACT ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); if ( ! $loaded ) { return null; } $xp = new \DOMXPath( $doc ); $base = $url; $base_node = $xp->query( '//head/base[@href]' )->item( 0 ); if ( $base_node instanceof \DOMElement ) { $abs = Http::absolutize( $base_node->getAttribute( 'href' ), $url ); if ( $abs ) { $base = $abs; } } $facts = array( 'lang' => '', 'titles' => array(), 'meta_desc' => array(), 'canonicals' => array(), 'robots' => array(), 'meta_refresh' => '', 'viewport' => '', 'headings' => array(), 'images' => array( 'total' => 0, 'missing_alt' => array(), 'empty_alt_in_link' => array(), ), 'links' => array(), 'unnamed_links' => array(), 'generic_links' => array(), 'unlabeled_fields' => array(), 'placeholder_only' => array(), 'unnamed_buttons' => array(), 'invalid_roles' => array(), 'hidden_focusable' => array(), 'broken_aria_refs' => array(), 'unknown_aria' => array(), 'duplicate_ids' => array(), 'positive_tabindex' => 0, 'click_handlers' => array(), 'iframes_untitled' => array(), 'autoplay_media' => 0, 'contrast' => array(), 'mixed_content' => array(), 'jsonld' => array( 'count' => 0, 'invalid' => 0, 'types' => array(), ), 'word_count' => 0, 'text_hash' => '', 'script_count' => 0, 'forms_without_submit' => 0, 'required_without_hint' => 0, ); $html_el = $doc->getElementsByTagName( 'html' )->item( 0 ); if ( $html_el instanceof \DOMElement ) { $facts['lang'] = trim( $html_el->getAttribute( 'lang' ) ? $html_el->getAttribute( 'lang' ) : $html_el->getAttribute( 'xml:lang' ) ); } foreach ( $xp->query( '//title' ) as $node ) { // Ignore inside inline SVG. if ( self::inside( $node, 'svg' ) ) { continue; } $facts['titles'][] = self::clean( $node->textContent, 300 ); } foreach ( $xp->query( '//meta' ) as $meta ) { $name = strtolower( trim( $meta->getAttribute( 'name' ) ) ); $equiv = strtolower( trim( $meta->getAttribute( 'http-equiv' ) ) ); $content = trim( $meta->getAttribute( 'content' ) ); if ( 'description' === $name ) { $facts['meta_desc'][] = self::clean( $content, 500 ); } elseif ( 'robots' === $name || 'googlebot' === $name ) { $facts['robots'][] = strtolower( $content ); } elseif ( 'viewport' === $name ) { $facts['viewport'] = strtolower( $content ); } elseif ( 'refresh' === $equiv ) { $facts['meta_refresh'] = self::clean( $content, 300 ); } } foreach ( $xp->query( '//link[@rel]' ) as $link ) { $rels = preg_split( '/\s+/', strtolower( $link->getAttribute( 'rel' ) ) ); if ( in_array( 'canonical', $rels, true ) ) { $abs = Http::absolutize( $link->getAttribute( 'href' ), $base ); $facts['canonicals'][] = $abs ? $abs : $link->getAttribute( 'href' ); } } // Headings in document order. foreach ( $xp->query( '//h1|//h2|//h3|//h4|//h5|//h6' ) as $h ) { $facts['headings'][] = array( (int) substr( $h->nodeName, 1 ), self::clean( self::accessible_text( $h ), 120 ) ); if ( count( $facts['headings'] ) >= 200 ) { break; } } // Images. foreach ( $xp->query( '//img' ) as $img ) { ++$facts['images']['total']; $role = strtolower( $img->getAttribute( 'role' ) ); $hidden = 'true' === strtolower( $img->getAttribute( 'aria-hidden' ) ) || 'presentation' === $role || 'none' === $role; $src = $img->getAttribute( 'src' ) ? $img->getAttribute( 'src' ) : $img->getAttribute( 'data-src' ); if ( ! $img->hasAttribute( 'alt' ) && ! $hidden && '' === trim( $img->getAttribute( 'aria-label' ) ) && '' === trim( $img->getAttribute( 'aria-labelledby' ) ) ) { if ( count( $facts['images']['missing_alt'] ) < 50 ) { $facts['images']['missing_alt'][] = self::clean( $src, 300 ); } else { $facts['images']['missing_alt'][] = ''; } } } // Links. $generic = array( 'click here', 'here', 'read more', 'more', 'learn more', 'link', 'this', 'continue', 'details', 'click', 'go' ); foreach ( $xp->query( '//a[@href]' ) as $a ) { $href = trim( $a->getAttribute( 'href' ) ); $name = self::accessible_name( $a, $xp ); if ( '' === $name && 'true' !== strtolower( $a->getAttribute( 'aria-hidden' ) ) ) { if ( count( $facts['unnamed_links'] ) < 30 ) { $facts['unnamed_links'][] = self::clean( $href, 200 ); } } elseif ( in_array( strtolower( trim( $name, " .:!>\xC2\xA0" ) ), $generic, true ) ) { if ( count( $facts['generic_links'] ) < 30 ) { $facts['generic_links'][] = self::clean( $name, 60 ) . ' → ' . self::clean( $href, 150 ); } } $abs = Http::absolutize( $href, $base ); if ( $abs && count( $facts['links'] ) < 500 ) { $rel = strtolower( $a->getAttribute( 'rel' ) ); $facts['links'][] = array( $abs, self::clean( $name, 100 ), false !== strpos( $rel, 'nofollow' ) ); } } // Form controls. $ids = array(); foreach ( $xp->query( '//*[@id]' ) as $el ) { $id = $el->getAttribute( 'id' ); if ( '' !== $id ) { $ids[ $id ] = isset( $ids[ $id ] ) ? $ids[ $id ] + 1 : 1; } } foreach ( $ids as $id => $count ) { if ( $count > 1 && count( $facts['duplicate_ids'] ) < 20 ) { $facts['duplicate_ids'][] = self::clean( $id, 80 ) . ' ×' . $count; } } $label_for = array(); foreach ( $xp->query( '//label[@for]' ) as $label ) { if ( '' !== trim( self::accessible_text( $label ) ) ) { $label_for[ $label->getAttribute( 'for' ) ] = true; } } foreach ( $xp->query( '//input|//select|//textarea' ) as $field ) { $type = strtolower( $field->getAttribute( 'type' ) ); if ( 'input' === $field->nodeName && in_array( $type, array( 'hidden', 'submit', 'button', 'reset', 'image' ), true ) ) { if ( in_array( $type, array( 'submit', 'button', 'reset' ), true ) && '' === trim( $field->getAttribute( 'value' ) ) && '' === trim( $field->getAttribute( 'aria-label' ) ) && 'submit' !== $type && 'reset' !== $type ) { $facts['unnamed_buttons'][] = 'input[type=' . $type . ']'; } if ( 'image' === $type && '' === trim( $field->getAttribute( 'alt' ) ) && '' === trim( $field->getAttribute( 'aria-label' ) ) ) { $facts['unnamed_buttons'][] = 'input[type=image]'; } continue; } if ( 'true' === strtolower( $field->getAttribute( 'aria-hidden' ) ) || $field->hasAttribute( 'disabled' ) && 'none' === $field->getAttribute( 'role' ) ) { continue; } $id = $field->getAttribute( 'id' ); $labelled = ( '' !== $id && isset( $label_for[ $id ] ) ) || self::inside( $field, 'label' ) || '' !== trim( $field->getAttribute( 'aria-label' ) ) || self::labelledby_text( $field, $xp ) !== '' || '' !== trim( $field->getAttribute( 'title' ) ); if ( ! $labelled ) { $desc = $field->nodeName . ( $type ? '[type=' . $type . ']' : '' ) . ( $field->getAttribute( 'name' ) ? '[name=' . $field->getAttribute( 'name' ) . ']' : '' ); if ( '' !== trim( $field->getAttribute( 'placeholder' ) ) ) { $facts['placeholder_only'][] = self::clean( $desc, 120 ); } else { $facts['unlabeled_fields'][] = self::clean( $desc, 120 ); } } if ( $field->hasAttribute( 'required' ) && '' === trim( $field->getAttribute( 'aria-describedby' ) ) ) { ++$facts['required_without_hint']; } } foreach ( $xp->query( '//button|//*[@role="button"]' ) as $button ) { if ( 'true' === strtolower( $button->getAttribute( 'aria-hidden' ) ) ) { continue; } if ( '' === self::accessible_name( $button, $xp ) && count( $facts['unnamed_buttons'] ) < 30 ) { $cls = $button->getAttribute( 'class' ); $facts['unnamed_buttons'][] = self::clean( $button->nodeName . ( $cls ? '.' . preg_replace( '/\s+/', '.', trim( $cls ) ) : '' ), 120 ); } } // ARIA validity. $valid_roles = self::valid_roles(); $aria_attrs = self::valid_aria_attributes(); foreach ( $xp->query( '//*[@role]' ) as $el ) { foreach ( preg_split( '/\s+/', strtolower( trim( $el->getAttribute( 'role' ) ) ) ) as $role ) { if ( '' !== $role && ! in_array( $role, $valid_roles, true ) && count( $facts['invalid_roles'] ) < 20 ) { $facts['invalid_roles'][] = $el->nodeName . '[role=' . self::clean( $role, 40 ) . ']'; } } } foreach ( $xp->query( '//*[@*[starts-with(name(),"aria-")]]' ) as $el ) { foreach ( $el->attributes as $attr ) { $n = strtolower( $attr->nodeName ); if ( 0 === strpos( $n, 'aria-' ) && ! in_array( $n, $aria_attrs, true ) && count( $facts['unknown_aria'] ) < 20 ) { $facts['unknown_aria'][] = $el->nodeName . '[' . self::clean( $n, 40 ) . ']'; } } if ( 'true' === strtolower( $el->getAttribute( 'aria-hidden' ) ) && self::is_focusable( $el ) && count( $facts['hidden_focusable'] ) < 20 ) { $facts['hidden_focusable'][] = $el->nodeName . ( $el->getAttribute( 'href' ) ? '[href=' . self::clean( $el->getAttribute( 'href' ), 80 ) . ']' : '' ); } foreach ( array( 'aria-labelledby', 'aria-describedby' ) as $ref_attr ) { if ( $el->hasAttribute( $ref_attr ) ) { foreach ( preg_split( '/\s+/', trim( $el->getAttribute( $ref_attr ) ) ) as $ref ) { if ( '' !== $ref && ! isset( $ids[ $ref ] ) && count( $facts['broken_aria_refs'] ) < 20 ) { $facts['broken_aria_refs'][] = $ref_attr . '="' . self::clean( $ref, 60 ) . '"'; } } } } } foreach ( $xp->query( '//*[@tabindex]' ) as $el ) { if ( (int) $el->getAttribute( 'tabindex' ) > 0 ) { ++$facts['positive_tabindex']; } } foreach ( $xp->query( '//div[@onclick]|//span[@onclick]|//li[@onclick]|//img[@onclick]' ) as $el ) { if ( ! $el->hasAttribute( 'role' ) && ! $el->hasAttribute( 'tabindex' ) && count( $facts['click_handlers'] ) < 20 ) { $facts['click_handlers'][] = $el->nodeName . ( $el->getAttribute( 'class' ) ? '.' . self::clean( preg_replace( '/\s+/', '.', trim( $el->getAttribute( 'class' ) ) ), 60 ) : '' ); } } foreach ( $xp->query( '//iframe' ) as $iframe ) { if ( '' === trim( $iframe->getAttribute( 'title' ) ) && '' === trim( $iframe->getAttribute( 'aria-label' ) ) && 'true' !== strtolower( $iframe->getAttribute( 'aria-hidden' ) ) ) { $facts['iframes_untitled'][] = self::clean( $iframe->getAttribute( 'src' ), 200 ); } } foreach ( $xp->query( '//video[@autoplay]|//audio[@autoplay]' ) as $media ) { if ( ! $media->hasAttribute( 'muted' ) ) { ++$facts['autoplay_media']; } } // Inline-style contrast (only when both colours are explicitly declared). foreach ( $xp->query( '//*[@style]' ) as $el ) { $style = strtolower( $el->getAttribute( 'style' ) ); if ( false === strpos( $style, 'color' ) ) { continue; } $fg = self::style_color( $style, 'color' ); $bg = self::style_color( $style, 'background-color' ); if ( null === $bg ) { $bg = self::style_color( $style, 'background' ); } $text = trim( $el->textContent ); if ( null !== $fg && null !== $bg && '' !== $text ) { $ratio = self::contrast_ratio( $fg, $bg ); if ( $ratio < 4.5 && count( $facts['contrast'] ) < 20 ) { $facts['contrast'][] = sprintf( '%s "%s" ratio %.2f:1', $el->nodeName, self::clean( $text, 40 ), $ratio ); } } } // Mixed content (only meaningful on HTTPS pages). if ( 0 === stripos( $url, 'https://' ) ) { $q = '//img[@src]|//script[@src]|//iframe[@src]|//video[@src]|//audio[@src]|//source[@src]|//link[@href][contains(translate(@rel,"STYLESHEET","stylesheet"),"stylesheet")]'; foreach ( $xp->query( $q ) as $el ) { $ref = $el->hasAttribute( 'src' ) ? $el->getAttribute( 'src' ) : $el->getAttribute( 'href' ); if ( 0 === stripos( trim( $ref ), 'http://' ) && count( $facts['mixed_content'] ) < 30 ) { $facts['mixed_content'][] = $el->nodeName . ': ' . self::clean( $ref, 200 ); } } } // Structured data. foreach ( $xp->query( '//script[@type="application/ld+json"]' ) as $script ) { ++$facts['jsonld']['count']; $json = json_decode( trim( $script->textContent ), true ); if ( null === $json && JSON_ERROR_NONE !== json_last_error() ) { ++$facts['jsonld']['invalid']; continue; } foreach ( self::jsonld_types( $json ) as $type ) { $facts['jsonld']['types'][ $type ] = true; } } $facts['jsonld']['types'] = array_slice( array_keys( $facts['jsonld']['types'] ), 0, 20 ); $facts['script_count'] = $xp->query( '//script' )->length; // Visible text of the main content area (falls back to <body>) so that // shared navigation/footer text does not distort word counts or duplicates. $body = null; foreach ( array( '//main', '//*[@role="main"]', '//article' ) as $main_query ) { $candidate = $xp->query( $main_query )->item( 0 ); if ( $candidate && strlen( trim( $candidate->textContent ) ) > 0 ) { $body = $candidate; break; } } if ( ! $body ) { $body = $doc->getElementsByTagName( 'body' )->item( 0 ); } if ( $body ) { foreach ( array( 'script', 'style', 'noscript', 'template', 'svg' ) as $tag ) { $nodes = array(); foreach ( $body->getElementsByTagName( $tag ) as $n ) { $nodes[] = $n; } foreach ( $nodes as $n ) { if ( $n->parentNode ) { $n->parentNode->removeChild( $n ); } } } $text = preg_replace( '/\s+/u', ' ', $body->textContent ); $words = preg_split( '/[\s\p{P}]+/u', trim( (string) $text ), -1, PREG_SPLIT_NO_EMPTY ); $facts['word_count'] = is_array( $words ) ? count( $words ) : 0; if ( $facts['word_count'] >= 50 ) { $facts['text_hash'] = md5( mb_strtolower( (string) $text ) ); } } return $facts; } /** * Whether a node has an ancestor with the given tag. * * @param \DOMNode $node Node. * @param string $tag Tag. * @return bool */ private static function inside( $node, $tag ) { for ( $p = $node->parentNode; $p; $p = $p->parentNode ) { if ( $p instanceof \DOMElement && strtolower( $p->nodeName ) === $tag ) { return true; } } return false; } /** * Text content including img alt text (approximate accessible text). * * @param \DOMNode $node Node. * @return string */ private static function accessible_text( $node ) { $out = ''; foreach ( $node->childNodes as $child ) { if ( XML_TEXT_NODE === $child->nodeType ) { $out .= $child->nodeValue; } elseif ( $child instanceof \DOMElement ) { if ( 'true' === strtolower( $child->getAttribute( 'aria-hidden' ) ) || in_array( strtolower( $child->nodeName ), array( 'script', 'style' ), true ) ) { continue; } if ( 'img' === strtolower( $child->nodeName ) ) { $out .= ' ' . $child->getAttribute( 'alt' ) . ' '; } elseif ( 'svg' === strtolower( $child->nodeName ) ) { $out .= ' ' . $child->getAttribute( 'aria-label' ) . ' '; foreach ( $child->getElementsByTagName( 'title' ) as $t ) { $out .= ' ' . $t->textContent . ' '; } } else { $label = $child->getAttribute( 'aria-label' ); $out .= '' !== trim( $label ) ? ' ' . $label . ' ' : self::accessible_text( $child ); } } } return trim( preg_replace( '/\s+/u', ' ', $out ) ); } /** * Text referenced by aria-labelledby. * * @param \DOMElement $el Element. * @param \DOMXPath $xp XPath. * @return string */ private static function labelledby_text( $el, $xp ) { $ref = trim( $el->getAttribute( 'aria-labelledby' ) ); if ( '' === $ref ) { return ''; } $text = ''; foreach ( preg_split( '/\s+/', $ref ) as $id ) { if ( '' === $id || false !== strpos( $id, '"' ) ) { continue; } $node = $xp->query( '//*[@id="' . $id . '"]' )->item( 0 ); if ( $node ) { $text .= ' ' . $node->textContent; } } return trim( $text ); } /** * Approximate accessible name for links/buttons. * * @param \DOMElement $el Element. * @param \DOMXPath $xp XPath. * @return string */ private static function accessible_name( $el, $xp ) { $label = trim( $el->getAttribute( 'aria-label' ) ); if ( '' !== $label ) { return $label; } $by = self::labelledby_text( $el, $xp ); if ( '' !== $by ) { return $by; } $text = self::accessible_text( $el ); if ( '' !== $text ) { return $text; } return trim( $el->getAttribute( 'title' ) ); } /** * Whether an element is natively focusable. * * @param \DOMElement $el Element. * @return bool */ private static function is_focusable( $el ) { $tag = strtolower( $el->nodeName ); if ( $el->hasAttribute( 'tabindex' ) && (int) $el->getAttribute( 'tabindex' ) >= 0 ) { return true; } if ( 'a' === $tag && $el->hasAttribute( 'href' ) ) { return true; } return in_array( $tag, array( 'button', 'input', 'select', 'textarea' ), true ) && ! $el->hasAttribute( 'disabled' ); } /** * Collect @type values from JSON-LD. * * @param mixed $json Decoded JSON. * @param int $depth Depth guard. * @return array */ private static function jsonld_types( $json, $depth = 0 ) { $types = array(); if ( ! is_array( $json ) || $depth > 4 ) { return $types; } if ( isset( $json['@type'] ) ) { foreach ( (array) $json['@type'] as $t ) { if ( is_string( $t ) ) { $types[] = sanitize_text_field( $t ); } } } foreach ( array( '@graph' ) as $k ) { if ( isset( $json[ $k ] ) && is_array( $json[ $k ] ) ) { foreach ( $json[ $k ] as $node ) { $types = array_merge( $types, self::jsonld_types( $node, $depth + 1 ) ); } } } if ( isset( $json[0] ) ) { foreach ( $json as $node ) { $types = array_merge( $types, self::jsonld_types( $node, $depth + 1 ) ); } } return $types; } /** * Parse a colour declared in an inline style. * * @param string $style Style attribute (lowercase). * @param string $prop Property. * @return array|null RGB. */ private static function style_color( $style, $prop ) { if ( ! preg_match( '/(?:^|;)\s*' . preg_quote( $prop, '/' ) . '\s*:\s*([^;!]+)/', $style, $m ) ) { return null; } $value = trim( $m[1] ); if ( preg_match( '/#([0-9a-f]{3}|[0-9a-f]{6})\b/', $value, $hex ) ) { $h = $hex[1]; if ( 3 === strlen( $h ) ) { $h = $h[0] . $h[0] . $h[1] . $h[1] . $h[2] . $h[2]; } return array( hexdec( substr( $h, 0, 2 ) ), hexdec( substr( $h, 2, 2 ) ), hexdec( substr( $h, 4, 2 ) ) ); } if ( preg_match( '/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?/', $value, $rgb ) ) { if ( isset( $rgb[4] ) && '' !== $rgb[4] && (float) $rgb[4] < 1 ) { return null; // Transparency makes the result unreliable. } return array( min( 255, (int) $rgb[1] ), min( 255, (int) $rgb[2] ), min( 255, (int) $rgb[3] ) ); } $named = array( 'white' => array( 255, 255, 255 ), 'black' => array( 0, 0, 0 ), ); return isset( $named[ $value ] ) ? $named[ $value ] : null; } /** * WCAG relative-luminance contrast ratio. * * @param array $a RGB. * @param array $b RGB. * @return float */ public static function contrast_ratio( array $a, array $b ) { $lum = static function ( array $c ) { $out = array(); foreach ( $c as $v ) { $v = $v / 255; $out[] = $v <= 0.03928 ? $v / 12.92 : pow( ( $v + 0.055 ) / 1.055, 2.4 ); } return 0.2126 * $out[0] + 0.7152 * $out[1] + 0.0722 * $out[2]; }; $l1 = $lum( $a ); $l2 = $lum( $b ); return ( max( $l1, $l2 ) + 0.05 ) / ( min( $l1, $l2 ) + 0.05 ); } /** * Normalize whitespace and truncate. * * @param string $text Text. * @param int $max Max chars. * @return string */ private static function clean( $text, $max ) { $text = trim( preg_replace( '/\s+/u', ' ', (string) $text ) ); return mb_strlen( $text ) > $max ? mb_substr( $text, 0, $max - 1 ) . '…' : $text; } /** * WAI-ARIA 1.2 roles (plus common DPUB/graphics roles). * * @return array */ private static function valid_roles() { return array( 'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid', 'gridcell', 'group', 'heading', 'img', 'image', 'insertion', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'mark', 'marquee', 'math', 'menu', 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup', 'region', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'separator', 'slider', 'spinbutton', 'status', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem', 'graphics-document', 'graphics-object', 'graphics-symbol', 'doc-abstract', 'doc-acknowledgments', 'doc-afterword', 'doc-appendix', 'doc-backlink', 'doc-biblioentry', 'doc-bibliography', 'doc-biblioref', 'doc-chapter', 'doc-colophon', 'doc-conclusion', 'doc-cover', 'doc-credit', 'doc-credits', 'doc-dedication', 'doc-endnote', 'doc-endnotes', 'doc-epigraph', 'doc-epilogue', 'doc-errata', 'doc-example', 'doc-footnote', 'doc-foreword', 'doc-glossary', 'doc-glossref', 'doc-index', 'doc-introduction', 'doc-noteref', 'doc-notice', 'doc-pagebreak', 'doc-pagelist', 'doc-part', 'doc-preface', 'doc-prologue', 'doc-pullquote', 'doc-qna', 'doc-subtitle', 'doc-tip', 'doc-toc' ); } /** * WAI-ARIA 1.2 attributes. * * @return array */ private static function valid_aria_attributes() { return array( 'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy', 'aria-checked', 'aria-colcount', 'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description', 'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-expanded', 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live', 'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation', 'aria-owns', 'aria-placeholder', 'aria-posinset', 'aria-pressed', 'aria-readonly', 'aria-relevant', 'aria-required', 'aria-roledescription', 'aria-rowcount', 'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected', 'aria-setsize', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext' ); } }