Skip to content

Commit

Permalink
Merge pull request #1707 from b1ink0/fix/preload-lcp-images-for-pictu…
Browse files Browse the repository at this point in the history
…re-elements

Add preload links LCP picture elements
  • Loading branch information
westonruter authored Dec 5, 2024
2 parents 7e69f6b + ece0618 commit 3c5dd7f
Show file tree
Hide file tree
Showing 11 changed files with 652 additions and 194 deletions.
242 changes: 214 additions & 28 deletions plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
/**
* Tag visitor that optimizes IMG tags.
*
* @phpstan-import-type LinkAttributes from OD_Link_Collection
*
* @since 0.1.0
* @access private
*/
Expand All @@ -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;
}

Expand Down Expand Up @@ -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<string, string|true|null> $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 ];
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
return array(
'set_up' => 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',
Expand All @@ -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(
Expand All @@ -49,9 +53,10 @@ static function () use ( $breakpoint_max_widths ) {
</head>
<body>
<img src="https://example.com/mobile-logo.png" alt="Mobile Logo" width="600" height="600" crossorigin>
<img src="https://example.com/phablet-logo.png" alt="Phablet Logo" width="600" height="600" crossorigin="">
<img src="https://example.com/tablet-logo.png" alt="Tablet Logo" width="600" height="600" crossorigin="anonymous">
<img src="https://example.net/desktop-logo.png" alt="Desktop Logo" width="600" height="600" crossorigin="use-credentials">
<img src="https://example.com/phablet-logo.png" alt="Phablet Logo" width="600" height="600" crossorigin="" referrerpolicy="no-referrer">
<img src="https://example.com/tablet-logo.png" alt="Tablet Logo" width="600" height="600" crossorigin="anonymous" referrerpolicy="no-referrer-when-downgrade">
<img src="https://example.net/desktop-logo.png" alt="Desktop Logo" width="600" height="600" crossorigin="use-credentials" referrerpolicy="origin-when-cross-origin">
<img src="https://example.net/ultra-desktop-logo.png" alt="Desktop Logo" width="600" height="600" crossorigin=" something-custom " referrerpolicy="same-origin">
</body>
</html>
',
Expand All @@ -61,15 +66,17 @@ static function () use ( $breakpoint_max_widths ) {
<meta charset="utf-8">
<title>...</title>
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/mobile-logo.png" crossorigin="anonymous" media="screen and (max-width: 480px)">
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/phablet-logo.png" crossorigin="anonymous" media="screen and (min-width: 481px) and (max-width: 600px)">
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/tablet-logo.png" crossorigin="anonymous" media="screen and (min-width: 601px) and (max-width: 782px)">
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.net/desktop-logo.png" crossorigin="use-credentials" media="screen and (min-width: 783px)">
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/phablet-logo.png" crossorigin="anonymous" referrerpolicy="no-referrer" media="screen and (min-width: 481px) and (max-width: 600px)">
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/tablet-logo.png" crossorigin="anonymous" referrerpolicy="no-referrer-when-downgrade" media="screen and (min-width: 601px) and (max-width: 782px)">
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.net/desktop-logo.png" crossorigin="use-credentials" referrerpolicy="origin-when-cross-origin" media="screen and (min-width: 783px) and (max-width: 1000px)">
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.net/ultra-desktop-logo.png" crossorigin="anonymous" referrerpolicy="same-origin" media="screen and (min-width: 1001px)">
</head>
<body>
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]" src="https://example.com/mobile-logo.png" alt="Mobile Logo" width="600" height="600" crossorigin>
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[2][self::IMG]" src="https://example.com/phablet-logo.png" alt="Phablet Logo" width="600" height="600" crossorigin="">
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::IMG]" src="https://example.com/tablet-logo.png" alt="Tablet Logo" width="600" height="600" crossorigin="anonymous">
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[4][self::IMG]" src="https://example.net/desktop-logo.png" alt="Desktop Logo" width="600" height="600" crossorigin="use-credentials">
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[2][self::IMG]" src="https://example.com/phablet-logo.png" alt="Phablet Logo" width="600" height="600" crossorigin="" referrerpolicy="no-referrer">
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::IMG]" src="https://example.com/tablet-logo.png" alt="Tablet Logo" width="600" height="600" crossorigin="anonymous" referrerpolicy="no-referrer-when-downgrade">
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[4][self::IMG]" src="https://example.net/desktop-logo.png" alt="Desktop Logo" width="600" height="600" crossorigin="use-credentials" referrerpolicy="origin-when-cross-origin">
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[5][self::IMG]" src="https://example.net/ultra-desktop-logo.png" alt="Desktop Logo" width="600" height="600" crossorigin=" something-custom " referrerpolicy="same-origin">
<script type="module">/* import detect ... */</script>
</body>
</html>
Expand Down
Loading

0 comments on commit 3c5dd7f

Please sign in to comment.