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 3480c6d72..ba14edf57 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 */ @@ -22,19 +24,37 @@ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visi /** * Visits a tag. * - * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @since 0.1.0 + * @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 { $processor = $context->processor; - if ( 'IMG' !== $processor->get_tag() ) { - return false; + $tag = $processor->get_tag(); + + if ( 'PICTURE' === $tag ) { + return $this->process_picture( $processor, $context ); + } elseif ( 'IMG' === $tag ) { + return $this->process_img( $processor, $context ); } - // Skip empty src attributes and data: URLs. - $src = trim( (string) $processor->get_attribute( 'src' ) ); - if ( '' === $src || $this->is_data_url( $src ) ) { + return false; + } + + /** + * Process an IMG 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_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { + $src = $this->get_valid_src( $processor ); + if ( null === $src ) { return false; } @@ -142,41 +162,207 @@ 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( + $parent_tag = $this->get_parent_tag_name( $context ); + if ( 'PICTURE' !== $parent_tag ) { + $this->add_image_preload_link_for_lcp_element_groups( + $context, + $xpath, 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; - } + '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' ), ) ); + } + + return true; + } + + /** + * Process a PICTURE 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 { + /** + * 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; + + $referrerpolicy = null; + $crossorigin = null; + + // Loop through child tags until we reach the closing PICTURE tag. + while ( $processor->next_tag() ) { + $tag = $processor->get_tag(); + + // If we reached the closing PICTURE tag, break. + if ( 'PICTURE' === $tag && $processor->is_tag_closer() ) { + break; + } + + // Process the SOURCE elements. + if ( 'SOURCE' === $tag && ! $processor->is_tag_closer() ) { + // 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 = $this->get_valid_src( $processor, 'srcset' ); + if ( null === $srcset ) { + return false; + } + + // 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' => $srcset, + 'sizes' => is_string( $sizes ) ? $sizes : null, + 'type' => $type, + ); + } + } + + // Process the IMG element within the PICTURE. + if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) { + $src = $this->get_valid_src( $processor ); + if ( null === $src ) { + return false; + } - $crossorigin = $this->get_attribute_value( $processor, 'crossorigin' ); - if ( null !== $crossorigin ) { - $link_attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous'; + // 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(); } + } - $link_attributes['media'] = 'screen'; + // Abort if we never encountered a SOURCE or IMG tag. + if ( null === $img_xpath || null === $first_source ) { + return false; + } + + $this->add_image_preload_link_for_lcp_element_groups( + $context, + $img_xpath, + array( + 'imagesrcset' => $first_source['srcset'], + 'imagesizes' => $first_source['sizes'], + 'type' => $first_source['type'], + 'crossorigin' => $crossorigin, + 'referrerpolicy' => $referrerpolicy, + ) + ); + + 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. + * @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 $attribute_name = 'src' ): ?string { + $src = $processor->get_attribute( $attribute_name ); + 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. + * + * @since n.e.x.t + * + * @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_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() ); } + } - return true; + /** + * 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 { + $breadcrumbs = $context->processor->get_breadcrumbs(); + $length = count( $breadcrumbs ); + if ( $length < 2 ) { + return null; + } + return $breadcrumbs[ $length - 2 ]; } /** diff --git a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php index 240b359f6..ba2850f6a 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'|'type' * * @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,9 +54,16 @@ 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 && '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 c51a47dca..9c86be802 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( @@ -49,9 +53,10 @@ static function () use ( $breakpoint_max_widths ) { Mobile Logo - Phablet Logo - Tablet Logo - Desktop Logo + Phablet Logo + Tablet Logo + Desktop Logo + Desktop Logo ', @@ -61,15 +66,17 @@ static function () use ( $breakpoint_max_widths ) { ... - - - + + + + Mobile Logo - Phablet Logo - Tablet Logo - Desktop Logo + Phablet Logo + Tablet Logo + Desktop Logo + Desktop Logo 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 000000000..f4ca8404a --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php @@ -0,0 +1,68 @@ + 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]/*[3][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 new file mode 100644 index 000000000..836c0d993 --- /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 + + + + ', +); 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 000000000..8f2af94d6 --- /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 new file mode 100644 index 000000000..24c4021b0 --- /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 + + + + ', +); diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index c4a74f44a..7fc896711 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 ); } } @@ -497,15 +497,31 @@ 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 ] ); } } + /** + * 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 + * @see WP_HTML_Processor::get_breadcrumbs() + * + * @return string[] Array of tag names representing path to matched node. + */ + public function get_breadcrumbs(): array { + return $this->open_stack_tags; + } + /** * Determines whether currently inside a foreign element (MATH or SVG). * @@ -535,7 +551,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 ); } } diff --git a/plugins/optimization-detective/class-od-link-collection.php b/plugins/optimization-detective/class-od-link-collection.php index d6d7739fc..4e41a7af7 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' * } 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 fe9ff889a..4590d28cb 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,26 +45,26 @@ 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]', + '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' ), + '/*[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; } } 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 dffaa3ead..3c0ea1bd4 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' => '', ),