Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Send preload links via HTTP Link headers in addition to LINK tags #1323

Merged
merged 10 commits into from
Jul 8, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, Link> Links with adjacent-duplicates merged together.
* @return array<int, Link> 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(
Expand All @@ -100,7 +100,8 @@ static function ( array $a, array $b ): int {
}
);

return array_reduce(
// Deduplicate adjacent links.
$prepared_links = array_reduce(
$links,
/**
* Reducer.
Expand All @@ -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;
Expand All @@ -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
);
}

/**
Expand All @@ -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 = '<link data-od-added-tag rel="preload"';
foreach ( $link['attributes'] as $name => $value ) {
$link_tag .= sprintf( ' %s="%s"', $name, esc_attr( $value ) );
Expand All @@ -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 <https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.4>. 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;
}

AhmarZaidi marked this conversation as resolved.
Show resolved Hide resolved
return 'Link: ' . implode( ', ', $link_headers );
}

/**
* Counts the links.
*
Expand Down
6 changes: 5 additions & 1 deletion plugins/optimization-detective/optimization.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
}

Expand Down
38 changes: 38 additions & 0 deletions plugins/optimization-detective/tests/test-optimization.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://example.com/foo.jpg>; 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", <https://example.com/bar.jpg>; 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.
*
Expand Down
Loading