loadHTML( '' . $html, LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_COMPACT | LIBXML_PARSEHUGE );
libxml_clear_errors();
libxml_use_internal_errors( $prev );
return $ok ? $doc : null;
}
/**
* Tidy text.
*
* @param string $text Text.
* @return string
*/
public static function clean( $text ) {
return trim( preg_replace( '/\s+/u', ' ', (string) $text ) );
}
/**
* Does an id look machine generated (so unsuitable for a stable selector)?
*
* @param string $id Id.
* @return bool
*/
private static function unstable_id( $id ) {
return (bool) preg_match( '/\d{2,}|[0-9a-f]{8,}|^[a-z]+-[0-9]+$/i', $id );
}
/**
* Build a short, stable CSS selector for an element.
*
* @param \DOMElement $el Element.
* @return string
*/
public static function selector( \DOMElement $el ) {
$parts = array();
$node = $el;
$depth = 0;
while ( $node instanceof \DOMElement && $depth < 6 ) {
$tag = strtolower( $node->tagName );
if ( 'html' === $tag ) {
$parts[] = 'html';
break;
}
$id = $node->getAttribute( 'id' );
if ( '' !== $id && ! self::unstable_id( $id ) && preg_match( '/^[A-Za-z][\w-]*$/', $id ) ) {
$parts[] = $tag . '#' . $id;
break;
}
$part = $tag;
if ( 'body' === $tag ) {
$parts[] = 'body';
break;
}
$classes = preg_split( '/\s+/', trim( $node->getAttribute( 'class' ) ) );
$stable = array();
foreach ( (array) $classes as $c ) {
if ( '' !== $c && preg_match( '/^[A-Za-z_][\w-]*$/', $c ) && ! preg_match( '/\d{2,}|^(is|has)-|^wp-image-|^post-|^page-id|^align|^wp-block-group|^wp-site-blocks|^wp-singular|^page-template|-is-layout-/', $c ) ) {
$stable[] = $c;
}
if ( count( $stable ) >= 2 ) {
break;
}
}
if ( $stable ) {
$part .= '.' . implode( '.', $stable );
}
if ( 'body' !== $tag && $node->parentNode instanceof \DOMElement ) {
$same = 0;
$pos = 0;
foreach ( $node->parentNode->childNodes as $sib ) {
if ( $sib instanceof \DOMElement && $sib->tagName === $node->tagName ) {
++$same;
if ( $sib->isSameNode( $node ) ) {
$pos = $same;
}
}
}
if ( $same > 1 ) {
$part .= ':nth-of-type(' . $pos . ')';
}
}
$parts[] = $part;
if ( 'body' === $tag ) {
break;
}
$node = $node->parentNode;
++$depth;
}
return implode( ' > ', array_reverse( $parts ) );
}
/**
* Short, readable snippet of an element: its opening tag and a bit of text.
*
* @param \DOMElement $el Element.
* @return string
*/
public static function snippet( \DOMElement $el ) {
$tag = strtolower( $el->tagName );
if ( in_array( $tag, array( 'body', 'html', 'head', 'main' ), true ) ) {
return '<' . $tag . '>';
}
$attrs = '';
$skip = array( 'srcset', 'sizes', 'style', 'data-src', 'data-srcset', 'loading', 'decoding', 'fetchpriority' );
foreach ( $el->attributes as $attr ) {
if ( in_array( $attr->name, $skip, true ) || 0 === strpos( $attr->name, 'data-' ) ) {
continue;
}
$val = $attr->value;
if ( strlen( $val ) > 70 ) {
$val = substr( $val, 0, 67 ) . '...';
}
$attrs .= ' ' . $attr->name . '="' . str_replace( '"', '"', $val ) . '"';
if ( strlen( $attrs ) > 220 ) {
$attrs .= ' ...';
break;
}
}
$void = in_array( $tag, array( 'img', 'input', 'br', 'hr', 'meta', 'link', 'area', 'source', 'track' ), true );
if ( $void ) {
return '<' . $tag . $attrs . '>';
}
$text = self::clean( $el->textContent );
if ( strlen( $text ) > 60 ) {
$text = rtrim( substr( $text, 0, 57 ) ) . '...';
}
return '<' . $tag . $attrs . '>' . $text . '' . $tag . '>';
}
/**
* Normalise a selector or snippet so template-level repeats get the same fingerprint.
*
* @param string $value Value.
* @return string
*/
public static function normalise( $value ) {
$value = strtolower( self::clean( $value ) );
$value = preg_replace( '/\b(wp-image|post|page-id|postid|attachment|menu-item|cat-item|comment)-\d+/', '$1-n', $value );
$value = preg_replace( '/\b(\w+)-\d{4,}\b/', '$1-n', $value );
return $value;
}
/**
* Resolve a link against the page URL so relative and absolute forms compare equal.
*
* @param string $href Link.
* @param string $base Page URL.
* @return string
*/
public static function resolve( $href, $base ) {
$href = trim( preg_replace( '/#.*$/', '', $href ) );
if ( preg_match( '#^https?://#i', $href ) ) {
return strtolower( untrailingslashit( $href ) );
}
$p = wp_parse_url( $base );
if ( empty( $p['host'] ) ) {
return strtolower( untrailingslashit( $href ) );
}
$origin = ( isset( $p['scheme'] ) ? $p['scheme'] : 'http' ) . '://' . $p['host'] . ( isset( $p['port'] ) ? ':' . $p['port'] : '' );
if ( 0 === strpos( $href, '//' ) ) {
return strtolower( untrailingslashit( ( isset( $p['scheme'] ) ? $p['scheme'] : 'http' ) . ':' . $href ) );
}
if ( 0 === strpos( $href, '/' ) ) {
return strtolower( untrailingslashit( $origin . $href ) );
}
if ( 0 === strpos( $href, '?' ) ) {
return strtolower( untrailingslashit( $origin . ( isset( $p['path'] ) ? $p['path'] : '/' ) . $href ) );
}
$dir = isset( $p['path'] ) ? preg_replace( '#/[^/]*$#', '/', $p['path'] ) : '/';
return strtolower( untrailingslashit( $origin . $dir . $href ) );
}
/**
* Visible text of a node, skipping aria-hidden branches.
*
* @param \DOMNode $node Node.
* @return string
*/
public static function text_of( \DOMNode $node ) {
$out = '';
foreach ( $node->childNodes as $child ) {
if ( $child instanceof \DOMText ) {
$out .= $child->nodeValue;
} elseif ( $child instanceof \DOMElement ) {
$tag = strtolower( $child->tagName );
if ( in_array( $tag, array( 'script', 'style', 'template' ), true ) ) {
continue;
}
if ( 'true' === $child->getAttribute( 'aria-hidden' ) ) {
continue;
}
if ( 'img' === $tag ) {
$out .= ' ' . $child->getAttribute( 'alt' ) . ' ';
} elseif ( 'svg' === $tag ) {
$out .= ' ' . self::svg_name( $child ) . ' ';
} else {
$label = $child->getAttribute( 'aria-label' );
$out .= ' ' . ( '' !== $label ? $label : self::text_of( $child ) ) . ' ';
}
}
}
return $out;
}
/**
* Name of an SVG from aria-label / title.
*
* @param \DOMElement $svg SVG.
* @return string
*/
public static function svg_name( \DOMElement $svg ) {
if ( '' !== trim( $svg->getAttribute( 'aria-label' ) ) ) {
return trim( $svg->getAttribute( 'aria-label' ) );
}
foreach ( $svg->childNodes as $child ) {
if ( $child instanceof \DOMElement && 'title' === strtolower( $child->tagName ) ) {
return trim( $child->textContent );
}
}
return '';
}
/**
* Simplified accessible name for links, buttons and form controls.
*
* @param \DOMElement $el Element.
* @param \DOMXPath $xp XPath.
* @return string
*/
public static function accessible_name( \DOMElement $el, \DOMXPath $xp ) {
$by = trim( $el->getAttribute( 'aria-labelledby' ) );
if ( '' !== $by ) {
$text = '';
foreach ( preg_split( '/\s+/', $by ) as $id ) {
$target = self::by_id( $xp, $id );
if ( $target ) {
$text .= ' ' . self::clean( $target->textContent );
}
}
if ( '' !== trim( $text ) ) {
return trim( $text );
}
}
$label = trim( $el->getAttribute( 'aria-label' ) );
if ( '' !== $label ) {
return $label;
}
$tag = strtolower( $el->tagName );
if ( 'input' === $tag ) {
$type = strtolower( $el->getAttribute( 'type' ) );
if ( in_array( $type, array( 'submit', 'button', 'reset' ), true ) && '' !== trim( $el->getAttribute( 'value' ) ) ) {
return trim( $el->getAttribute( 'value' ) );
}
if ( 'submit' === $type || 'reset' === $type ) {
return 'x';
}
if ( 'image' === $type && '' !== trim( $el->getAttribute( 'alt' ) ) ) {
return trim( $el->getAttribute( 'alt' ) );
}
} else {
$text = self::clean( self::text_of( $el ) );
if ( '' !== $text ) {
return $text;
}
}
$title = trim( $el->getAttribute( 'title' ) );
return $title;
}
/**
* Find an element by id.
*
* @param \DOMXPath $xp XPath.
* @param string $id Id.
* @return \DOMElement|null
*/
public static function by_id( \DOMXPath $xp, $id ) {
if ( '' === $id ) {
return null;
}
$q = $xp->query( '//*[@id=' . self::xpath_literal( $id ) . ']' );
$node = $q && $q->length ? $q->item( 0 ) : null;
return $node instanceof \DOMElement ? $node : null;
}
/**
* Quote a string for XPath.
*
* @param string $s String.
* @return string
*/
public static function xpath_literal( $s ) {
if ( false === strpos( $s, "'" ) ) {
return "'" . $s . "'";
}
if ( false === strpos( $s, '"' ) ) {
return '"' . $s . '"';
}
return "concat('" . str_replace( "'", "',\"'\",'", $s ) . "')";
}
/**
* Is the element inside one of the ancestors that we should ignore?
*
* @param \DOMElement $el Element.
* @return bool
*/
public static function in_ignored( \DOMElement $el ) {
$node = $el;
while ( $node instanceof \DOMElement ) {
$tag = strtolower( $node->tagName );
if ( in_array( $tag, array( 'template', 'noscript', 'script', 'style' ), true ) ) {
return true;
}
$id = $node->getAttribute( 'id' );
if ( 'wpadminbar' === $id || 'a11ymate-toolbar' === $id || 'a11ymate-root' === $id ) {
return true;
}
$node = $node->parentNode;
}
return false;
}
/**
* Is the element hidden from everyone (display:none via hidden attribute or inline style)?
*
* @param \DOMElement $el Element.
* @return bool
*/
public static function is_hidden( \DOMElement $el ) {
$node = $el;
while ( $node instanceof \DOMElement ) {
if ( $node->hasAttribute( 'hidden' ) ) {
return true;
}
$style = strtolower( $node->getAttribute( 'style' ) );
if ( '' !== $style && preg_match( '/display\s*:\s*none|visibility\s*:\s*hidden/', $style ) ) {
return true;
}
if ( 'input' === strtolower( $node->tagName ) && 'hidden' === strtolower( $node->getAttribute( 'type' ) ) ) {
return true;
}
$node = $node->parentNode;
}
return false;
}
}