*/ private array $a11y = array(); /** * Content facts of the page being parsed: kind, text, source type, source reference. * * @var array */ private array $content = array(); /** * Ids of fields that a points to. * * @var array */ private array $label_for = array(); /** * How often each id appears, with the source of its first use: id => [count, type, ref]. * * @var array */ private array $ids = array(); /** * Links seen so far: whether the first few include an in-page link, and the number of links. * * @var array{skip: bool, links: int} */ private array $links = array( 'skip' => false, 'links' => 0, ); /** * Performance facts of the page: kind, text, source type, source reference. * * @var array */ private array $perf = array(); /** * Images seen so far, in document order. * * @var int */ private int $image_count = 0; /** * Base URL for relative values. * * @var string */ private string $base = ''; /** * Parses a page. * * @param string $html HTML. * @param string $url Page URL, used to resolve relative URLs. * @param array $headers Response headers with lower-case names; X-Robots-Tag is read from them. * @phpstan-param array $headers */ public function parse( string $html, string $url, array $headers = array() ): ParsedPage { $this->stack = array(); $this->references = array(); $this->base = $url; $dom = new \DOMDocument(); $previous = libxml_use_internal_errors( true ); $dom->loadHTML( '' . $html, LIBXML_NONET | LIBXML_COMPACT | LIBXML_NOERROR | LIBXML_NOWARNING ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); $title = ''; $noindex = false; $facts = array( 'title_count' => 0, 'descriptions' => array(), 'canonicals' => array(), 'robots' => array(), 'open_graph' => array(), 'twitter' => array(), 'json_ld' => array(), 'hreflang' => array(), ); foreach ( $dom->getElementsByTagName( 'base' ) as $base ) { $resolved = Url::resolve( $base->getAttribute( 'href' ), $url ); if ( null !== $resolved ) { $this->base = $resolved; } break; } foreach ( $dom->getElementsByTagName( 'title' ) as $node ) { if ( 0 === $facts['title_count'] ) { $title = trim( (string) preg_replace( '/\s+/', ' ', $node->textContent ) ); } ++$facts['title_count']; } foreach ( $dom->getElementsByTagName( 'meta' ) as $meta ) { $name = strtolower( trim( $meta->getAttribute( 'name' ) ) ); $prop = strtolower( trim( $meta->getAttribute( 'property' ) ) ); $content = trim( $meta->getAttribute( 'content' ) ); if ( 'robots' === $name ) { $facts['robots'][] = strtolower( $content ); if ( str_contains( strtolower( $content ), 'noindex' ) ) { $noindex = true; } } elseif ( 'description' === $name ) { $facts['descriptions'][] = trim( (string) preg_replace( '/\s+/', ' ', $content ) ); } elseif ( str_starts_with( $prop, 'og:' ) ) { $facts['open_graph'][ $prop ][] = $content; } elseif ( str_starts_with( $name, 'twitter:' ) ) { $facts['twitter'][ $name ][] = $content; } } foreach ( $dom->getElementsByTagName( 'link' ) as $link ) { $rels = preg_split( '/\s+/', strtolower( trim( $link->getAttribute( 'rel' ) ) ) ); if ( is_array( $rels ) && in_array( 'canonical', $rels, true ) ) { $href = trim( $link->getAttribute( 'href' ) ); $facts['canonicals'][] = Url::resolve( $href, $this->base ) ?? $href; } if ( is_array( $rels ) && in_array( 'alternate', $rels, true ) && '' !== trim( $link->getAttribute( 'hreflang' ) ) ) { $href = trim( $link->getAttribute( 'href' ) ); $facts['hreflang'][] = array( trim( $link->getAttribute( 'hreflang' ) ), Url::resolve( $href, $this->base ) ?? $href ); } } foreach ( $dom->getElementsByTagName( 'script' ) as $script ) { if ( 'application/ld+json' === strtolower( trim( $script->getAttribute( 'type' ) ) ) ) { $facts['json_ld'][] = trim( $script->textContent ); } } $header_robots = strtolower( trim( $headers['x-robots-tag'] ?? '' ) ); if ( str_contains( $header_robots, 'noindex' ) ) { $noindex = true; } $this->perf = array( array( 'html_bytes', (string) strlen( $html ), Source::TEMPLATE, '' ) ); $this->image_count = 0; $this->headings = array(); $this->a11y = array(); $this->content = array(); $this->ids = array(); $this->label_for = array(); $this->links = array( 'skip' => false, 'links' => 0, ); foreach ( $dom->getElementsByTagName( 'label' ) as $label ) { if ( '' !== trim( $label->getAttribute( 'for' ) ) ) { $this->label_for[ trim( $label->getAttribute( 'for' ) ) ] = true; } } $this->walk( $dom ); $this->document_problems( $dom ); $this->content_facts( $dom ); foreach ( $facts['hreflang'] as $tag ) { $this->content[] = array( 'hreflang', $tag[0] . '|' . $tag[1], Source::TEMPLATE, '' ); } $sketch = TextSketch::of( self::main_text( $dom ) ); return new ParsedPage( $url, $title, $noindex, $this->references, new PageFacts( $facts['title_count'], $facts['descriptions'], $facts['canonicals'], $facts['robots'], $facts['open_graph'], $facts['twitter'], $this->headings, $facts['json_ld'], $header_robots, $sketch, $this->a11y, $this->content, $this->perf ) ); } /** * Text of the main part of the page: the element, or the body without navigation, * header, footer, sidebars, forms and scripts. Changes the document, so it runs last. * * @param \DOMDocument $dom Document. */ private static function main_text( \DOMDocument $dom ): string { $root = $dom->getElementsByTagName( 'main' )->item( 0 ) ?? $dom->getElementsByTagName( 'body' )->item( 0 ); if ( ! $root instanceof \DOMElement ) { return ''; } foreach ( array( 'script', 'style', 'noscript', 'nav', 'header', 'footer', 'aside', 'form' ) as $tag ) { foreach ( iterator_to_array( $root->getElementsByTagName( $tag ) ) as $node ) { $node->parentNode?->removeChild( $node ); } } return $root->textContent; } /** * Visits nodes in document order. * * @param \DOMNode $node Parent node. */ private function walk( \DOMNode $node ): void { foreach ( $node->childNodes as $child ) { if ( $child instanceof \DOMComment ) { $this->marker( $child->data ); } elseif ( $child instanceof \DOMElement ) { $this->element( $child ); if ( $child->hasChildNodes() ) { $this->walk( $child ); } } } } /** * Opens or closes a source marker. * * @param string $comment Comment text. */ private function marker( string $comment ): void { $marker = Source::parse_marker( $comment ); if ( null === $marker ) { return; } [ $kind, $source ] = $marker; if ( 's' === $kind ) { $this->stack[] = $source; return; } // Close the matching marker and anything left open inside it. for ( $i = count( $this->stack ) - 1; $i >= 0; $i-- ) { if ( $this->stack[ $i ]->type === $source->type && $this->stack[ $i ]->ref === $source->ref ) { $this->stack = array_slice( $this->stack, 0, $i ); return; } } } /** * Collects the URLs of one element. * * @param \DOMElement $el Element. */ private function element( \DOMElement $el ): void { $tag = strtolower( $el->tagName ); if ( 1 === preg_match( '/^h([1-6])$/', $tag, $level ) ) { $text = trim( (string) preg_replace( '/\s+/', ' ', $el->textContent ) ); if ( '' === $text ) { foreach ( $el->getElementsByTagName( 'img' ) as $img ) { $text = trim( $img->getAttribute( 'alt' ) ); break; } } $this->headings[] = new Heading( (int) $level[1], mb_substr( $text, 0, self::TEXT_LENGTH ), array() === $this->stack ? Source::template() : $this->stack[ count( $this->stack ) - 1 ] ); } $this->accessibility( $el, $tag ); $this->performance( $el, $tag ); switch ( $tag ) { case 'a': case 'area': $text = trim( (string) preg_replace( '/\s+/', ' ', $el->textContent ) ); if ( '' === $text ) { foreach ( $el->getElementsByTagName( 'img' ) as $img ) { $text = trim( $img->getAttribute( 'alt' ) ); break; } } $this->add( Reference::LINK, $el->getAttribute( 'href' ), $tag, $text ); break; case 'img': $alt = $el->getAttribute( 'alt' ); $this->add( Reference::IMAGE, $el->getAttribute( 'src' ), $tag, $alt ); $this->add( Reference::IMAGE, $el->getAttribute( 'data-src' ), $tag, $alt ); $this->add_srcset( $el->getAttribute( 'srcset' ), $tag, $alt ); $this->add_srcset( $el->getAttribute( 'data-srcset' ), $tag, $alt ); break; case 'source': $parent = $el->parentNode instanceof \DOMElement ? strtolower( $el->parentNode->tagName ) : ''; if ( 'picture' === $parent ) { $this->add_srcset( $el->getAttribute( 'srcset' ), $tag, '' ); } else { $this->add( Reference::MEDIA, $el->getAttribute( 'src' ), $tag, '' ); } break; case 'video': case 'audio': $this->add( Reference::MEDIA, $el->getAttribute( 'src' ), $tag, '' ); $this->add( Reference::IMAGE, $el->getAttribute( 'poster' ), $tag, '' ); break; case 'script': $this->add( Reference::SCRIPT, $el->getAttribute( 'src' ), $tag, '' ); break; case 'link': $rel = ' ' . strtolower( $el->getAttribute( 'rel' ) ) . ' '; if ( str_contains( $rel, ' stylesheet ' ) ) { $this->add( Reference::STYLE, $el->getAttribute( 'href' ), $tag, '' ); } break; case 'iframe': $this->add( Reference::FRAME, $el->getAttribute( 'src' ), $tag, $el->getAttribute( 'title' ) ); break; } $style = $el->getAttribute( 'style' ); if ( '' !== $style && preg_match_all( '/url\(\s*([\'"]?)(.*?)\1\s*\)/i', $style, $m ) ) { foreach ( $m[2] as $value ) { $this->add( Reference::IMAGE, $value, $tag, '' ); } } } /** * Notes scripts that block the first paint and the lazy loading of images. * * @param \DOMElement $el Element. * @param string $tag Lower-case element name. */ private function performance( \DOMElement $el, string $tag ): void { $source = array() === $this->stack ? Source::template() : $this->stack[ count( $this->stack ) - 1 ]; if ( 'script' === $tag ) { $src = trim( $el->getAttribute( 'src' ) ); $type = strtolower( trim( $el->getAttribute( 'type' ) ) ); $url = '' === $src ? null : Url::resolve( $src, $this->base ); if ( null !== $url && ! $el->hasAttribute( 'async' ) && ! $el->hasAttribute( 'defer' ) && 'module' !== $type && self::inside( $el, 'head' ) ) { $this->perf[] = array( 'blocking_script', $url, $source->type, $source->ref ); } return; } if ( 'img' !== $tag || self::inside( $el, 'noscript' ) ) { return; } $src = trim( $el->getAttribute( 'src' ) ); $url = '' === $src || str_starts_with( $src, 'data:' ) ? null : Url::resolve( $src, $this->base ); if ( null === $url || ( '1' === $el->getAttribute( 'width' ) && '1' === $el->getAttribute( 'height' ) ) ) { return; } ++$this->image_count; $lazy = 'lazy' === strtolower( trim( $el->getAttribute( 'loading' ) ) ); if ( 1 === $this->image_count && $lazy ) { $this->perf[] = array( 'lazy_first', $url, $source->type, $source->ref ); } elseif ( $this->image_count > 3 && ! $lazy && 'high' !== strtolower( $el->getAttribute( 'fetchpriority' ) ) ) { $this->perf[] = array( 'lazy_missing', $url, $source->type, $source->ref ); } } /** * Whether an element sits inside another element. * * @param \DOMElement $el Element. * @param string $tag Lower-case name of the ancestor. */ private static function inside( \DOMElement $el, string $tag ): bool { for ( $node = $el->parentNode; $node instanceof \DOMElement; $node = $node->parentNode ) { if ( strtolower( $node->tagName ) === $tag ) { return true; } } return false; } /** * Notes accessibility problems of one element, and the ids and links needed for page-wide ones. * * @param \DOMElement $el Element. * @param string $tag Lower-case element name. */ private function accessibility( \DOMElement $el, string $tag ): void { $source = array() === $this->stack ? Source::template() : $this->stack[ count( $this->stack ) - 1 ]; foreach ( A11yFacts::problems( $el, $this->label_for ) as $problem ) { $this->a11y[] = array( $problem[0], mb_substr( $problem[1], 0, self::TEXT_LENGTH ), $source->type, $source->ref ); } $id = $el->getAttribute( 'id' ); if ( '' !== $id ) { $seen = $this->ids[ $id ] ?? array( 0, $source->type, $source->ref ); $this->ids[ $id ] = array( $seen[0] + 1, $seen[1], $seen[2] ); } if ( 'a' === $tag && '' !== trim( $el->getAttribute( 'href' ) ) ) { ++$this->links['links']; if ( $this->links['links'] <= 8 && str_starts_with( trim( $el->getAttribute( 'href' ) ), '#' ) && strlen( trim( $el->getAttribute( 'href' ) ) ) > 1 ) { $this->links['skip'] = true; } } } /** * Page-wide accessibility problems: no language, repeated ids, no skip link. * * @param \DOMDocument $dom Document. */ private function document_problems( \DOMDocument $dom ): void { $html = $dom->getElementsByTagName( 'html' )->item( 0 ); $lang = $html instanceof \DOMElement ? trim( $html->getAttribute( 'lang' ) . $html->getAttribute( 'xml:lang' ) ) : ''; if ( $html instanceof \DOMElement && '' === $lang ) { $this->a11y[] = array( 'lang_missing', '', Source::TEMPLATE, '' ); } foreach ( $this->ids as $id => $seen ) { if ( $seen[0] > 1 ) { $this->a11y[] = array( 'duplicate_id', mb_substr( (string) $id, 0, self::TEXT_LENGTH ), $seen[1], $seen[2] ); } } if ( $this->links['links'] >= 8 && ! $this->links['skip'] ) { $this->a11y[] = array( 'no_skip_link', '', Source::TEMPLATE, '' ); } } /** * Facts about the written content: the newest year in a copyright notice. The notice is * normally in the footer of the theme, so it is reported for the whole site. * * @param \DOMDocument $dom Document. */ private function content_facts( \DOMDocument $dom ): void { $xpath = new \DOMXPath( $dom ); $nodes = $xpath->query( '//text()[not(ancestor::script) and not(ancestor::style) and not(ancestor::noscript)]' ); $year = 0; foreach ( false === $nodes ? array() : $nodes as $node ) { if ( 1 === preg_match( '/(?:©|\(c\)|copyright)\s*(?:©|\(c\))?\s*((?:19|20)\d{2})(?:\s*[-–—]\s*((?:19|20)\d{2}))?/iu', $node->nodeValue ?? '', $found ) ) { $year = max( $year, (int) ( $found[2] ?? $found[1] ), (int) $found[1] ); } } if ( $year > 0 ) { $this->content[] = array( 'copyright_year', (string) $year, Source::TEMPLATE, '' ); } } /** * Adds each candidate of a srcset. * * @param string $srcset Attribute value. * @param string $tag Element name. * @param string $text Alt text. */ private function add_srcset( string $srcset, string $tag, string $text ): void { foreach ( explode( ',', $srcset ) as $candidate ) { $parts = preg_split( '/\s+/', trim( $candidate ) ); if ( is_array( $parts ) && '' !== $parts[0] ) { $this->add( Reference::IMAGE, $parts[0], $tag, $text ); } } } /** * Adds one reference if the value is a web address. * * @param string $kind Reference kind. * @param string $value Attribute value. * @param string $tag Element name. * @param string $text Text or alt. */ private function add( string $kind, string $value, string $tag, string $text ): void { if ( '' === trim( $value ) ) { return; } $url = Url::resolve( $value, $this->base ); if ( null === $url ) { return; } $source = array() === $this->stack ? Source::template() : $this->stack[ count( $this->stack ) - 1 ]; $this->references[] = new Reference( $kind, $url, $value, $tag, mb_substr( trim( $text ), 0, self::TEXT_LENGTH ), $source ); } }