diff --git a/plugins/optimization-detective/class-od-preload-link-collection.php b/plugins/optimization-detective/class-od-preload-link-collection.php index 6c0acdb8da..91f47850d8 100644 --- a/plugins/optimization-detective/class-od-preload-link-collection.php +++ b/plugins/optimization-detective/class-od-preload-link-collection.php @@ -76,14 +76,14 @@ public function add_link( array $attributes, ?int $minimum_viewport_width, ?int } /** - * Get adjacent-deduplicated links. + * Prepares links by deduplicating adjacent links and adding media attributes. * * When two links are identical except for their minimum/maximum widths which are also consecutive, then merge them - * together. + * together. Also, add media attributes to the links. * - * @return array Links with adjacent-duplicates merged together. + * @return array Prepared links with adjacent-duplicates merged together and media attributes added. */ - private function get_adjacent_deduplicated_links(): array { + private function get_prepared_links(): array { $links = $this->links; usort( @@ -100,7 +100,8 @@ static function ( array $a, array $b ): int { } ); - return array_reduce( + // Deduplicate adjacent links. + $prepared_links = array_reduce( $links, /** * Reducer. @@ -125,7 +126,7 @@ static function ( array $carry, array $link ): array { ) { $last_link['maximum_viewport_width'] = max( $last_link['maximum_viewport_width'], $link['maximum_viewport_width'] ); - // Update the last link with the new maximum viewport with. + // Update the last link with the new maximum viewport width. $carry[ count( $carry ) - 1 ] = $last_link; } else { $carry[] = $link; @@ -134,6 +135,22 @@ static function ( array $carry, array $link ): array { }, array() ); + + // Add media attributes to the deduplicated links. + return array_map( + static function ( array $link ): array { + $media_attributes = array( 'screen' ); + if ( null !== $link['minimum_viewport_width'] && $link['minimum_viewport_width'] > 0 ) { + $media_attributes[] = sprintf( '(min-width: %dpx)', $link['minimum_viewport_width'] ); + } + if ( null !== $link['maximum_viewport_width'] && PHP_INT_MAX !== $link['maximum_viewport_width'] ) { + $media_attributes[] = sprintf( '(max-width: %dpx)', $link['maximum_viewport_width'] ); + } + $link['attributes']['media'] = implode( ' and ', $media_attributes ); + return $link; + }, + $prepared_links + ); } /** @@ -144,16 +161,7 @@ static function ( array $carry, array $link ): array { public function get_html(): string { $link_tags = array(); - foreach ( $this->get_adjacent_deduplicated_links() as $link ) { - $media_features = array( 'screen' ); - if ( null !== $link['minimum_viewport_width'] && $link['minimum_viewport_width'] > 0 ) { - $media_features[] = sprintf( '(min-width: %dpx)', $link['minimum_viewport_width'] ); - } - if ( null !== $link['maximum_viewport_width'] && PHP_INT_MAX !== $link['maximum_viewport_width'] ) { - $media_features[] = sprintf( '(max-width: %dpx)', $link['maximum_viewport_width'] ); - } - $link['attributes']['media'] = implode( ' and ', $media_features ); - + foreach ( $this->get_prepared_links() as $link ) { $link_tag = ' $value ) { $link_tag .= sprintf( ' %s="%s"', $name, esc_attr( $value ) ); @@ -166,6 +174,44 @@ public function get_html(): string { return implode( '', $link_tags ); } + /** + * Constructs the Link HTTP response header. + * + * @return string|null Link HTTP response header, or null if there are none. + */ + public function get_response_header(): ?string { + $link_headers = array(); + + foreach ( $this->get_prepared_links() as $link ) { + // The about:blank is present since a Link without a reference-uri is invalid so any imagesrcset would otherwise not get downloaded. + $link['attributes']['href'] = isset( $link['attributes']['href'] ) ? esc_url_raw( $link['attributes']['href'] ) : 'about:blank'; + $link_header = '<' . $link['attributes']['href'] . '>; rel="preload"'; + unset( $link['attributes']['href'] ); + foreach ( $link['attributes'] as $name => $value ) { + /* + * Escape the value being put into an HTTP quoted string. The grammar is: + * + * quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE + * qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text + * quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) + * obs-text = %x80-FF + * + * See . So to escape a value we need to add + * a backslash in front of anything character which is not qdtext. + */ + $escaped_value = preg_replace( '/(?=[^\t \x21\x23-\x5B\x5D-\x7E\x80-\xFF])/', '\\\\', $value ); + $link_header .= sprintf( '; %s="%s"', $name, $escaped_value ); + } + + $link_headers[] = $link_header; + } + if ( count( $link_headers ) === 0 ) { + return null; + } + + return 'Link: ' . implode( ', ', $link_headers ); + } + /** * Counts the links. * diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index e3a9696c85..2ae83ba295 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -189,8 +189,12 @@ function od_optimize_template_output_buffer( string $buffer ): string { $generator->next(); } - // Inject any preload links at the end of the HEAD. + // Send any preload links in a Link response header and in a LINK tag injected at the end of the HEAD. if ( count( $preload_links ) > 0 ) { + $response_header_links = $preload_links->get_response_header(); + if ( ! is_null( $response_header_links ) && ! headers_sent() ) { + header( $response_header_links, false ); + } $walker->append_head_html( $preload_links->get_html() ); } diff --git a/plugins/optimization-detective/tests/test-optimization.php b/plugins/optimization-detective/tests/test-optimization.php index 613265dc52..ea3480cdbc 100644 --- a/plugins/optimization-detective/tests/test-optimization.php +++ b/plugins/optimization-detective/tests/test-optimization.php @@ -368,6 +368,44 @@ function ( OD_HTML_Tag_Walker $walker, OD_URL_Metrics_Group_Collection $url_metr $this->assertEquals( $expected, $buffer ); } + /** + * Test get_response_header(). + * + * @covers OD_Preload_Link_Collection::get_response_header + */ + public function test_get_response_header(): void { + $collection = new OD_Preload_Link_Collection(); + + $collection->add_link( + array( + 'href' => 'https://example.com/foo.jpg', + 'as' => 'image', + 'fetchpriority' => 'high', + 'imagesrcset' => 'https://example.com/foo-480w.jpg 480w, https://example.com/foo-800w.jpg 800w', + 'imagesizes' => '(max-width: 600px) 480px, 800px', + 'crossorigin' => 'anonymous', + ), + null, + null + ); + + $collection->add_link( + array( + 'href' => 'https://example.com/bar.jpg', + 'as' => 'image', + 'fetchpriority' => 'high', + 'imagesrcset' => 'https://example.com/"bar"-480w.jpg 480w, https://example.com/"bar"-800w.jpg 800w', + 'imagesizes' => '(max-width: 600px) 480px, 800px', + 'crossorigin' => 'anonymous', + ), + 600, + 1200 + ); + + $expected_header = 'Link: ; rel="preload"; as="image"; fetchpriority="high"; imagesrcset="https://example.com/foo-480w.jpg 480w, https://example.com/foo-800w.jpg 800w"; imagesizes="(max-width: 600px) 480px, 800px"; crossorigin="anonymous"; media="screen", ; rel="preload"; as="image"; fetchpriority="high"; imagesrcset="https://example.com/\"bar\"-480w.jpg 480w, https://example.com/\"bar\"-800w.jpg 800w"; imagesizes="(max-width: 600px) 480px, 800px"; crossorigin="anonymous"; media="screen and (min-width: 600px) and (max-width: 1200px)"'; + $this->assertSame( $expected_header, $collection->get_response_header() ); + } + /** * Gets a validated URL metric. *