Skip to content

Commit 3c5dd7f

Browse files
authored
Merge pull request #1707 from b1ink0/fix/preload-lcp-images-for-picture-elements
Add preload links LCP picture elements
2 parents 7e69f6b + ece0618 commit 3c5dd7f

11 files changed

+652
-194
lines changed

plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php

Lines changed: 214 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
/**
1515
* Tag visitor that optimizes IMG tags.
1616
*
17+
* @phpstan-import-type LinkAttributes from OD_Link_Collection
18+
*
1719
* @since 0.1.0
1820
* @access private
1921
*/
@@ -22,19 +24,37 @@ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visi
2224
/**
2325
* Visits a tag.
2426
*
25-
* @param OD_Tag_Visitor_Context $context Tag visitor context.
27+
* @since 0.1.0
28+
* @since n.e.x.t Separate the processing of IMG and PICTURE elements.
2629
*
30+
* @param OD_Tag_Visitor_Context $context Tag visitor context.
2731
* @return bool Whether the tag should be tracked in URL Metrics.
2832
*/
2933
public function __invoke( OD_Tag_Visitor_Context $context ): bool {
3034
$processor = $context->processor;
31-
if ( 'IMG' !== $processor->get_tag() ) {
32-
return false;
35+
$tag = $processor->get_tag();
36+
37+
if ( 'PICTURE' === $tag ) {
38+
return $this->process_picture( $processor, $context );
39+
} elseif ( 'IMG' === $tag ) {
40+
return $this->process_img( $processor, $context );
3341
}
3442

35-
// Skip empty src attributes and data: URLs.
36-
$src = trim( (string) $processor->get_attribute( 'src' ) );
37-
if ( '' === $src || $this->is_data_url( $src ) ) {
43+
return false;
44+
}
45+
46+
/**
47+
* Process an IMG element.
48+
*
49+
* @since n.e.x.t
50+
*
51+
* @param OD_HTML_Tag_Processor $processor HTML tag processor.
52+
* @param OD_Tag_Visitor_Context $context Tag visitor context.
53+
* @return bool Whether the tag should be tracked in URL Metrics.
54+
*/
55+
private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool {
56+
$src = $this->get_valid_src( $processor );
57+
if ( null === $src ) {
3858
return false;
3959
}
4060

@@ -142,41 +162,207 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool {
142162
}
143163
}
144164

145-
// If this element is the LCP (for a breakpoint group), add a preload link for it.
146-
foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
147-
$link_attributes = array_merge(
165+
$parent_tag = $this->get_parent_tag_name( $context );
166+
if ( 'PICTURE' !== $parent_tag ) {
167+
$this->add_image_preload_link_for_lcp_element_groups(
168+
$context,
169+
$xpath,
148170
array(
149-
'rel' => 'preload',
150-
'fetchpriority' => 'high',
151-
'as' => 'image',
152-
),
153-
array_filter(
154-
array(
155-
'href' => (string) $processor->get_attribute( 'src' ),
156-
'imagesrcset' => (string) $processor->get_attribute( 'srcset' ),
157-
'imagesizes' => (string) $processor->get_attribute( 'sizes' ),
158-
),
159-
static function ( string $value ): bool {
160-
return '' !== $value;
161-
}
171+
'href' => $processor->get_attribute( 'src' ),
172+
'imagesrcset' => $processor->get_attribute( 'srcset' ),
173+
'imagesizes' => $processor->get_attribute( 'sizes' ),
174+
'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ),
175+
'referrerpolicy' => $this->get_attribute_value( $processor, 'referrerpolicy' ),
162176
)
163177
);
178+
}
179+
180+
return true;
181+
}
182+
183+
/**
184+
* Process a PICTURE element.
185+
*
186+
* @since n.e.x.t
187+
*
188+
* @param OD_HTML_Tag_Processor $processor HTML tag processor.
189+
* @param OD_Tag_Visitor_Context $context Tag visitor context.
190+
* @return bool Whether the tag should be tracked in URL Metrics.
191+
*/
192+
private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool {
193+
/**
194+
* First SOURCE tag's attributes.
195+
*
196+
* @var array{ srcset: non-empty-string, sizes: string|null, type: non-empty-string }|null $first_source
197+
*/
198+
$first_source = null;
199+
$img_xpath = null;
200+
201+
$referrerpolicy = null;
202+
$crossorigin = null;
203+
204+
// Loop through child tags until we reach the closing PICTURE tag.
205+
while ( $processor->next_tag() ) {
206+
$tag = $processor->get_tag();
207+
208+
// If we reached the closing PICTURE tag, break.
209+
if ( 'PICTURE' === $tag && $processor->is_tag_closer() ) {
210+
break;
211+
}
212+
213+
// Process the SOURCE elements.
214+
if ( 'SOURCE' === $tag && ! $processor->is_tag_closer() ) {
215+
// Abort processing if the PICTURE involves art direction since then adding a preload link is infeasible.
216+
if ( null !== $processor->get_attribute( 'media' ) ) {
217+
return false;
218+
}
219+
220+
// Abort processing if a SOURCE lacks the required srcset attribute.
221+
$srcset = $this->get_valid_src( $processor, 'srcset' );
222+
if ( null === $srcset ) {
223+
return false;
224+
}
225+
226+
// Abort processing if there is no valid image type.
227+
$type = $this->get_attribute_value( $processor, 'type' );
228+
if ( ! is_string( $type ) || ! str_starts_with( $type, 'image/' ) ) {
229+
return false;
230+
}
231+
232+
// Collect the first valid SOURCE as the preload link.
233+
if ( null === $first_source ) {
234+
$sizes = $processor->get_attribute( 'sizes' );
235+
$first_source = array(
236+
'srcset' => $srcset,
237+
'sizes' => is_string( $sizes ) ? $sizes : null,
238+
'type' => $type,
239+
);
240+
}
241+
}
242+
243+
// Process the IMG element within the PICTURE.
244+
if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) {
245+
$src = $this->get_valid_src( $processor );
246+
if ( null === $src ) {
247+
return false;
248+
}
164249

165-
$crossorigin = $this->get_attribute_value( $processor, 'crossorigin' );
166-
if ( null !== $crossorigin ) {
167-
$link_attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous';
250+
// These attributes are only defined on the IMG itself.
251+
$referrerpolicy = $this->get_attribute_value( $processor, 'referrerpolicy' );
252+
$crossorigin = $this->get_attribute_value( $processor, 'crossorigin' );
253+
254+
// Capture the XPath for the IMG since the browser captures it as the LCP element, so we need this to
255+
// look up whether it is the LCP element in the URL Metric groups.
256+
$img_xpath = $processor->get_xpath();
168257
}
258+
}
169259

170-
$link_attributes['media'] = 'screen';
260+
// Abort if we never encountered a SOURCE or IMG tag.
261+
if ( null === $img_xpath || null === $first_source ) {
262+
return false;
263+
}
264+
265+
$this->add_image_preload_link_for_lcp_element_groups(
266+
$context,
267+
$img_xpath,
268+
array(
269+
'imagesrcset' => $first_source['srcset'],
270+
'imagesizes' => $first_source['sizes'],
271+
'type' => $first_source['type'],
272+
'crossorigin' => $crossorigin,
273+
'referrerpolicy' => $referrerpolicy,
274+
)
275+
);
276+
277+
return false;
278+
}
171279

280+
/**
281+
* Gets valid src attribute value for preloading.
282+
*
283+
* Returns null if the src attribute is not a string (i.e. src was used as a boolean attribute was used), if it
284+
* it has an empty string value after trimming, or if it is a data: URL.
285+
*
286+
* @since n.e.x.t
287+
*
288+
* @param OD_HTML_Tag_Processor $processor Processor.
289+
* @param 'src'|'srcset' $attribute_name Attribute name.
290+
* @return non-empty-string|null URL which is not a data: URL.
291+
*/
292+
private function get_valid_src( OD_HTML_Tag_Processor $processor, string $attribute_name = 'src' ): ?string {
293+
$src = $processor->get_attribute( $attribute_name );
294+
if ( ! is_string( $src ) ) {
295+
return null;
296+
}
297+
$src = trim( $src );
298+
if ( '' === $src || $this->is_data_url( $src ) ) {
299+
return null;
300+
}
301+
return $src;
302+
}
303+
304+
/**
305+
* Adds a LINK with the supplied attributes for each viewport group when the provided XPath is the LCP element.
306+
*
307+
* @since n.e.x.t
308+
*
309+
* @param OD_Tag_Visitor_Context $context Tag visitor context.
310+
* @param string $xpath XPath of the element.
311+
* @param array<string, string|true|null> $attributes Attributes to add to the link.
312+
*/
313+
private function add_image_preload_link_for_lcp_element_groups( OD_Tag_Visitor_Context $context, string $xpath, array $attributes ): void {
314+
$attributes = array_filter(
315+
$attributes,
316+
static function ( $attribute_value ) {
317+
return is_string( $attribute_value ) && '' !== $attribute_value;
318+
}
319+
);
320+
321+
/**
322+
* Link attributes.
323+
*
324+
* This type is needed because PHPStan isn't apparently aware of the new keys added after the array_merge().
325+
* Note that there is no type checking being done on the attributes above other than ensuring they are
326+
* non-empty-strings.
327+
*
328+
* @var LinkAttributes $attributes
329+
*/
330+
$attributes = array_merge(
331+
array(
332+
'rel' => 'preload',
333+
'fetchpriority' => 'high',
334+
'as' => 'image',
335+
),
336+
$attributes,
337+
array(
338+
'media' => 'screen',
339+
)
340+
);
341+
342+
foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
172343
$context->link_collection->add_link(
173-
$link_attributes,
344+
$attributes,
174345
$group->get_minimum_viewport_width(),
175346
$group->get_maximum_viewport_width()
176347
);
177348
}
349+
}
178350

179-
return true;
351+
/**
352+
* Gets the parent tag name.
353+
*
354+
* @since n.e.x.t
355+
*
356+
* @param OD_Tag_Visitor_Context $context Tag visitor context.
357+
* @return string|null The parent tag name or null if not found.
358+
*/
359+
private function get_parent_tag_name( OD_Tag_Visitor_Context $context ): ?string {
360+
$breadcrumbs = $context->processor->get_breadcrumbs();
361+
$length = count( $breadcrumbs );
362+
if ( $length < 2 ) {
363+
return null;
364+
}
365+
return $breadcrumbs[ $length - 2 ];
180366
}
181367

182368
/**

plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
/**
1515
* Tag visitor that optimizes image tags.
1616
*
17-
* @phpstan-type NormalizedAttributeNames 'fetchpriority'|'loading'|'crossorigin'|'preload'
17+
* @phpstan-type NormalizedAttributeNames 'fetchpriority'|'loading'|'crossorigin'|'preload'|'referrerpolicy'|'type'
1818
*
1919
* @since 0.1.0
2020
* @access private
@@ -44,6 +44,7 @@ protected function is_data_url( string $url ): bool {
4444
*
4545
* @since 0.2.0
4646
* @todo Move this into the OD_HTML_Tag_Processor/OD_HTML_Processor class eventually.
47+
* @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'.
4748
*
4849
* @phpstan-param NormalizedAttributeNames $attribute_name
4950
*
@@ -53,9 +54,16 @@ protected function is_data_url( string $url ): bool {
5354
*/
5455
protected function get_attribute_value( OD_HTML_Tag_Processor $processor, string $attribute_name ) {
5556
$value = $processor->get_attribute( $attribute_name );
57+
if ( null === $value ) {
58+
return null;
59+
}
60+
5661
if ( is_string( $value ) ) {
5762
$value = strtolower( trim( $value, " \t\f\r\n" ) );
5863
}
64+
if ( 'crossorigin' === $attribute_name && 'use-credentials' !== $value ) {
65+
$value = 'anonymous';
66+
}
5967
return $value;
6068
}
6169
}

plugins/image-prioritizer/tests/test-cases/different-lcp-elements-for-all-breakpoints.php

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?php
22
return array(
33
'set_up' => static function ( Test_Image_Prioritizer_Helper $test_case ): void {
4-
$breakpoint_max_widths = array( 480, 600, 782 );
4+
$breakpoint_max_widths = array( 480, 600, 782, 1000 );
55

66
add_filter(
77
'od_breakpoint_max_widths',
@@ -28,6 +28,10 @@ static function () use ( $breakpoint_max_widths ) {
2828
'isLCP' => false,
2929
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::IMG]',
3030
),
31+
array(
32+
'isLCP' => false,
33+
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[5][self::IMG]',
34+
),
3135
);
3236
$elements[ $i ]['isLCP'] = true;
3337
OD_URL_Metrics_Post_Type::store_url_metric(
@@ -49,9 +53,10 @@ static function () use ( $breakpoint_max_widths ) {
4953
</head>
5054
<body>
5155
<img src="https://example.com/mobile-logo.png" alt="Mobile Logo" width="600" height="600" crossorigin>
52-
<img src="https://example.com/phablet-logo.png" alt="Phablet Logo" width="600" height="600" crossorigin="">
53-
<img src="https://example.com/tablet-logo.png" alt="Tablet Logo" width="600" height="600" crossorigin="anonymous">
54-
<img src="https://example.net/desktop-logo.png" alt="Desktop Logo" width="600" height="600" crossorigin="use-credentials">
56+
<img src="https://example.com/phablet-logo.png" alt="Phablet Logo" width="600" height="600" crossorigin="" referrerpolicy="no-referrer">
57+
<img src="https://example.com/tablet-logo.png" alt="Tablet Logo" width="600" height="600" crossorigin="anonymous" referrerpolicy="no-referrer-when-downgrade">
58+
<img src="https://example.net/desktop-logo.png" alt="Desktop Logo" width="600" height="600" crossorigin="use-credentials" referrerpolicy="origin-when-cross-origin">
59+
<img src="https://example.net/ultra-desktop-logo.png" alt="Desktop Logo" width="600" height="600" crossorigin=" something-custom " referrerpolicy="same-origin">
5560
</body>
5661
</html>
5762
',
@@ -61,15 +66,17 @@ static function () use ( $breakpoint_max_widths ) {
6166
<meta charset="utf-8">
6267
<title>...</title>
6368
<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)">
64-
<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)">
65-
<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)">
66-
<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)">
69+
<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)">
70+
<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)">
71+
<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)">
72+
<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)">
6773
</head>
6874
<body>
6975
<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>
70-
<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="">
71-
<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">
72-
<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">
76+
<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">
77+
<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">
78+
<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">
79+
<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">
7380
<script type="module">/* import detect ... */</script>
7481
</body>
7582
</html>

0 commit comments

Comments
 (0)