From 43525c0a5c1c7428e9f181150d63a3b072319c97 Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Wed, 27 Nov 2024 18:46:06 +0530 Subject: [PATCH 01/28] Fix preloading of LCP images for elements --- ...lass-image-prioritizer-img-tag-visitor.php | 132 ++++++++++++++---- 1 file changed, 106 insertions(+), 26 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 3480c6d72f..9bded234e8 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -19,6 +19,20 @@ */ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visitor { + /** + * Flag to indicate whether we're within a `` element. + * + * @var bool + */ + private $is_within_picture = false; + + /** + * Collected `` elements within the current ``. + * + * @var array + */ + private $collected_sources = array(); + /** * Visits a tag. * @@ -28,7 +42,36 @@ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visi */ public function __invoke( OD_Tag_Visitor_Context $context ): bool { $processor = $context->processor; - if ( 'IMG' !== $processor->get_tag() ) { + $tag = $processor->get_tag(); + + // Handle opening and closing `` tags. + if ( 'PICTURE' === $tag ) { + if ( ! $processor->is_tag_closer() ) { + // Opening tag. + $this->is_within_picture = true; + $this->collected_sources = array(); + } else { + // Closing tag. + $this->is_within_picture = false; + $this->collected_sources = array(); + } + + return false; + } + + // Collect `` elements within ``. + if ( $this->is_within_picture && 'SOURCE' === $tag && ! $processor->is_tag_closer() ) { + $this->collected_sources[] = array( + 'srcset' => $processor->get_attribute( 'srcset' ), + 'sizes' => $processor->get_attribute( 'sizes' ), + 'type' => $processor->get_attribute( 'type' ), + 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), + ); + + return false; + } + + if ( 'IMG' !== $tag ) { return false; } @@ -144,36 +187,73 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { // If this element is the LCP (for a breakpoint group), add a preload link for it. foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { - $link_attributes = array_merge( - array( - 'rel' => 'preload', - 'fetchpriority' => 'high', - 'as' => 'image', - ), - array_filter( + if ( $this->is_within_picture && count( $this->collected_sources ) > 0 ) { + foreach ( $this->collected_sources as $source ) { + $link_attributes = array_merge( + array( + 'rel' => 'preload', + 'fetchpriority' => 'high', + 'as' => 'image', + ), + array_filter( + array( + 'href' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) + ? explode( ' ', $source['srcset'] )[0] + : '', + 'imagesrcset' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) ? $source['srcset'] : '', + 'imagesizes' => isset( $source['sizes'] ) && is_string( $source['sizes'] ) ? $source['sizes'] : '', + 'type' => isset( $source['type'] ) && is_string( $source['type'] ) ? $source['type'] : '', + ), + static function ( string $value ): bool { + return '' !== $value; + } + ) + ); + + if ( isset( $source['crossorigin'] ) ) { + $link_attributes['crossorigin'] = 'use-credentials' === $source['crossorigin'] ? 'use-credentials' : 'anonymous'; + } + + $link_attributes['media'] = 'screen'; + + $context->link_collection->add_link( + $link_attributes, + $group->get_minimum_viewport_width(), + $group->get_maximum_viewport_width() + ); + } + } else { + $link_attributes = array_merge( array( - 'href' => (string) $processor->get_attribute( 'src' ), - 'imagesrcset' => (string) $processor->get_attribute( 'srcset' ), - 'imagesizes' => (string) $processor->get_attribute( 'sizes' ), + 'rel' => 'preload', + 'fetchpriority' => 'high', + 'as' => 'image', ), - static function ( string $value ): bool { - return '' !== $value; - } - ) - ); + array_filter( + array( + 'href' => (string) $processor->get_attribute( 'src' ), + 'imagesrcset' => (string) $processor->get_attribute( 'srcset' ), + 'imagesizes' => (string) $processor->get_attribute( 'sizes' ), + ), + static function ( string $value ): bool { + return '' !== $value; + } + ) + ); - $crossorigin = $this->get_attribute_value( $processor, 'crossorigin' ); - if ( null !== $crossorigin ) { - $link_attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous'; - } + $crossorigin = $this->get_attribute_value( $processor, 'crossorigin' ); + if ( null !== $crossorigin ) { + $link_attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous'; + } - $link_attributes['media'] = 'screen'; + $link_attributes['media'] = 'screen'; - $context->link_collection->add_link( - $link_attributes, - $group->get_minimum_viewport_width(), - $group->get_maximum_viewport_width() - ); + $context->link_collection->add_link( + $link_attributes, + $group->get_minimum_viewport_width(), + $group->get_maximum_viewport_width() + ); + } } return true; From c20943c970f7abdc6fe05a94fefd85a34de72a0a Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Wed, 27 Nov 2024 18:56:46 +0530 Subject: [PATCH 02/28] Add media attribute support for elements in image prioritization --- .../class-image-prioritizer-img-tag-visitor.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 9bded234e8..658a3b78ee 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -29,7 +29,7 @@ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visi /** * Collected `` elements within the current ``. * - * @var array + * @var array */ private $collected_sources = array(); @@ -65,6 +65,7 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { 'srcset' => $processor->get_attribute( 'srcset' ), 'sizes' => $processor->get_attribute( 'sizes' ), 'type' => $processor->get_attribute( 'type' ), + 'media' => $processor->get_attribute( 'media' ), 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), ); @@ -203,6 +204,7 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { 'imagesrcset' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) ? $source['srcset'] : '', 'imagesizes' => isset( $source['sizes'] ) && is_string( $source['sizes'] ) ? $source['sizes'] : '', 'type' => isset( $source['type'] ) && is_string( $source['type'] ) ? $source['type'] : '', + 'media' => isset( $source['media'] ) && is_string( $source['media'] ) ? 'screen and ' . $source['media'] : 'screen', ), static function ( string $value ): bool { return '' !== $value; @@ -214,8 +216,6 @@ static function ( string $value ): bool { $link_attributes['crossorigin'] = 'use-credentials' === $source['crossorigin'] ? 'use-credentials' : 'anonymous'; } - $link_attributes['media'] = 'screen'; - $context->link_collection->add_link( $link_attributes, $group->get_minimum_viewport_width(), From 83c19a27253def7047e6784ddce7607d4784f678 Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Thu, 28 Nov 2024 19:03:11 +0530 Subject: [PATCH 03/28] Break logic into multiple methods for handling img and picture tag preload --- ...lass-image-prioritizer-img-tag-visitor.php | 278 +++++++++++++----- 1 file changed, 197 insertions(+), 81 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 658a3b78ee..8d10419685 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -20,70 +20,143 @@ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visitor { /** - * Flag to indicate whether we're within a `` element. + * Visits a tag. + * + * @since n.e.x.t Separate the processing of and elements. + * @since 0.1.0 + * + * @param OD_Tag_Visitor_Context $context Tag visitor context. * - * @var bool + * @return bool Whether the tag should be tracked in URL Metrics. */ - private $is_within_picture = false; + public function __invoke( OD_Tag_Visitor_Context $context ): bool { + $processor = $context->processor; + $tag = $processor->get_tag(); + + if ( 'PICTURE' === $tag ) { + $this->process_picture( $processor, $context ); + return false; + } + + if ( 'IMG' !== $tag ) { + return false; + } + + return $this->process_img( $processor, $context ); + } /** - * Collected `` elements within the current ``. + * Process an element. + * + * @since n.e.x.t + * + * @param OD_HTML_Tag_Processor $processor HTML tag processor. + * @param OD_Tag_Visitor_Context $context Tag visitor context. * - * @var array + * @return bool Whether the tag should be tracked in URL Metrics. */ - private $collected_sources = array(); + private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { + // Skip empty src attributes and data: URLs. + $src = trim( (string) $processor->get_attribute( 'src' ) ); + if ( '' === $src || $this->is_data_url( $src ) ) { + return false; + } + + $xpath = $processor->get_xpath(); + + $parent_tag = $this->get_parent_tag_name( $xpath ); + if ( 'PICTURE' === $parent_tag ) { + return true; + } + + $this->pre_img_process( $processor, $context, $xpath ); + + $this->add_preload_link_for_img( $processor, $context, $xpath ); + + return true; + } /** - * Visits a tag. + * Process a element. + * + * @since n.e.x.t * + * @param OD_HTML_Tag_Processor $processor HTML tag processor. * @param OD_Tag_Visitor_Context $context Tag visitor context. * * @return bool Whether the tag should be tracked in URL Metrics. */ - public function __invoke( OD_Tag_Visitor_Context $context ): bool { - $processor = $context->processor; - $tag = $processor->get_tag(); + private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { + // Set a bookmark to return to after processing. + $processor->set_bookmark( 'img-prioritizer-picture' ); - // Handle opening and closing `` tags. - if ( 'PICTURE' === $tag ) { - if ( ! $processor->is_tag_closer() ) { - // Opening tag. - $this->is_within_picture = true; - $this->collected_sources = array(); - } else { - // Closing tag. - $this->is_within_picture = false; - $this->collected_sources = array(); + $collected_sources = array(); + $img_xpath = null; + + // Loop through child tags until we reach the closing tag. + while ( $processor->next_tag() ) { + $tag = $processor->get_tag(); + + // If we reached the closing tag, break. + if ( 'PICTURE' === $tag && $processor->is_tag_closer() ) { + break; } - return false; - } + // Collect elements. + if ( 'SOURCE' === $tag && ! $processor->is_tag_closer() ) { + $collected_sources[] = array( + 'srcset' => $processor->get_attribute( 'srcset' ), + 'sizes' => $processor->get_attribute( 'sizes' ), + 'type' => $processor->get_attribute( 'type' ), + 'media' => $processor->get_attribute( 'media' ), + 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), + ); + } - // Collect `` elements within ``. - if ( $this->is_within_picture && 'SOURCE' === $tag && ! $processor->is_tag_closer() ) { - $this->collected_sources[] = array( - 'srcset' => $processor->get_attribute( 'srcset' ), - 'sizes' => $processor->get_attribute( 'sizes' ), - 'type' => $processor->get_attribute( 'type' ), - 'media' => $processor->get_attribute( 'media' ), - 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), - ); + // Process the element within the . + if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) { + // Skip empty src attributes and data: URLs. + $src = trim( (string) $processor->get_attribute( 'src' ) ); + if ( '' === $src || $this->is_data_url( $src ) ) { + return false; + } - return false; + $img_xpath = $processor->get_xpath(); + + $this->pre_img_process( $processor, $context, $img_xpath ); + } } - if ( 'IMG' !== $tag ) { + // Reset the processor back to the bookmark and release it. + $processor->seek( 'img-prioritizer-picture' ); + $processor->release_bookmark( 'img-prioritizer-picture' ); + + if ( null === $img_xpath ) { return false; } - // Skip empty src attributes and data: URLs. - $src = trim( (string) $processor->get_attribute( 'src' ) ); - if ( '' === $src || $this->is_data_url( $src ) ) { + // If no elements were found, add a preload link for the element. + if ( 0 === count( $collected_sources ) ) { + $processor->next_tag(); + $this->add_preload_link_for_img( $processor, $context, $img_xpath ); return false; } - $xpath = $processor->get_xpath(); + $this->add_preload_link_for_picture( $context, $img_xpath, $collected_sources ); + + return true; + } + /** + * Preprocesses an element. + * + * @since n.e.x.t + * + * @param OD_HTML_Tag_Processor $processor HTML tag processor. + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @param string $xpath XPath of the element. + */ + private function pre_img_process( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context, string $xpath ): void { $current_fetchpriority = $this->get_attribute_value( $processor, 'fetchpriority' ); $is_lazy_loaded = 'lazy' === $this->get_attribute_value( $processor, 'loading' ); $updated_fetchpriority = null; @@ -185,44 +258,21 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { ); } } + } + /** + * Adds a preload link for a element. + * + * @since n.e.x.t + * + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @param string $xpath XPath of the element. + * @param array $collected_sources Collected sources from the elements. + */ + private function add_preload_link_for_picture( OD_Tag_Visitor_Context $context, string $xpath, array $collected_sources ): void { // If this element is the LCP (for a breakpoint group), add a preload link for it. foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { - if ( $this->is_within_picture && count( $this->collected_sources ) > 0 ) { - foreach ( $this->collected_sources as $source ) { - $link_attributes = array_merge( - array( - 'rel' => 'preload', - 'fetchpriority' => 'high', - 'as' => 'image', - ), - array_filter( - array( - 'href' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) - ? explode( ' ', $source['srcset'] )[0] - : '', - 'imagesrcset' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) ? $source['srcset'] : '', - 'imagesizes' => isset( $source['sizes'] ) && is_string( $source['sizes'] ) ? $source['sizes'] : '', - 'type' => isset( $source['type'] ) && is_string( $source['type'] ) ? $source['type'] : '', - 'media' => isset( $source['media'] ) && is_string( $source['media'] ) ? 'screen and ' . $source['media'] : 'screen', - ), - static function ( string $value ): bool { - return '' !== $value; - } - ) - ); - - if ( isset( $source['crossorigin'] ) ) { - $link_attributes['crossorigin'] = 'use-credentials' === $source['crossorigin'] ? 'use-credentials' : 'anonymous'; - } - - $context->link_collection->add_link( - $link_attributes, - $group->get_minimum_viewport_width(), - $group->get_maximum_viewport_width() - ); - } - } else { + foreach ( $collected_sources as $source ) { $link_attributes = array_merge( array( 'rel' => 'preload', @@ -231,9 +281,13 @@ static function ( string $value ): bool { ), array_filter( array( - 'href' => (string) $processor->get_attribute( 'src' ), - 'imagesrcset' => (string) $processor->get_attribute( 'srcset' ), - 'imagesizes' => (string) $processor->get_attribute( 'sizes' ), + 'href' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) + ? explode( ' ', $source['srcset'] )[0] + : '', + 'imagesrcset' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) ? $source['srcset'] : '', + 'imagesizes' => isset( $source['sizes'] ) && is_string( $source['sizes'] ) ? $source['sizes'] : '', + 'type' => isset( $source['type'] ) && is_string( $source['type'] ) ? $source['type'] : '', + 'media' => isset( $source['media'] ) && is_string( $source['media'] ) ? 'screen and ' . $source['media'] : 'screen', ), static function ( string $value ): bool { return '' !== $value; @@ -241,13 +295,10 @@ static function ( string $value ): bool { ) ); - $crossorigin = $this->get_attribute_value( $processor, 'crossorigin' ); - if ( null !== $crossorigin ) { - $link_attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous'; + if ( isset( $source['crossorigin'] ) ) { + $link_attributes['crossorigin'] = 'use-credentials' === $source['crossorigin'] ? 'use-credentials' : 'anonymous'; } - $link_attributes['media'] = 'screen'; - $context->link_collection->add_link( $link_attributes, $group->get_minimum_viewport_width(), @@ -255,8 +306,73 @@ static function ( string $value ): bool { ); } } + } - return true; + /** + * Adds a preload link for an element. + * + * @since n.e.x.t + * + * @param OD_HTML_Tag_Processor $processor HTML tag processor. + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @param string $xpath XPath of the element. + */ + private function add_preload_link_for_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context, string $xpath ): void { + // If this element is the LCP (for a breakpoint group), add a preload link for it. + foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { + $link_attributes = array_merge( + array( + 'rel' => 'preload', + 'fetchpriority' => 'high', + 'as' => 'image', + ), + array_filter( + array( + 'href' => (string) $processor->get_attribute( 'src' ), + 'imagesrcset' => (string) $processor->get_attribute( 'srcset' ), + 'imagesizes' => (string) $processor->get_attribute( 'sizes' ), + ), + static function ( string $value ): bool { + return '' !== $value; + } + ) + ); + + $crossorigin = $this->get_attribute_value( $processor, 'crossorigin' ); + if ( null !== $crossorigin ) { + $link_attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous'; + } + + $link_attributes['media'] = 'screen'; + + $context->link_collection->add_link( + $link_attributes, + $group->get_minimum_viewport_width(), + $group->get_maximum_viewport_width() + ); + } + } + + /** + * Extracts the parent tag name from the XPath. + * + * @since n.e.x.t + * + * @param string $xpath The XPath of the current element. + * + * @return string|null The parent tag name or null if not found. + */ + private function get_parent_tag_name( string $xpath ): ?string { + $steps = explode( '/', $xpath ); + if ( count( $steps ) < 3 ) { + // There is no parent. + return null; + } + $second_last_step = $steps[ count( $steps ) - 2 ]; + if ( (bool) preg_match( '/\[self::([^\]]+)\]/', $second_last_step, $matches ) ) { + return $matches[1]; + } + return null; } /** From 2b812fbe7faf2b94f9bd49243ffbd9c972d179b3 Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Fri, 29 Nov 2024 18:31:12 +0530 Subject: [PATCH 04/28] Remove handling of picture with no source scenario --- ...lass-image-prioritizer-img-tag-visitor.php | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 8d10419685..740314bde1 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -22,8 +22,8 @@ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visi /** * Visits a tag. * - * @since n.e.x.t Separate the processing of and elements. * @since 0.1.0 + * @since n.e.x.t Separate the processing of and elements. * * @param OD_Tag_Visitor_Context $context Tag visitor context. * @@ -34,8 +34,7 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { $tag = $processor->get_tag(); if ( 'PICTURE' === $tag ) { - $this->process_picture( $processor, $context ); - return false; + return $this->process_picture( $processor, $context ); } if ( 'IMG' !== $tag ) { @@ -87,9 +86,6 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C * @return bool Whether the tag should be tracked in URL Metrics. */ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { - // Set a bookmark to return to after processing. - $processor->set_bookmark( 'img-prioritizer-picture' ); - $collected_sources = array(); $img_xpath = null; @@ -127,18 +123,7 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit } } - // Reset the processor back to the bookmark and release it. - $processor->seek( 'img-prioritizer-picture' ); - $processor->release_bookmark( 'img-prioritizer-picture' ); - - if ( null === $img_xpath ) { - return false; - } - - // If no elements were found, add a preload link for the element. - if ( 0 === count( $collected_sources ) ) { - $processor->next_tag(); - $this->add_preload_link_for_img( $processor, $context, $img_xpath ); + if ( null === $img_xpath || 0 === count( $collected_sources ) ) { return false; } From df1d90e91e032c5544a083a704b843b78fa920fc Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Fri, 29 Nov 2024 19:03:13 +0530 Subject: [PATCH 05/28] Ensure source elements have a type attribute and no media attribute, Only add preload link for first source --- ...lass-image-prioritizer-img-tag-visitor.php | 77 ++++++++++--------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 740314bde1..74770eb0e7 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -100,11 +100,18 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit // Collect elements. if ( 'SOURCE' === $tag && ! $processor->is_tag_closer() ) { + $media = $processor->get_attribute( 'media' ); + $type = $processor->get_attribute( 'type' ); + + // Ensure that all elements have a type attribute and no media attribute. + if ( null !== $media || null === $type ) { + return false; + } + $collected_sources[] = array( 'srcset' => $processor->get_attribute( 'srcset' ), 'sizes' => $processor->get_attribute( 'sizes' ), - 'type' => $processor->get_attribute( 'type' ), - 'media' => $processor->get_attribute( 'media' ), + 'type' => $type, 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), ); } @@ -127,7 +134,7 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit return false; } - $this->add_preload_link_for_picture( $context, $img_xpath, $collected_sources ); + $this->add_preload_link_for_picture( $context, $img_xpath, $collected_sources[0] ); return true; } @@ -250,46 +257,42 @@ private function pre_img_process( OD_HTML_Tag_Processor $processor, OD_Tag_Visit * * @since n.e.x.t * - * @param OD_Tag_Visitor_Context $context Tag visitor context. - * @param string $xpath XPath of the element. - * @param array $collected_sources Collected sources from the elements. + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @param string $xpath XPath of the element. + * @param array{srcset?: string|true|null,sizes?: string|true|null,type?: string|true|null,media?: string|true|null,crossorigin?: string|true|null} $source Collected sources from the elements. */ - private function add_preload_link_for_picture( OD_Tag_Visitor_Context $context, string $xpath, array $collected_sources ): void { + private function add_preload_link_for_picture( OD_Tag_Visitor_Context $context, string $xpath, array $source ): void { // If this element is the LCP (for a breakpoint group), add a preload link for it. foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { - foreach ( $collected_sources as $source ) { - $link_attributes = array_merge( + $link_attributes = array_merge( + array( + 'rel' => 'preload', + 'fetchpriority' => 'high', + 'as' => 'image', + ), + array_filter( array( - 'rel' => 'preload', - 'fetchpriority' => 'high', - 'as' => 'image', + 'href' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) + ? explode( ' ', $source['srcset'] )[0] + : '', + 'imagesrcset' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) ? $source['srcset'] : '', + 'imagesizes' => isset( $source['sizes'] ) && is_string( $source['sizes'] ) ? $source['sizes'] : '', + 'type' => isset( $source['type'] ) && is_string( $source['type'] ) ? $source['type'] : '', + 'media' => isset( $source['media'] ) && is_string( $source['media'] ) ? 'screen and ' . $source['media'] : 'screen', ), - array_filter( - array( - 'href' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) - ? explode( ' ', $source['srcset'] )[0] - : '', - 'imagesrcset' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) ? $source['srcset'] : '', - 'imagesizes' => isset( $source['sizes'] ) && is_string( $source['sizes'] ) ? $source['sizes'] : '', - 'type' => isset( $source['type'] ) && is_string( $source['type'] ) ? $source['type'] : '', - 'media' => isset( $source['media'] ) && is_string( $source['media'] ) ? 'screen and ' . $source['media'] : 'screen', - ), - static function ( string $value ): bool { - return '' !== $value; - } - ) - ); - - if ( isset( $source['crossorigin'] ) ) { - $link_attributes['crossorigin'] = 'use-credentials' === $source['crossorigin'] ? 'use-credentials' : 'anonymous'; - } - - $context->link_collection->add_link( - $link_attributes, - $group->get_minimum_viewport_width(), - $group->get_maximum_viewport_width() - ); + static function ( string $value ): bool { + return '' !== $value; + } + ) + ); + if ( isset( $source['crossorigin'] ) ) { + $link_attributes['crossorigin'] = 'use-credentials' === $source['crossorigin'] ? 'use-credentials' : 'anonymous'; } + $context->link_collection->add_link( + $link_attributes, + $group->get_minimum_viewport_width(), + $group->get_maximum_viewport_width() + ); } } From b8a17c0bb62807bc19aca04b76def843a5c69ed6 Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Mon, 2 Dec 2024 15:27:57 +0530 Subject: [PATCH 06/28] Refactor conditional logic so the cases are consistent --- .../class-image-prioritizer-img-tag-visitor.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 74770eb0e7..fceafe314f 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -35,13 +35,11 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { if ( 'PICTURE' === $tag ) { return $this->process_picture( $processor, $context ); + } elseif ( 'IMG' === $tag ) { + return $this->process_img( $processor, $context ); } - if ( 'IMG' !== $tag ) { - return false; - } - - return $this->process_img( $processor, $context ); + return false; } /** From 194075c741bf2c8886360d9c24938bae9c65089d Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Mon, 2 Dec 2024 15:40:57 +0530 Subject: [PATCH 07/28] Refactor get_parent_tag_name to use breadcrumbs from context and rename get_breadcrumbs to get_indexed_breadcrumbs --- ...class-image-prioritizer-img-tag-visitor.php | 18 +++++++----------- .../class-od-html-tag-processor.php | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index fceafe314f..e69681449a 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -61,7 +61,7 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C $xpath = $processor->get_xpath(); - $parent_tag = $this->get_parent_tag_name( $xpath ); + $parent_tag = $this->get_parent_tag_name( $context ); if ( 'PICTURE' === $parent_tag ) { return true; } @@ -344,21 +344,17 @@ static function ( string $value ): bool { * * @since n.e.x.t * - * @param string $xpath The XPath of the current element. + * @param OD_Tag_Visitor_Context $context Tag visitor context. * * @return string|null The parent tag name or null if not found. */ - private function get_parent_tag_name( string $xpath ): ?string { - $steps = explode( '/', $xpath ); - if ( count( $steps ) < 3 ) { - // There is no parent. + private function get_parent_tag_name( OD_Tag_Visitor_Context $context ): ?string { + $breadcrumbs = $context->processor->get_breadcrumbs(); + $length = count( $breadcrumbs ); + if ( $length < 2 ) { return null; } - $second_last_step = $steps[ count( $steps ) - 2 ]; - if ( (bool) preg_match( '/\[self::([^\]]+)\]/', $second_last_step, $matches ) ) { - return $matches[1]; - } - return null; + return $breadcrumbs[ $length - 2 ]; } /** diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index c4a74f44a3..40f39e79ec 100644 --- a/plugins/optimization-detective/class-od-html-tag-processor.php +++ b/plugins/optimization-detective/class-od-html-tag-processor.php @@ -497,15 +497,27 @@ public function release_bookmark( $name ): bool { * A breadcrumb consists of a tag name and its sibling index. * * @since 0.4.0 + * @since n.e.x.t Renamed from get_breadcrumbs() to get_indexed_breadcrumbs(). * * @return Generator Breadcrumb. */ - private function get_breadcrumbs(): Generator { + private function get_indexed_breadcrumbs(): Generator { foreach ( $this->open_stack_tags as $i => $breadcrumb_tag_name ) { yield array( $breadcrumb_tag_name, $this->open_stack_indices[ $i ] ); } } + /** + * Gets breadcrumbs for the current open tag. + * + * @since n.e.x.t + * + * @return string[] Breadcrumb. + */ + public function get_breadcrumbs(): array { + return $this->open_stack_tags; + } + /** * Determines whether currently inside a foreign element (MATH or SVG). * @@ -535,7 +547,7 @@ private function is_foreign_element(): bool { public function get_xpath(): string { if ( null === $this->current_xpath ) { $this->current_xpath = ''; - foreach ( $this->get_breadcrumbs() as list( $tag_name, $index ) ) { + foreach ( $this->get_indexed_breadcrumbs() as list( $tag_name, $index ) ) { $this->current_xpath .= sprintf( '/*[%d][self::%s]', $index + 1, $tag_name ); } } From ff8df96b9a718d90f8af82a1eddb417a23b05586 Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Mon, 2 Dec 2024 19:47:09 +0530 Subject: [PATCH 08/28] Move pre_img_process logic directly into process_img method --- ...lass-image-prioritizer-img-tag-visitor.php | 158 ++++++++---------- 1 file changed, 70 insertions(+), 88 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index e69681449a..833077228d 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -59,97 +59,10 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C return false; } - $xpath = $processor->get_xpath(); - - $parent_tag = $this->get_parent_tag_name( $context ); - if ( 'PICTURE' === $parent_tag ) { - return true; - } - - $this->pre_img_process( $processor, $context, $xpath ); - - $this->add_preload_link_for_img( $processor, $context, $xpath ); - - return true; - } - - /** - * Process a element. - * - * @since n.e.x.t - * - * @param OD_HTML_Tag_Processor $processor HTML tag processor. - * @param OD_Tag_Visitor_Context $context Tag visitor context. - * - * @return bool Whether the tag should be tracked in URL Metrics. - */ - private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { - $collected_sources = array(); - $img_xpath = null; - - // Loop through child tags until we reach the closing tag. - while ( $processor->next_tag() ) { - $tag = $processor->get_tag(); - - // If we reached the closing tag, break. - if ( 'PICTURE' === $tag && $processor->is_tag_closer() ) { - break; - } - - // Collect elements. - if ( 'SOURCE' === $tag && ! $processor->is_tag_closer() ) { - $media = $processor->get_attribute( 'media' ); - $type = $processor->get_attribute( 'type' ); - - // Ensure that all elements have a type attribute and no media attribute. - if ( null !== $media || null === $type ) { - return false; - } - - $collected_sources[] = array( - 'srcset' => $processor->get_attribute( 'srcset' ), - 'sizes' => $processor->get_attribute( 'sizes' ), - 'type' => $type, - 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), - ); - } - - // Process the element within the . - if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) { - // Skip empty src attributes and data: URLs. - $src = trim( (string) $processor->get_attribute( 'src' ) ); - if ( '' === $src || $this->is_data_url( $src ) ) { - return false; - } - - $img_xpath = $processor->get_xpath(); - - $this->pre_img_process( $processor, $context, $img_xpath ); - } - } - - if ( null === $img_xpath || 0 === count( $collected_sources ) ) { - return false; - } - - $this->add_preload_link_for_picture( $context, $img_xpath, $collected_sources[0] ); - - return true; - } - - /** - * Preprocesses an element. - * - * @since n.e.x.t - * - * @param OD_HTML_Tag_Processor $processor HTML tag processor. - * @param OD_Tag_Visitor_Context $context Tag visitor context. - * @param string $xpath XPath of the element. - */ - private function pre_img_process( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context, string $xpath ): void { $current_fetchpriority = $this->get_attribute_value( $processor, 'fetchpriority' ); $is_lazy_loaded = 'lazy' === $this->get_attribute_value( $processor, 'loading' ); $updated_fetchpriority = null; + $xpath = $processor->get_xpath(); /* * When the same LCP element is common/shared among all viewport groups, make sure that the element has @@ -248,6 +161,75 @@ private function pre_img_process( OD_HTML_Tag_Processor $processor, OD_Tag_Visit ); } } + + $parent_tag = $this->get_parent_tag_name( $context ); + if ( 'PICTURE' !== $parent_tag ) { + $this->add_preload_link_for_img( $processor, $context, $xpath ); + } + + return true; + } + + /** + * Process a element. + * + * @since n.e.x.t + * + * @param OD_HTML_Tag_Processor $processor HTML tag processor. + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * + * @return bool Whether the tag should be tracked in URL Metrics. + */ + private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { + $collected_sources = array(); + $img_xpath = null; + + // Loop through child tags until we reach the closing tag. + while ( $processor->next_tag() ) { + $tag = $processor->get_tag(); + + // If we reached the closing tag, break. + if ( 'PICTURE' === $tag && $processor->is_tag_closer() ) { + break; + } + + // Collect elements. + if ( 'SOURCE' === $tag && ! $processor->is_tag_closer() ) { + $media = $processor->get_attribute( 'media' ); + $type = $processor->get_attribute( 'type' ); + + // Ensure that all elements have a type attribute and no media attribute. + if ( null !== $media || null === $type ) { + return false; + } + + $collected_sources[] = array( + 'srcset' => $processor->get_attribute( 'srcset' ), + 'sizes' => $processor->get_attribute( 'sizes' ), + 'type' => $type, + 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), + ); + } + + // Process the element within the . + if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) { + // Skip empty src attributes and data: URLs. + $src = trim( (string) $processor->get_attribute( 'src' ) ); + if ( '' === $src || $this->is_data_url( $src ) ) { + return false; + } + + $img_xpath = $processor->get_xpath(); + } + } + + if ( null === $img_xpath || 0 === count( $collected_sources ) ) { + return false; + } + + $this->add_preload_link_for_picture( $context, $img_xpath, $collected_sources[0] ); + + return false; } /** From bc178ba9b70bbdd9e513ba6e425c2c77a8cb2eda Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Mon, 2 Dec 2024 22:31:08 +0530 Subject: [PATCH 09/28] Refactor to use single function for adding preload links --- ...lass-image-prioritizer-img-tag-visitor.php | 96 +++++++------------ 1 file changed, 33 insertions(+), 63 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 833077228d..a4da98e0fb 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -164,7 +164,19 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C $parent_tag = $this->get_parent_tag_name( $context ); if ( 'PICTURE' !== $parent_tag ) { - $this->add_preload_link_for_img( $processor, $context, $xpath ); + $attributes = array( + 'href' => (string) $processor->get_attribute( 'src' ), + 'imagesrcset' => (string) $processor->get_attribute( 'srcset' ), + 'imagesizes' => (string) $processor->get_attribute( 'sizes' ), + 'media' => 'screen', + ); + + $crossorigin = $this->get_attribute_value( $processor, 'crossorigin' ); + if ( null !== $crossorigin ) { + $attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous'; + } + + $this->add_preload_link( $context, $xpath, $attributes ); } return true; @@ -227,65 +239,34 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit return false; } - $this->add_preload_link_for_picture( $context, $img_xpath, $collected_sources[0] ); + $source = $collected_sources[0]; + $attributes = array( + 'href' => explode( ' ', (string) $source['srcset'] )[0], + 'imagesrcset' => (string) $source['srcset'], + 'imagesizes' => (string) $source['sizes'], + 'type' => (string) $source['type'], + 'media' => 'screen', + ); + + if ( null !== $source['crossorigin'] ) { + $attributes['crossorigin'] = 'use-credentials' === $source['crossorigin'] ? 'use-credentials' : 'anonymous'; + } - return false; - } + $this->add_preload_link( $context, $img_xpath, $attributes ); - /** - * Adds a preload link for a element. - * - * @since n.e.x.t - * - * @param OD_Tag_Visitor_Context $context Tag visitor context. - * @param string $xpath XPath of the element. - * @param array{srcset?: string|true|null,sizes?: string|true|null,type?: string|true|null,media?: string|true|null,crossorigin?: string|true|null} $source Collected sources from the elements. - */ - private function add_preload_link_for_picture( OD_Tag_Visitor_Context $context, string $xpath, array $source ): void { - // If this element is the LCP (for a breakpoint group), add a preload link for it. - foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { - $link_attributes = array_merge( - array( - 'rel' => 'preload', - 'fetchpriority' => 'high', - 'as' => 'image', - ), - array_filter( - array( - 'href' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) - ? explode( ' ', $source['srcset'] )[0] - : '', - 'imagesrcset' => isset( $source['srcset'] ) && is_string( $source['srcset'] ) ? $source['srcset'] : '', - 'imagesizes' => isset( $source['sizes'] ) && is_string( $source['sizes'] ) ? $source['sizes'] : '', - 'type' => isset( $source['type'] ) && is_string( $source['type'] ) ? $source['type'] : '', - 'media' => isset( $source['media'] ) && is_string( $source['media'] ) ? 'screen and ' . $source['media'] : 'screen', - ), - static function ( string $value ): bool { - return '' !== $value; - } - ) - ); - if ( isset( $source['crossorigin'] ) ) { - $link_attributes['crossorigin'] = 'use-credentials' === $source['crossorigin'] ? 'use-credentials' : 'anonymous'; - } - $context->link_collection->add_link( - $link_attributes, - $group->get_minimum_viewport_width(), - $group->get_maximum_viewport_width() - ); - } + return false; } /** - * Adds a preload link for an element. + * Adds a preload link. * * @since n.e.x.t * - * @param OD_HTML_Tag_Processor $processor HTML tag processor. - * @param OD_Tag_Visitor_Context $context Tag visitor context. - * @param string $xpath XPath of the element. + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @param string $xpath XPath of the element. + * @param array{href: string, imagesrcset: string, imagesizes: string, type?: string, media: string, crossorigin?: 'anonymous'|'use-credentials'} $attributes Attributes to add to the link. */ - private function add_preload_link_for_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context, string $xpath ): void { + private function add_preload_link( OD_Tag_Visitor_Context $context, string $xpath, array $attributes ): void { // If this element is the LCP (for a breakpoint group), add a preload link for it. foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { $link_attributes = array_merge( @@ -295,24 +276,13 @@ private function add_preload_link_for_img( OD_HTML_Tag_Processor $processor, OD_ 'as' => 'image', ), array_filter( - array( - 'href' => (string) $processor->get_attribute( 'src' ), - 'imagesrcset' => (string) $processor->get_attribute( 'srcset' ), - 'imagesizes' => (string) $processor->get_attribute( 'sizes' ), - ), + $attributes, static function ( string $value ): bool { return '' !== $value; } ) ); - $crossorigin = $this->get_attribute_value( $processor, 'crossorigin' ); - if ( null !== $crossorigin ) { - $link_attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous'; - } - - $link_attributes['media'] = 'screen'; - $context->link_collection->add_link( $link_attributes, $group->get_minimum_viewport_width(), From 664c2287ca07725db27ae92dcc9f7c7cce1b0aa7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 2 Dec 2024 11:28:41 -0800 Subject: [PATCH 10/28] Refactor how preload links are added --- ...lass-image-prioritizer-img-tag-visitor.php | 104 ++++++++++-------- .../class-image-prioritizer-tag-visitor.php | 3 + .../class-od-link-collection.php | 1 + 3 files changed, 60 insertions(+), 48 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index a4da98e0fb..48bc66ebba 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -14,6 +14,8 @@ /** * Tag visitor that optimizes IMG tags. * + * @phpstan-import-type LinkAttributes from OD_Link_Collection + * * @since 0.1.0 * @access private */ @@ -164,19 +166,16 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C $parent_tag = $this->get_parent_tag_name( $context ); if ( 'PICTURE' !== $parent_tag ) { - $attributes = array( - 'href' => (string) $processor->get_attribute( 'src' ), - 'imagesrcset' => (string) $processor->get_attribute( 'srcset' ), - 'imagesizes' => (string) $processor->get_attribute( 'sizes' ), - 'media' => 'screen', + $this->add_image_preload_link_for_lcp_element_groups( + $context, + $xpath, + array( + 'href' => $processor->get_attribute( 'src' ), + 'imagesrcset' => $processor->get_attribute( 'srcset' ), + 'imagesizes' => $processor->get_attribute( 'sizes' ), + 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), + ) ); - - $crossorigin = $this->get_attribute_value( $processor, 'crossorigin' ); - if ( null !== $crossorigin ) { - $attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous'; - } - - $this->add_preload_link( $context, $xpath, $attributes ); } return true; @@ -239,52 +238,62 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit return false; } - $source = $collected_sources[0]; - $attributes = array( - 'href' => explode( ' ', (string) $source['srcset'] )[0], - 'imagesrcset' => (string) $source['srcset'], - 'imagesizes' => (string) $source['sizes'], - 'type' => (string) $source['type'], - 'media' => 'screen', + $source = $collected_sources[0]; + $this->add_image_preload_link_for_lcp_element_groups( + $context, + $img_xpath, + array( + 'imagesrcset' => $source['srcset'], + 'imagesizes' => $source['sizes'], + 'type' => $source['type'], + 'crossorigin' => $source['crossorigin'], + ) ); - if ( null !== $source['crossorigin'] ) { - $attributes['crossorigin'] = 'use-credentials' === $source['crossorigin'] ? 'use-credentials' : 'anonymous'; - } - - $this->add_preload_link( $context, $img_xpath, $attributes ); - return false; } /** - * Adds a preload link. + * Adds a LINK with the supplied attributes for each viewport group when the provided XPath is the LCP element. * * @since n.e.x.t * - * @param OD_Tag_Visitor_Context $context Tag visitor context. - * @param string $xpath XPath of the element. - * @param array{href: string, imagesrcset: string, imagesizes: string, type?: string, media: string, crossorigin?: 'anonymous'|'use-credentials'} $attributes Attributes to add to the link. + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @param string $xpath XPath of the element. + * @param array $attributes Attributes to add to the link. */ - private function add_preload_link( OD_Tag_Visitor_Context $context, string $xpath, array $attributes ): void { - // If this element is the LCP (for a breakpoint group), add a preload link for it. - foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { - $link_attributes = array_merge( - array( - 'rel' => 'preload', - 'fetchpriority' => 'high', - 'as' => 'image', - ), - array_filter( - $attributes, - static function ( string $value ): bool { - return '' !== $value; - } - ) - ); + private function add_image_preload_link_for_lcp_element_groups( OD_Tag_Visitor_Context $context, string $xpath, array $attributes ): void { + $attributes = array_filter( + $attributes, + static function ( $attribute_value ) { + return is_string( $attribute_value ) && '' !== $attribute_value; + } + ); + /** + * Link attributes. + * + * This type is needed because PHPStan isn't apparently aware of the new keys added after the array_merge(). + * Note that there is no type checking being done on the attributes above other than ensuring they are + * non-empty-strings. + * + * @var LinkAttributes $attributes + */ + $attributes = array_merge( + array( + 'rel' => 'preload', + 'fetchpriority' => 'high', + 'as' => 'image', + ), + $attributes, + array( + 'media' => 'screen', + ) + ); + + foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { $context->link_collection->add_link( - $link_attributes, + $attributes, $group->get_minimum_viewport_width(), $group->get_maximum_viewport_width() ); @@ -292,12 +301,11 @@ static function ( string $value ): bool { } /** - * Extracts the parent tag name from the XPath. + * Gets the parent tag name. * * @since n.e.x.t * * @param OD_Tag_Visitor_Context $context Tag visitor context. - * * @return string|null The parent tag name or null if not found. */ private function get_parent_tag_name( OD_Tag_Visitor_Context $context ): ?string { diff --git a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php index 240b359f65..0523b6f650 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php @@ -56,6 +56,9 @@ protected function get_attribute_value( OD_HTML_Tag_Processor $processor, string if ( is_string( $value ) ) { $value = strtolower( trim( $value, " \t\f\r\n" ) ); } + if ( 'crossorigin' === $attribute_name && ( true === $value || '' === $value ) ) { + $value = 'anonymous'; + } return $value; } } diff --git a/plugins/optimization-detective/class-od-link-collection.php b/plugins/optimization-detective/class-od-link-collection.php index d6d7739fc3..4e41a7af74 100644 --- a/plugins/optimization-detective/class-od-link-collection.php +++ b/plugins/optimization-detective/class-od-link-collection.php @@ -29,6 +29,7 @@ * fetchpriority?: 'high'|'low'|'auto', * as?: 'audio'|'document'|'embed'|'fetch'|'font'|'image'|'object'|'script'|'style'|'track'|'video'|'worker', * media?: non-empty-string, + * type?: non-empty-string, * integrity?: non-empty-string, * referrerpolicy?: 'no-referrer'|'no-referrer-when-downgrade'|'origin'|'origin-when-cross-origin'|'unsafe-url' * } From d76759e45b51c9bdb9c456e465e847ecd686e105 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 2 Dec 2024 11:30:15 -0800 Subject: [PATCH 11/28] Align phpdoc in get_breadcrumbs() with WP_HTML_Processor --- .../optimization-detective/class-od-html-tag-processor.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index 40f39e79ec..b1501bacd1 100644 --- a/plugins/optimization-detective/class-od-html-tag-processor.php +++ b/plugins/optimization-detective/class-od-html-tag-processor.php @@ -508,11 +508,14 @@ private function get_indexed_breadcrumbs(): Generator { } /** - * Gets breadcrumbs for the current open tag. + * Computes the HTML breadcrumbs for the currently-matched node, if matched. + * + * Breadcrumbs start at the outermost parent and descend toward the matched element. + * They always include the entire path from the root HTML node to the matched element. * * @since n.e.x.t * - * @return string[] Breadcrumb. + * @return string[] Array of tag names representing path to matched node. */ public function get_breadcrumbs(): array { return $this->open_stack_tags; From a767385c4faf59268074b1326b28f289b81df7c4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 2 Dec 2024 13:53:57 -0800 Subject: [PATCH 12/28] Add test for type attribute on link --- .../class-image-prioritizer-img-tag-visitor.php | 3 ++- .../tests/test-class-od-link-collection.php | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 48bc66ebba..844575fc50 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -61,10 +61,11 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C return false; } + $xpath = $processor->get_xpath(); + $current_fetchpriority = $this->get_attribute_value( $processor, 'fetchpriority' ); $is_lazy_loaded = 'lazy' === $this->get_attribute_value( $processor, 'loading' ); $updated_fetchpriority = null; - $xpath = $processor->get_xpath(); /* * When the same LCP element is common/shared among all viewport groups, make sure that the element has diff --git a/plugins/optimization-detective/tests/test-class-od-link-collection.php b/plugins/optimization-detective/tests/test-class-od-link-collection.php index dffaa3ead6..3c0ea1bd4c 100644 --- a/plugins/optimization-detective/tests/test-class-od-link-collection.php +++ b/plugins/optimization-detective/tests/test-class-od-link-collection.php @@ -30,13 +30,14 @@ public function data_provider_to_test_add_link(): array { 'media' => 'screen', 'integrity' => 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC', 'referrerpolicy' => 'origin', + 'type' => 'image/jpeg', ), ), ), 'expected_html' => ' - + ', - 'expected_header' => 'Link: ; rel="preload"; imagesrcset="https://example.com/foo-400.jpg 400w, https://example.com/foo-800.jpg 800w"; imagesizes="100vw"; crossorigin="anonymous"; fetchpriority="high"; as="image"; media="screen"; integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"; referrerpolicy="origin"', + 'expected_header' => 'Link: ; rel="preload"; imagesrcset="https://example.com/foo-400.jpg 400w, https://example.com/foo-800.jpg 800w"; imagesizes="100vw"; crossorigin="anonymous"; fetchpriority="high"; as="image"; media="screen"; integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"; referrerpolicy="origin"; type="image/jpeg"', 'expected_count' => 1, 'error' => '', ), From 34272cda8ee254a66ee869eca51a1e6c3a2151cf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 2 Dec 2024 14:41:38 -0800 Subject: [PATCH 13/28] Fix sourcing of crossorigin and referrerpolicy attributes --- ...lass-image-prioritizer-img-tag-visitor.php | 34 ++++++++++++------- .../class-image-prioritizer-tag-visitor.php | 9 +++-- ...erent-lcp-elements-for-all-breakpoints.php | 18 +++++----- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 844575fc50..8c8171b0c0 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -171,10 +171,11 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C $context, $xpath, array( - 'href' => $processor->get_attribute( 'src' ), - 'imagesrcset' => $processor->get_attribute( 'srcset' ), - 'imagesizes' => $processor->get_attribute( 'sizes' ), - 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), + 'href' => $processor->get_attribute( 'src' ), + 'imagesrcset' => $processor->get_attribute( 'srcset' ), + 'imagesizes' => $processor->get_attribute( 'sizes' ), + 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), + 'referrerpolicy' => $this->get_attribute_value( $processor, 'referrerpolicy' ), ) ); } @@ -196,6 +197,9 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit $collected_sources = array(); $img_xpath = null; + $referrerpolicy = null; + $crossorigin = null; + // Loop through child tags until we reach the closing tag. while ( $processor->next_tag() ) { $tag = $processor->get_tag(); @@ -216,10 +220,9 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit } $collected_sources[] = array( - 'srcset' => $processor->get_attribute( 'srcset' ), - 'sizes' => $processor->get_attribute( 'sizes' ), - 'type' => $type, - 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), + 'srcset' => $processor->get_attribute( 'srcset' ), + 'sizes' => $processor->get_attribute( 'sizes' ), + 'type' => $type, ); } @@ -231,6 +234,12 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit return false; } + // These attributes are only defined on the IMG itself. + $referrerpolicy = $this->get_attribute_value( $processor, 'referrerpolicy' ); + $crossorigin = $this->get_attribute_value( $processor, 'crossorigin' ); + + // Capture the XPath for the IMG since the browser captures it as the LCP element, so we need this to + // look up whether it is the LCP element in the URL Metric groups. $img_xpath = $processor->get_xpath(); } } @@ -244,10 +253,11 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit $context, $img_xpath, array( - 'imagesrcset' => $source['srcset'], - 'imagesizes' => $source['sizes'], - 'type' => $source['type'], - 'crossorigin' => $source['crossorigin'], + 'imagesrcset' => $source['srcset'], + 'imagesizes' => $source['sizes'], + 'type' => $source['type'], + 'crossorigin' => $crossorigin, + 'referrerpolicy' => $referrerpolicy, ) ); diff --git a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php index 0523b6f650..e2fe21bcae 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php @@ -14,7 +14,7 @@ /** * Tag visitor that optimizes image tags. * - * @phpstan-type NormalizedAttributeNames 'fetchpriority'|'loading'|'crossorigin'|'preload' + * @phpstan-type NormalizedAttributeNames 'fetchpriority'|'loading'|'crossorigin'|'preload'|'referrerpolicy' * * @since 0.1.0 * @access private @@ -44,6 +44,7 @@ protected function is_data_url( string $url ): bool { * * @since 0.2.0 * @todo Move this into the OD_HTML_Tag_Processor/OD_HTML_Processor class eventually. + * @todo It would be nice if PHPStan could know that if you pass 'crossorigin' as $attribute_name that you will get back null|'anonymous'|'use-credentials'. * * @phpstan-param NormalizedAttributeNames $attribute_name * @@ -53,10 +54,14 @@ protected function is_data_url( string $url ): bool { */ protected function get_attribute_value( OD_HTML_Tag_Processor $processor, string $attribute_name ) { $value = $processor->get_attribute( $attribute_name ); + if ( null === $value ) { + return null; + } + if ( is_string( $value ) ) { $value = strtolower( trim( $value, " \t\f\r\n" ) ); } - if ( 'crossorigin' === $attribute_name && ( true === $value || '' === $value ) ) { + if ( 'crossorigin' === $attribute_name && ( true === $value || '' === trim( $value ) ) ) { $value = 'anonymous'; } return $value; diff --git a/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-all-breakpoints.php b/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-all-breakpoints.php index c51a47dcac..52e063669e 100644 --- a/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-all-breakpoints.php +++ b/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-all-breakpoints.php @@ -49,9 +49,9 @@ static function () use ( $breakpoint_max_widths ) { Mobile Logo - Phablet Logo - Tablet Logo - Desktop Logo + Phablet Logo + Tablet Logo + Desktop Logo ', @@ -61,15 +61,15 @@ static function () use ( $breakpoint_max_widths ) { ... - - - + + + Mobile Logo - Phablet Logo - Tablet Logo - Desktop Logo + Phablet Logo + Tablet Logo + Desktop Logo From b468896c4de51517a92f70b36ec4f7daabcdf891 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 2 Dec 2024 14:45:03 -0800 Subject: [PATCH 14/28] Only capture the first source --- ...lass-image-prioritizer-img-tag-visitor.php | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 8c8171b0c0..7ae53db50b 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -194,8 +194,8 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C * @return bool Whether the tag should be tracked in URL Metrics. */ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { - $collected_sources = array(); - $img_xpath = null; + $first_source = null; + $img_xpath = null; $referrerpolicy = null; $crossorigin = null; @@ -219,11 +219,13 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit return false; } - $collected_sources[] = array( - 'srcset' => $processor->get_attribute( 'srcset' ), - 'sizes' => $processor->get_attribute( 'sizes' ), - 'type' => $type, - ); + if ( null === $first_source ) { + $first_source = array( + 'srcset' => $processor->get_attribute( 'srcset' ), + 'sizes' => $processor->get_attribute( 'sizes' ), + 'type' => $type, + ); + } } // Process the element within the . @@ -244,18 +246,17 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit } } - if ( null === $img_xpath || 0 === count( $collected_sources ) ) { + if ( null === $img_xpath || null === $first_source ) { return false; } - $source = $collected_sources[0]; $this->add_image_preload_link_for_lcp_element_groups( $context, $img_xpath, array( - 'imagesrcset' => $source['srcset'], - 'imagesizes' => $source['sizes'], - 'type' => $source['type'], + 'imagesrcset' => $first_source['srcset'], + 'imagesizes' => $first_source['sizes'], + 'type' => $first_source['type'], 'crossorigin' => $crossorigin, 'referrerpolicy' => $referrerpolicy, ) From 93a0f1bd4f48861786729d79d5e24086eaf11d5f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 2 Dec 2024 14:47:34 -0800 Subject: [PATCH 15/28] Use upper-case tag names rather than markup in phpdoc --- .../class-image-prioritizer-img-tag-visitor.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 7ae53db50b..efd90996a7 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -25,7 +25,7 @@ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visi * Visits a tag. * * @since 0.1.0 - * @since n.e.x.t Separate the processing of and elements. + * @since n.e.x.t Separate the processing of IMG and PICTURE elements. * * @param OD_Tag_Visitor_Context $context Tag visitor context. * @@ -45,7 +45,7 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { } /** - * Process an element. + * Process an IMG element. * * @since n.e.x.t * @@ -184,7 +184,7 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C } /** - * Process a element. + * Process a PICTURE element. * * @since n.e.x.t * @@ -228,7 +228,7 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit } } - // Process the element within the . + // Process the IMG element within the PICTURE. if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) { // Skip empty src attributes and data: URLs. $src = trim( (string) $processor->get_attribute( 'src' ) ); From bf3932f9b0e0ef068963e1301354b865fa67b7a4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 2 Dec 2024 15:32:03 -0800 Subject: [PATCH 16/28] Add test for get_breadcrumbs() and fix bug in implicit P closing --- .../class-od-html-tag-processor.php | 2 +- .../test-class-od-html-tag-processor.php | 316 ++++++++++-------- 2 files changed, 169 insertions(+), 149 deletions(-) diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index b1501bacd1..30bfc5eb5c 100644 --- a/plugins/optimization-detective/class-od-html-tag-processor.php +++ b/plugins/optimization-detective/class-od-html-tag-processor.php @@ -296,7 +296,7 @@ public function next_token(): bool { $i = array_search( 'P', $this->open_stack_tags, true ); if ( false !== $i ) { array_splice( $this->open_stack_tags, (int) $i ); - array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) ); + array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) + 1 ); } } diff --git a/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php b/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php index fe9ff889ae..accfca3cce 100644 --- a/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php +++ b/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php @@ -46,25 +46,25 @@ public function data_provider_sample_documents(): array { ', 'open_tags' => array( 'HTML', 'HEAD', 'META', 'TITLE', 'SCRIPT', 'STYLE', 'BODY', 'IFRAME', 'P', 'BR', 'IMG', 'FORM', 'TEXTAREA', 'FOOTER' ), - 'xpaths' => array( - '/*[1][self::HTML]', - '/*[1][self::HTML]/*[1][self::HEAD]', - '/*[1][self::HTML]/*[1][self::HEAD]/*[1][self::META]', - '/*[1][self::HTML]/*[1][self::HEAD]/*[2][self::TITLE]', - '/*[1][self::HTML]/*[1][self::HEAD]/*[3][self::SCRIPT]', - '/*[1][self::HTML]/*[1][self::HEAD]/*[4][self::STYLE]', - '/*[1][self::HTML]/*[2][self::BODY]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IFRAME]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]/*[1][self::BR]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]/*[2][self::IMG]', - '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::FORM]', - '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::FORM]/*[1][self::TEXTAREA]', - '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::FOOTER]', + array( + '/*[1][self::HTML]' => array( 'HTML' ), + '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), + '/*[1][self::HTML]/*[1][self::HEAD]/*[1][self::META]' => array( 'HTML', 'HEAD', 'META' ), + '/*[1][self::HTML]/*[1][self::HEAD]/*[2][self::TITLE]' => array( 'HTML', 'HEAD', 'TITLE' ), + '/*[1][self::HTML]/*[1][self::HEAD]/*[3][self::SCRIPT]' => array( 'HTML', 'HEAD', 'SCRIPT' ), + '/*[1][self::HTML]/*[1][self::HEAD]/*[4][self::STYLE]' => array( 'HTML', 'HEAD', 'STYLE' ), + '/*[1][self::HTML]/*[2][self::BODY]' => array( 'HTML', 'BODY' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IFRAME]' => array( 'HTML', 'BODY', 'IFRAME' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]/*[1][self::BR]' => array( 'HTML', 'BODY', 'P', 'BR' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]/*[2][self::IMG]' => array( 'HTML', 'BODY', 'P', 'IMG' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::FORM]' => array( 'HTML', 'BODY', 'FORM' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::FORM]/*[1][self::TEXTAREA]' => array( 'HTML', 'BODY', 'FORM', 'TEXTAREA' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::FOOTER]' => array( 'HTML', 'BODY', 'FOOTER' ), ), ), 'foreign-elements' => array( - 'document' => ' + 'document' => ' @@ -84,25 +84,25 @@ public function data_provider_sample_documents(): array { ', - 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SVG', 'G', 'PATH', 'CIRCLE', 'G', 'RECT', 'MATH', 'MN', 'MSPACE', 'MN' ), - 'xpaths' => array( - '/*[1][self::HTML]', - '/*[1][self::HTML]/*[1][self::HEAD]', - '/*[1][self::HTML]/*[2][self::BODY]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[1][self::PATH]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[2][self::CIRCLE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[3][self::G]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[4][self::RECT]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[1][self::MN]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[2][self::MSPACE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[3][self::MN]', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SVG', 'G', 'PATH', 'CIRCLE', 'G', 'RECT', 'MATH', 'MN', 'MSPACE', 'MN' ), + 'xpath_breadcrumbs' => array( + '/*[1][self::HTML]' => array( 'HTML' ), + '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), + '/*[1][self::HTML]/*[2][self::BODY]' => array( 'HTML', 'BODY' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]' => array( 'HTML', 'BODY', 'SVG' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]' => array( 'HTML', 'BODY', 'SVG', 'G' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[1][self::PATH]' => array( 'HTML', 'BODY', 'SVG', 'G', 'PATH' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[2][self::CIRCLE]' => array( 'HTML', 'BODY', 'SVG', 'G', 'CIRCLE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[3][self::G]' => array( 'HTML', 'BODY', 'SVG', 'G', 'G' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[4][self::RECT]' => array( 'HTML', 'BODY', 'SVG', 'G', 'RECT' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]' => array( 'HTML', 'BODY', 'MATH' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[1][self::MN]' => array( 'HTML', 'BODY', 'MATH', 'MN' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[2][self::MSPACE]' => array( 'HTML', 'BODY', 'MATH', 'MSPACE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[3][self::MN]' => array( 'HTML', 'BODY', 'MATH', 'MN' ), ), ), 'closing-void-tag' => array( - 'document' => ' + 'document' => ' @@ -112,18 +112,18 @@ public function data_provider_sample_documents(): array { ', - 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SPAN', 'META', 'SPAN' ), - 'xpaths' => array( - '/*[1][self::HTML]', - '/*[1][self::HTML]/*[1][self::HEAD]', - '/*[1][self::HTML]/*[2][self::BODY]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SPAN]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::META]', - '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::SPAN]', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SPAN', 'META', 'SPAN' ), + 'xpath_breadcrumbs' => array( + '/*[1][self::HTML]' => array( 'HTML' ), + '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), + '/*[1][self::HTML]/*[2][self::BODY]' => array( 'HTML', 'BODY' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SPAN]' => array( 'HTML', 'BODY', 'SPAN' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::META]' => array( 'HTML', 'BODY', 'META' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::SPAN]' => array( 'HTML', 'BODY', 'SPAN' ), ), ), 'void-tags' => array( - 'document' => ' + 'document' => ' @@ -153,36 +153,36 @@ public function data_provider_sample_documents(): array { ', - 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'AREA', 'BASE', 'BASEFONT', 'BGSOUND', 'BR', 'COL', 'EMBED', 'FRAME', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR', 'DIV', 'SPAN', 'EM' ), - 'xpaths' => array( - '/*[1][self::HTML]', - '/*[1][self::HTML]/*[1][self::HEAD]', - '/*[1][self::HTML]/*[2][self::BODY]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::AREA]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::BASE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::BASEFONT]', - '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::BGSOUND]', - '/*[1][self::HTML]/*[2][self::BODY]/*[5][self::BR]', - '/*[1][self::HTML]/*[2][self::BODY]/*[6][self::COL]', - '/*[1][self::HTML]/*[2][self::BODY]/*[7][self::EMBED]', - '/*[1][self::HTML]/*[2][self::BODY]/*[8][self::FRAME]', - '/*[1][self::HTML]/*[2][self::BODY]/*[9][self::HR]', - '/*[1][self::HTML]/*[2][self::BODY]/*[10][self::IMG]', - '/*[1][self::HTML]/*[2][self::BODY]/*[11][self::INPUT]', - '/*[1][self::HTML]/*[2][self::BODY]/*[12][self::KEYGEN]', - '/*[1][self::HTML]/*[2][self::BODY]/*[13][self::LINK]', - '/*[1][self::HTML]/*[2][self::BODY]/*[14][self::META]', - '/*[1][self::HTML]/*[2][self::BODY]/*[15][self::PARAM]', - '/*[1][self::HTML]/*[2][self::BODY]/*[16][self::SOURCE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[17][self::TRACK]', - '/*[1][self::HTML]/*[2][self::BODY]/*[18][self::WBR]', - '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]', - '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]/*[1][self::SPAN]', - '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]/*[1][self::SPAN]/*[1][self::EM]', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'AREA', 'BASE', 'BASEFONT', 'BGSOUND', 'BR', 'COL', 'EMBED', 'FRAME', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR', 'DIV', 'SPAN', 'EM' ), + 'xpath_breadcrumbs' => array( + '/*[1][self::HTML]' => array( 'HTML' ), + '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), + '/*[1][self::HTML]/*[2][self::BODY]' => array( 'HTML', 'BODY' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::AREA]' => array( 'HTML', 'BODY', 'AREA' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::BASE]' => array( 'HTML', 'BODY', 'BASE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::BASEFONT]' => array( 'HTML', 'BODY', 'BASEFONT' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::BGSOUND]' => array( 'HTML', 'BODY', 'BGSOUND' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[5][self::BR]' => array( 'HTML', 'BODY', 'BR' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[6][self::COL]' => array( 'HTML', 'BODY', 'COL' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[7][self::EMBED]' => array( 'HTML', 'BODY', 'EMBED' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[8][self::FRAME]' => array( 'HTML', 'BODY', 'FRAME' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[9][self::HR]' => array( 'HTML', 'BODY', 'HR' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[10][self::IMG]' => array( 'HTML', 'BODY', 'IMG' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[11][self::INPUT]' => array( 'HTML', 'BODY', 'INPUT' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[12][self::KEYGEN]' => array( 'HTML', 'BODY', 'KEYGEN' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[13][self::LINK]' => array( 'HTML', 'BODY', 'LINK' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[14][self::META]' => array( 'HTML', 'BODY', 'META' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[15][self::PARAM]' => array( 'HTML', 'BODY', 'PARAM' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[16][self::SOURCE]' => array( 'HTML', 'BODY', 'SOURCE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[17][self::TRACK]' => array( 'HTML', 'BODY', 'TRACK' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[18][self::WBR]' => array( 'HTML', 'BODY', 'WBR' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]' => array( 'HTML', 'BODY', 'DIV' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]/*[1][self::SPAN]' => array( 'HTML', 'BODY', 'DIV', 'SPAN' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]/*[1][self::SPAN]/*[1][self::EM]' => array( 'HTML', 'BODY', 'DIV', 'SPAN', 'EM' ), ), ), 'optional-closing-p' => array( - 'document' => ' + 'document' => ' @@ -225,75 +225,75 @@ public function data_provider_sample_documents(): array { ', - 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'P', 'P', 'EM', 'P', 'P', 'ADDRESS', 'P', 'ARTICLE', 'P', 'ASIDE', 'P', 'BLOCKQUOTE', 'P', 'DETAILS', 'P', 'DIV', 'P', 'DL', 'P', 'FIELDSET', 'P', 'FIGCAPTION', 'P', 'FIGURE', 'P', 'FOOTER', 'P', 'FORM', 'P', 'H1', 'P', 'H2', 'P', 'H3', 'P', 'H4', 'P', 'H5', 'P', 'H6', 'P', 'HEADER', 'P', 'HGROUP', 'P', 'HR', 'P', 'MAIN', 'P', 'MENU', 'P', 'NAV', 'P', 'OL', 'P', 'PRE', 'P', 'SEARCH', 'P', 'SECTION', 'P', 'TABLE', 'P', 'UL' ), - 'xpaths' => array( - '/*[1][self::HTML]', - '/*[1][self::HTML]/*[1][self::HEAD]', - '/*[1][self::HTML]/*[2][self::BODY]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]/*[1][self::EM]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::ADDRESS]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::ARTICLE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::ASIDE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::BLOCKQUOTE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::DETAILS]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::DIV]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::DL]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIELDSET]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGCAPTION]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FOOTER]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FORM]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H1]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H2]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H3]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H4]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H5]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H6]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::HEADER]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::HGROUP]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::HR]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::MAIN]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::MENU]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::NAV]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::OL]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PRE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SEARCH]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SECTION]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::TABLE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::UL]', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'P', 'P', 'EM', 'P', 'P', 'ADDRESS', 'P', 'ARTICLE', 'P', 'ASIDE', 'P', 'BLOCKQUOTE', 'P', 'DETAILS', 'P', 'DIV', 'P', 'DL', 'P', 'FIELDSET', 'P', 'FIGCAPTION', 'P', 'FIGURE', 'P', 'FOOTER', 'P', 'FORM', 'P', 'H1', 'P', 'H2', 'P', 'H3', 'P', 'H4', 'P', 'H5', 'P', 'H6', 'P', 'HEADER', 'P', 'HGROUP', 'P', 'HR', 'P', 'MAIN', 'P', 'MENU', 'P', 'NAV', 'P', 'OL', 'P', 'PRE', 'P', 'SEARCH', 'P', 'SECTION', 'P', 'TABLE', 'P', 'UL' ), + 'xpath_breadcrumbs' => array( + '/*[1][self::HTML]' => array( 'HTML' ), + '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), + '/*[1][self::HTML]/*[2][self::BODY]' => array( 'HTML', 'BODY' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]/*[1][self::EM]' => array( 'HTML', 'BODY', 'P', 'EM' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[5][self::ADDRESS]' => array( 'HTML', 'BODY', 'ADDRESS' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[6][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[7][self::ARTICLE]' => array( 'HTML', 'BODY', 'ARTICLE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[8][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[9][self::ASIDE]' => array( 'HTML', 'BODY', 'ASIDE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[10][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[11][self::BLOCKQUOTE]' => array( 'HTML', 'BODY', 'BLOCKQUOTE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[12][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[13][self::DETAILS]' => array( 'HTML', 'BODY', 'DETAILS' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[14][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[15][self::DIV]' => array( 'HTML', 'BODY', 'DIV' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[16][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[17][self::DL]' => array( 'HTML', 'BODY', 'DL' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[18][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::FIELDSET]' => array( 'HTML', 'BODY', 'FIELDSET' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[20][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[21][self::FIGCAPTION]' => array( 'HTML', 'BODY', 'FIGCAPTION' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[22][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[23][self::FIGURE]' => array( 'HTML', 'BODY', 'FIGURE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[24][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[25][self::FOOTER]' => array( 'HTML', 'BODY', 'FOOTER' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[26][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[27][self::FORM]' => array( 'HTML', 'BODY', 'FORM' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[28][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[29][self::H1]' => array( 'HTML', 'BODY', 'H1' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[30][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[31][self::H2]' => array( 'HTML', 'BODY', 'H2' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[32][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[33][self::H3]' => array( 'HTML', 'BODY', 'H3' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[34][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[35][self::H4]' => array( 'HTML', 'BODY', 'H4' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[36][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[37][self::H5]' => array( 'HTML', 'BODY', 'H5' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[38][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[39][self::H6]' => array( 'HTML', 'BODY', 'H6' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[40][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[41][self::HEADER]' => array( 'HTML', 'BODY', 'HEADER' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[42][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[43][self::HGROUP]' => array( 'HTML', 'BODY', 'HGROUP' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[44][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[45][self::HR]' => array( 'HTML', 'BODY', 'HR' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[46][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[47][self::MAIN]' => array( 'HTML', 'BODY', 'MAIN' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[48][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[49][self::MENU]' => array( 'HTML', 'BODY', 'MENU' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[50][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[51][self::NAV]' => array( 'HTML', 'BODY', 'NAV' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[52][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[53][self::OL]' => array( 'HTML', 'BODY', 'OL' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[54][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[55][self::PRE]' => array( 'HTML', 'BODY', 'PRE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[56][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[57][self::SEARCH]' => array( 'HTML', 'BODY', 'SEARCH' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[58][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[59][self::SECTION]' => array( 'HTML', 'BODY', 'SECTION' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[60][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[61][self::TABLE]' => array( 'HTML', 'BODY', 'TABLE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[62][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[63][self::UL]' => array( 'HTML', 'BODY', 'UL' ), ), ), ); @@ -306,25 +306,30 @@ public function data_provider_sample_documents(): array { * @covers ::next_tag * @covers ::next_token * @covers ::get_xpath + * @covers ::get_breadcrumbs * * @dataProvider data_provider_sample_documents * - * @param string $document Document. - * @param string[] $open_tags Open tags. - * @param string[] $xpaths XPaths. + * @param string $document Document. + * @param string[] $open_tags Open tags. + * @param array $xpath_breadcrumbs XPaths mapped to their breadcrumbs. */ - public function test_next_tag_and_get_xpath( string $document, array $open_tags, array $xpaths ): void { + public function test_next_tag_and_get_xpath( string $document, array $open_tags, array $xpath_breadcrumbs ): void { $p = new OD_HTML_Tag_Processor( $document ); $this->assertSame( '', $p->get_xpath(), 'Expected empty XPath since iteration has not started.' ); - $actual_open_tags = array(); - $actual_xpaths = array(); + $actual_open_tags = array(); + $actual_xpath_breadcrumbs_mapping = array(); while ( $p->next_open_tag() ) { $actual_open_tags[] = $p->get_tag(); - $actual_xpaths[] = $p->get_xpath(); + + $xpath = $p->get_xpath(); + $this->assertArrayNotHasKey( $xpath, $actual_xpath_breadcrumbs_mapping, 'Each tag must have a unique XPath.' ); + + $actual_xpath_breadcrumbs_mapping[ $xpath ] = $p->get_breadcrumbs(); } $this->assertSame( $open_tags, $actual_open_tags, "Expected list of open tags to match.\nSnapshot: " . $this->export_array_snapshot( $actual_open_tags, true ) ); - $this->assertSame( $xpaths, $actual_xpaths, "Expected list of XPaths to match.\nSnapshot: " . $this->export_array_snapshot( $actual_xpaths ) ); + $this->assertSame( $xpath_breadcrumbs, $actual_xpath_breadcrumbs_mapping, "Expected list of XPaths to match.\nSnapshot: " . $this->export_array_snapshot( $actual_xpath_breadcrumbs_mapping ) ); } /** @@ -616,10 +621,25 @@ public function test_get_cursor_move_count(): void { * @return string Snapshot. */ private function export_array_snapshot( array $data, bool $one_line = false ): string { - $php = (string) preg_replace( '/^\s*\d+\s*=>\s*/m', '', var_export( $data, true ) ); - if ( $one_line ) { - $php = str_replace( "\n", ' ', $php ); + $php = 'array('; + $php .= $one_line ? ' ' : "\n"; + foreach ( $data as $key => $value ) { + if ( ! $one_line ) { + $php .= "\t"; + } + if ( ! is_numeric( $key ) ) { + $php .= var_export( $key, true ) . ' => '; + } + + if ( is_array( $value ) ) { + $php .= $this->export_array_snapshot( $value, true ); + } else { + $php .= str_replace( "\n", ' ', var_export( $value, true ) ); + } + $php .= ','; + $php .= $one_line ? ' ' : "\n"; } + $php .= ')'; return $php; } } From 79d5fc3ebf6a3ceb8f7dc1bf13c6d52bca42b1b5 Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Tue, 3 Dec 2024 22:43:10 +0530 Subject: [PATCH 17/28] Add test case for picture element with LCP image and fully populated sample data --- ...-image-and-fully-populated-sample-data.php | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php b/plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php new file mode 100644 index 0000000000..0b3793f800 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php @@ -0,0 +1,51 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $test_case->populate_url_metrics( + array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[2][self::IMG]', + 'isLCP' => true, + ), + ) + ); + }, + 'buffer' => ' + + + + ... + + + + + Foo + + + + ', + 'expected' => ' + + + + ... + + + + + + Foo + + + + ', +); From 9f593a27baa9d2713d0d24bd815bb6c64e3b62c3 Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Tue, 3 Dec 2024 22:47:30 +0530 Subject: [PATCH 18/28] Add test case for picture element with missing LCP metrics for tablet and desktop --- ...lcp-tablet-and-desktop-metrics-missing.php | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php b/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php new file mode 100644 index 0000000000..20c1313473 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php @@ -0,0 +1,66 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + + // Only populate the mobile and phablet viewport groups. + foreach ( array( 480, 600 ) as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + OD_URL_Metrics_Post_Type::store_url_metric( + $slug, + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[2][self::IMG]', + 'isLCP' => true, + ), + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + + + Foo + + + + ', + 'expected' => ' + + + + ... + + + + + + Foo + + + + + ', +); From f3c2b6bb6024f29662b744d2d9d71584f8228d6c Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Tue, 3 Dec 2024 22:51:12 +0530 Subject: [PATCH 19/28] Add test case for picture element with missing type attribute in source --- ...ent-with-source-missing-type-attribute.php | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php b/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php new file mode 100644 index 0000000000..694833c5ab --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php @@ -0,0 +1,50 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $test_case->populate_url_metrics( + array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[2][self::IMG]', + 'isLCP' => true, + ), + ) + ); + }, + 'buffer' => ' + + + + ... + + + + + Foo + + + + ', + 'expected' => ' + + + + ... + + + + + Foo + + + + ', +); From ac9f499e3dd08af31ab036ca476517d3a3d22c16 Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Tue, 3 Dec 2024 23:09:42 +0530 Subject: [PATCH 20/28] Add tests for picture element with media attribute in source --- ...lcp-tablet-and-desktop-metrics-missing.php | 4 +- ...ent-with-source-having-media-attribute.php | 50 +++++++++++++++++++ ...ent-with-source-missing-type-attribute.php | 8 +-- 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 plugins/image-prioritizer/tests/test-cases/picture-element-with-source-having-media-attribute.php diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php b/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php index 20c1313473..656a75ac09 100644 --- a/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php @@ -42,7 +42,7 @@ static function () use ( $breakpoint_max_widths ) { - Foo + Foo @@ -57,7 +57,7 @@ static function () use ( $breakpoint_max_widths ) { - Foo + Foo diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-having-media-attribute.php b/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-having-media-attribute.php new file mode 100644 index 0000000000..8f2af94d69 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-having-media-attribute.php @@ -0,0 +1,50 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $test_case->populate_url_metrics( + array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[2][self::IMG]', + 'isLCP' => true, + ), + ) + ); + }, + 'buffer' => ' + + + + ... + + + + + Foo + + + + ', + 'expected' => ' + + + + ... + + + + + Foo + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php b/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php index 694833c5ab..24c4021b01 100644 --- a/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php @@ -27,8 +27,8 @@ static function () use ( $breakpoint_max_widths ) { - - Foo + + Foo @@ -41,8 +41,8 @@ static function () use ( $breakpoint_max_widths ) { - - Foo + + Foo From 71adb5884cfccbcf15291cef182aa350752c2a15 Mon Sep 17 00:00:00 2001 From: b1ink0 Date: Tue, 3 Dec 2024 23:15:41 +0530 Subject: [PATCH 21/28] Add test case for picture element with crossorigin and referrerpolicy attributes --- ...nt-with-crossorigin-and-referrerpolicy.php | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 plugins/image-prioritizer/tests/test-cases/picture-element-with-crossorigin-and-referrerpolicy.php diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-with-crossorigin-and-referrerpolicy.php b/plugins/image-prioritizer/tests/test-cases/picture-element-with-crossorigin-and-referrerpolicy.php new file mode 100644 index 0000000000..836c0d993c --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-with-crossorigin-and-referrerpolicy.php @@ -0,0 +1,51 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $test_case->populate_url_metrics( + array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[2][self::IMG]', + 'isLCP' => true, + ), + ) + ); + }, + 'buffer' => ' + + + + ... + + + + + Foo + + + + ', + 'expected' => ' + + + + ... + + + + + + Foo + + + + ', +); From b4660de9e3378623738b034b7e87a6354e56061c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Dec 2024 14:52:22 -0800 Subject: [PATCH 22/28] Improve normalization of crossorigin attribute value --- .../class-image-prioritizer-img-tag-visitor.php | 15 ++++++--------- .../class-image-prioritizer-tag-visitor.php | 2 +- ...different-lcp-elements-for-all-breakpoints.php | 11 +++++++++-- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index efd90996a7..30fd175cb5 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -28,7 +28,6 @@ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visi * @since n.e.x.t Separate the processing of IMG and PICTURE elements. * * @param OD_Tag_Visitor_Context $context Tag visitor context. - * * @return bool Whether the tag should be tracked in URL Metrics. */ public function __invoke( OD_Tag_Visitor_Context $context ): bool { @@ -50,8 +49,7 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { * @since n.e.x.t * * @param OD_HTML_Tag_Processor $processor HTML tag processor. - * @param OD_Tag_Visitor_Context $context Tag visitor context. - * + * @param OD_Tag_Visitor_Context $context Tag visitor context. * @return bool Whether the tag should be tracked in URL Metrics. */ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { @@ -189,8 +187,7 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C * @since n.e.x.t * * @param OD_HTML_Tag_Processor $processor HTML tag processor. - * @param OD_Tag_Visitor_Context $context Tag visitor context. - * + * @param OD_Tag_Visitor_Context $context Tag visitor context. * @return bool Whether the tag should be tracked in URL Metrics. */ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { @@ -200,21 +197,21 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit $referrerpolicy = null; $crossorigin = null; - // Loop through child tags until we reach the closing tag. + // Loop through child tags until we reach the closing PICTURE tag. while ( $processor->next_tag() ) { $tag = $processor->get_tag(); - // If we reached the closing tag, break. + // If we reached the closing PICTURE tag, break. if ( 'PICTURE' === $tag && $processor->is_tag_closer() ) { break; } - // Collect elements. + // Collect SOURCE elements. if ( 'SOURCE' === $tag && ! $processor->is_tag_closer() ) { $media = $processor->get_attribute( 'media' ); $type = $processor->get_attribute( 'type' ); - // Ensure that all elements have a type attribute and no media attribute. + // Ensure that all SOURCE elements have a type attribute and no media attribute. if ( null !== $media || null === $type ) { return false; } diff --git a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php index e2fe21bcae..68eab13446 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php @@ -61,7 +61,7 @@ protected function get_attribute_value( OD_HTML_Tag_Processor $processor, string if ( is_string( $value ) ) { $value = strtolower( trim( $value, " \t\f\r\n" ) ); } - if ( 'crossorigin' === $attribute_name && ( true === $value || '' === trim( $value ) ) ) { + if ( 'crossorigin' === $attribute_name && 'use-credentials' !== $value ) { $value = 'anonymous'; } return $value; diff --git a/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-all-breakpoints.php b/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-all-breakpoints.php index 52e063669e..9c86be802d 100644 --- a/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-all-breakpoints.php +++ b/plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-all-breakpoints.php @@ -1,7 +1,7 @@ static function ( Test_Image_Prioritizer_Helper $test_case ): void { - $breakpoint_max_widths = array( 480, 600, 782 ); + $breakpoint_max_widths = array( 480, 600, 782, 1000 ); add_filter( 'od_breakpoint_max_widths', @@ -28,6 +28,10 @@ static function () use ( $breakpoint_max_widths ) { 'isLCP' => false, 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::IMG]', ), + array( + 'isLCP' => false, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[5][self::IMG]', + ), ); $elements[ $i ]['isLCP'] = true; OD_URL_Metrics_Post_Type::store_url_metric( @@ -52,6 +56,7 @@ static function () use ( $breakpoint_max_widths ) { Phablet Logo Tablet Logo Desktop Logo + Desktop Logo ', @@ -63,13 +68,15 @@ static function () use ( $breakpoint_max_widths ) { - + + Mobile Logo Phablet Logo Tablet Logo Desktop Logo + Desktop Logo From e7cb4a79839008523c55e2a1a1012a42dd450d9b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Dec 2024 15:15:29 -0800 Subject: [PATCH 23/28] Harden processing of PICTURE tag --- ...lass-image-prioritizer-img-tag-visitor.php | 35 +++++++++++++++---- .../class-image-prioritizer-tag-visitor.php | 2 +- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 30fd175cb5..e3de981e6d 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -191,6 +191,11 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C * @return bool Whether the tag should be tracked in URL Metrics. */ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { + /** + * First SOURCE tag's attributes. + * + * @var array{ srcset: non-empty-string, sizes: string|null, type: non-empty-string }|null $first_source + */ $first_source = null; $img_xpath = null; @@ -206,20 +211,35 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit break; } - // Collect SOURCE elements. + // Process the SOURCE elements. if ( 'SOURCE' === $tag && ! $processor->is_tag_closer() ) { - $media = $processor->get_attribute( 'media' ); - $type = $processor->get_attribute( 'type' ); + // Abort processing if the PICTURE involves art direction since then adding a preload link is infeasible. + if ( null !== $processor->get_attribute( 'media' ) ) { + return false; + } + + // Abort processing if a SOURCE lacks the required srcset attribute. + $srcset = $processor->get_attribute( 'srcset' ); + if ( ! is_string( $srcset ) ) { + return false; + } + $srcset = trim( $srcset ); + if ( '' === $srcset ) { + return false; + } - // Ensure that all SOURCE elements have a type attribute and no media attribute. - if ( null !== $media || null === $type ) { + // Abort processing if there is no valid image type. + $type = $this->get_attribute_value( $processor, 'type' ); + if ( ! is_string( $type ) || ! str_starts_with( $type, 'image/' ) ) { return false; } + // Collect the first valid SOURCE as the preload link. if ( null === $first_source ) { + $sizes = $processor->get_attribute( 'sizes' ); $first_source = array( - 'srcset' => $processor->get_attribute( 'srcset' ), - 'sizes' => $processor->get_attribute( 'sizes' ), + 'srcset' => $srcset, + 'sizes' => is_string( $sizes ) ? $sizes : null, 'type' => $type, ); } @@ -243,6 +263,7 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit } } + // Abort if we never encountered a SOURCE or IMG tag. if ( null === $img_xpath || null === $first_source ) { return false; } diff --git a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php index 68eab13446..ba2850f6a8 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php @@ -14,7 +14,7 @@ /** * Tag visitor that optimizes image tags. * - * @phpstan-type NormalizedAttributeNames 'fetchpriority'|'loading'|'crossorigin'|'preload'|'referrerpolicy' + * @phpstan-type NormalizedAttributeNames 'fetchpriority'|'loading'|'crossorigin'|'preload'|'referrerpolicy'|'type' * * @since 0.1.0 * @access private From ebbf0d3796df42cc3563ecf9b21b24735de619dd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Dec 2024 15:24:00 -0800 Subject: [PATCH 24/28] Supply missing array key --- .../tests/test-class-od-html-tag-processor.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php b/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php index accfca3cce..4590d28cb7 100644 --- a/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php +++ b/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php @@ -24,7 +24,7 @@ class Test_OD_HTML_Tag_Processor extends WP_UnitTestCase { public function data_provider_sample_documents(): array { return array( 'well-formed-html' => array( - 'document' => ' + 'document' => ' @@ -45,8 +45,8 @@ public function data_provider_sample_documents(): array { ', - 'open_tags' => array( 'HTML', 'HEAD', 'META', 'TITLE', 'SCRIPT', 'STYLE', 'BODY', 'IFRAME', 'P', 'BR', 'IMG', 'FORM', 'TEXTAREA', 'FOOTER' ), - array( + 'open_tags' => array( 'HTML', 'HEAD', 'META', 'TITLE', 'SCRIPT', 'STYLE', 'BODY', 'IFRAME', 'P', 'BR', 'IMG', 'FORM', 'TEXTAREA', 'FOOTER' ), + 'xpath_breadcrumbs' => array( '/*[1][self::HTML]' => array( 'HTML' ), '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), '/*[1][self::HTML]/*[1][self::HEAD]/*[1][self::META]' => array( 'HTML', 'HEAD', 'META' ), From 7d2e6542ef038546e7f8bcacade6745d5c90e93b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Dec 2024 15:35:06 -0800 Subject: [PATCH 25/28] Factor out common logic to obtain valid src attribute --- ...lass-image-prioritizer-img-tag-visitor.php | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index e3de981e6d..ded4d6ae2e 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -53,9 +53,8 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { * @return bool Whether the tag should be tracked in URL Metrics. */ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { - // Skip empty src attributes and data: URLs. - $src = trim( (string) $processor->get_attribute( 'src' ) ); - if ( '' === $src || $this->is_data_url( $src ) ) { + $src = $this->get_valid_src( $processor ); + if ( null === $src ) { return false; } @@ -247,9 +246,8 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit // Process the IMG element within the PICTURE. if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) { - // Skip empty src attributes and data: URLs. - $src = trim( (string) $processor->get_attribute( 'src' ) ); - if ( '' === $src || $this->is_data_url( $src ) ) { + $src = $this->get_valid_src( $processor ); + if ( null === $src ) { return false; } @@ -283,6 +281,29 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit return false; } + /** + * Gets valid src attribute value for preloading. + * + * Returns null if the src attribute is not a string (i.e. src was used as a boolean attribute was used), if it + * it has an empty string value after trimming, or if it is a data: URL. + * + * @since n.e.x.t + * + * @param OD_HTML_Tag_Processor $processor Processor. + * @return non-empty-string|null URL which is not a data: URL. + */ + private function get_valid_src( OD_HTML_Tag_Processor $processor ): ?string { + $src = $processor->get_attribute( 'src' ); + if ( ! is_string( $src ) ) { + return null; + } + $src = trim( $src ); + if ( '' === $src || $this->is_data_url( $src ) ) { + return null; + } + return $src; + } + /** * Adds a LINK with the supplied attributes for each viewport group when the provided XPath is the LCP element. * From a92b899448df6fb5d1bbef3ed1204ac5db0e8593 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Dec 2024 15:37:51 -0800 Subject: [PATCH 26/28] Update test case to test for multiple sources --- ...re-element-as-lcp-tablet-and-desktop-metrics-missing.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php b/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php index 656a75ac09..f4ca8404ad 100644 --- a/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php @@ -23,7 +23,7 @@ static function () use ( $breakpoint_max_widths ) { 'viewport_width' => $viewport_width, 'elements' => array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[2][self::IMG]', + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[3][self::IMG]', 'isLCP' => true, ), ), @@ -42,6 +42,7 @@ static function () use ( $breakpoint_max_widths ) { + Foo @@ -57,7 +58,8 @@ static function () use ( $breakpoint_max_widths ) { - Foo + + Foo From 8f6550a27774720f519bff323aa7996065afb5a9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Dec 2024 15:41:59 -0800 Subject: [PATCH 27/28] Remove largely duplicate test case by merging --- ...nt-with-crossorigin-and-referrerpolicy.php | 51 ------------------- ...-image-and-fully-populated-sample-data.php | 6 +-- 2 files changed, 3 insertions(+), 54 deletions(-) delete mode 100644 plugins/image-prioritizer/tests/test-cases/picture-element-with-crossorigin-and-referrerpolicy.php diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-with-crossorigin-and-referrerpolicy.php b/plugins/image-prioritizer/tests/test-cases/picture-element-with-crossorigin-and-referrerpolicy.php deleted file mode 100644 index 836c0d993c..0000000000 --- a/plugins/image-prioritizer/tests/test-cases/picture-element-with-crossorigin-and-referrerpolicy.php +++ /dev/null @@ -1,51 +0,0 @@ - static function ( Test_Image_Prioritizer_Helper $test_case ): void { - $breakpoint_max_widths = array( 480, 600, 782 ); - - add_filter( - 'od_breakpoint_max_widths', - static function () use ( $breakpoint_max_widths ) { - return $breakpoint_max_widths; - } - ); - - $test_case->populate_url_metrics( - array( - array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[2][self::IMG]', - 'isLCP' => true, - ), - ) - ); - }, - 'buffer' => ' - - - - ... - - - - - Foo - - - - ', - 'expected' => ' - - - - ... - - - - - - Foo - - - - ', -); diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php b/plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php index 0b3793f800..836c0d993c 100644 --- a/plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php @@ -28,7 +28,7 @@ static function () use ( $breakpoint_max_widths ) { - Foo + Foo @@ -38,12 +38,12 @@ static function () use ( $breakpoint_max_widths ) { ... - + - Foo + Foo From ece0618a1bcb4b7331f1144cddca05e18f6de1c7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Dec 2024 15:52:34 -0800 Subject: [PATCH 28/28] Reuse get_valid_src to obtain valid srcset --- .../class-image-prioritizer-img-tag-visitor.php | 15 ++++++--------- .../class-od-html-tag-processor.php | 1 + 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index ded4d6ae2e..ba14edf57e 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -218,12 +218,8 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit } // Abort processing if a SOURCE lacks the required srcset attribute. - $srcset = $processor->get_attribute( 'srcset' ); - if ( ! is_string( $srcset ) ) { - return false; - } - $srcset = trim( $srcset ); - if ( '' === $srcset ) { + $srcset = $this->get_valid_src( $processor, 'srcset' ); + if ( null === $srcset ) { return false; } @@ -289,11 +285,12 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit * * @since n.e.x.t * - * @param OD_HTML_Tag_Processor $processor Processor. + * @param OD_HTML_Tag_Processor $processor Processor. + * @param 'src'|'srcset' $attribute_name Attribute name. * @return non-empty-string|null URL which is not a data: URL. */ - private function get_valid_src( OD_HTML_Tag_Processor $processor ): ?string { - $src = $processor->get_attribute( 'src' ); + private function get_valid_src( OD_HTML_Tag_Processor $processor, string $attribute_name = 'src' ): ?string { + $src = $processor->get_attribute( $attribute_name ); if ( ! is_string( $src ) ) { return null; } diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index 30bfc5eb5c..7fc8967118 100644 --- a/plugins/optimization-detective/class-od-html-tag-processor.php +++ b/plugins/optimization-detective/class-od-html-tag-processor.php @@ -514,6 +514,7 @@ private function get_indexed_breadcrumbs(): Generator { * They always include the entire path from the root HTML node to the matched element. * * @since n.e.x.t + * @see WP_HTML_Processor::get_breadcrumbs() * * @return string[] Array of tag names representing path to matched node. */