diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 25d98ede38..4933e3f88f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -324,6 +324,7 @@ add_filter( 'rest_authentication_errors', 'rest_cookie_check_errors', 100 ); add_action( 'wp_head', '_wp_render_title_tag', 1 ); add_action( 'wp_head', 'wp_enqueue_scripts', 1 ); add_action( 'wp_head', 'wp_resource_hints', 2 ); +add_action( 'wp_head', 'wp_preload_resources', 1 ); add_action( 'wp_head', 'feed_links', 2 ); add_action( 'wp_head', 'feed_links_extra', 3 ); add_action( 'wp_head', 'rsd_link' ); diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index c91cd48efa..0a12a590cf 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3423,6 +3423,120 @@ function wp_resource_hints() { } } +/** + * Prints resource preloads directives to browsers. + * + * Gives directive to browsers to preload specific resources that website will + * need very soon, this ensures that they are available earlier and are less + * likely to block the page's render. Preload directives should not be used for + * non-render-blocking elements, as then they would compete with the + * render-blocking ones, slowing down the render. + * + * These performance improving indicators work by using ``. + * + * @link https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload + * @link https://web.dev/preload-responsive-images/ + * + * @since 6.1.0 + */ +function wp_preload_resources() { + /** + * Filters domains and URLs for resource preloads. + * + * @since 6.1.0 + * + * @param array $preload_resources { + * Array of resources and their attributes, or URLs to print for resource preloads. + * + * @type array ...$0 { + * Array of resource attributes. + * + * @type string $href URL to include in resource preloads. Required. + * @type string $as How the browser should treat the resource + * (`script`, `style`, `image`, `document`, etc). + * @type string $crossorigin Indicates the CORS policy of the specified resource. + * @type string $type Type of the resource (`text/html`, `text/css`, etc). + * @type string $media Accepts media types or media queries. Allows responsive preloading. + * @type string $imagesizes Responsive source size to the source Set. + * @type string $imagesrcset Responsive image sources to the source set. + * } + * } + */ + $preload_resources = apply_filters( 'wp_preload_resources', array() ); + + if ( ! is_array( $preload_resources ) ) { + return; + } + + $unique_resources = array(); + + // Parse the complete resource list and extract unique resources. + foreach ( $preload_resources as $resource ) { + if ( ! is_array( $resource ) ) { + continue; + } + + $attributes = $resource; + if ( isset( $resource['href'] ) ) { + $href = $resource['href']; + if ( isset( $unique_resources[ $href ] ) ) { + continue; + } + $unique_resources[ $href ] = $attributes; + // Media can use imagesrcset and not href. + } elseif ( ( 'image' === $resource['as'] ) && + ( isset( $resource['imagesrcset'] ) || isset( $resource['imagesizes'] ) ) + ) { + if ( isset( $unique_resources[ $resource['imagesrcset'] ] ) ) { + continue; + } + $unique_resources[ $resource['imagesrcset'] ] = $attributes; + } else { + continue; + } + } + + // Build and output the HTML for each unique resource. + foreach ( $unique_resources as $unique_resource ) { + $html = ''; + + foreach ( $unique_resource as $resource_key => $resource_value ) { + if ( ! is_scalar( $resource_value ) ) { + continue; + } + + // Ignore non-supported attributes. + $non_supported_attributes = array( 'as', 'crossorigin', 'href', 'imagesrcset', 'imagesizes', 'type', 'media' ); + if ( ! in_array( $resource_key, $non_supported_attributes, true ) && ! is_numeric( $resource_key ) ) { + continue; + } + + // imagesrcset only usable when preloading image, ignore otherwise. + if ( ( 'imagesrcset' === $resource_key ) && ( ! isset( $unique_resource['as'] ) || ( 'image' !== $unique_resource['as'] ) ) ) { + continue; + } + + // imagesizes only usable when preloading image and imagesrcset present, ignore otherwise. + if ( ( 'imagesizes' === $resource_key ) && + ( ! isset( $unique_resource['as'] ) || ( 'image' !== $unique_resource['as'] ) || ! isset( $unique_resource['imagesrcset'] ) ) + ) { + continue; + } + + $resource_value = ( 'href' === $resource_key ) ? esc_url( $resource_value, array( 'http', 'https' ) ) : esc_attr( $resource_value ); + + if ( ! is_string( $resource_key ) ) { + $html .= " $resource_value"; + } else { + $html .= " $resource_key='$resource_value'"; + } + } + $html = trim( $html ); + + printf( "\n", $html ); + } +} + /** * Retrieves a list of unique hosts of all enqueued scripts and styles. * diff --git a/tests/phpunit/tests/general/wpPreloadLinks.php b/tests/phpunit/tests/general/wpPreloadLinks.php new file mode 100644 index 0000000000..d9f4150088 --- /dev/null +++ b/tests/phpunit/tests/general/wpPreloadLinks.php @@ -0,0 +1,253 @@ +assertSame( $expected, $actual ); + } + + /** + * Test provider for all preload link possible combinations. + * + * @return array[] + */ + public function data_preload_resources() { + return array( + 'basic_preload' => array( + 'expected' => "\n", + 'urls' => array( + array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + ), + ), + ), + 'multiple_links' => array( + 'expected' => "\n" . + "\n", + 'urls' => array( + array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + ), + array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + ), + ), + ), + 'MIME_types' => array( + 'expected' => "\n" . + "\n" . + "\n", + 'urls' => array( + array( + // Should ignore not valid attributes. + 'not' => 'valid', + 'href' => 'https://example.com/style.css', + 'as' => 'style', + ), + array( + 'href' => 'https://example.com/video.mp4', + 'as' => 'video', + 'type' => 'video/mp4', + ), + array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + ), + ), + ), + 'CORS' => array( + 'expected' => "\n" . + "\n" . + "\n" . + "\n", + 'urls' => array( + array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + 'crossorigin' => 'anonymous', + ), + array( + 'href' => 'https://example.com/video.mp4', + 'as' => 'video', + 'type' => 'video/mp4', + ), + array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + ), + array( + // Should ignore not valid attributes. + 'ignore' => 'ignore', + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'crossorigin', + ), + ), + ), + 'media' => array( + 'expected' => "\n" . + "\n" . + "\n" . + "\n" . + "\n" . + "\n", + 'urls' => array( + array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + 'crossorigin' => 'anonymous', + ), + array( + 'href' => 'https://example.com/video.mp4', + 'as' => 'video', + 'type' => 'video/mp4', + ), + // Duplicated href should be ignored. + array( + 'href' => 'https://example.com/video.mp4', + 'as' => 'video', + 'type' => 'video/mp4', + ), + array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + ), + array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'crossorigin', + ), + array( + 'href' => 'https://example.com/image-narrow.png', + 'as' => 'image', + 'media' => '(max-width: 600px)', + ), + array( + 'href' => 'https://example.com/image-wide.png', + 'as' => 'image', + 'media' => '(min-width: 601px)', + ), + + ), + ), + 'media_extra_attributes' => array( + 'expected' => "\n" . + "\n" . + "\n" . + "\n" . + "\n" . + "\n" . + "\n" . + "\n", + 'urls' => array( + array( + 'href' => 'https://example.com/style.css', + 'as' => 'style', + 'crossorigin' => 'anonymous', + ), + array( + 'href' => 'https://example.com/video.mp4', + 'as' => 'video', + 'type' => 'video/mp4', + ), + array( + 'href' => 'https://example.com/main.js', + 'as' => 'script', + ), + array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'crossorigin', + ), + // imagesrcset only possible when using image, ignore. + array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'imagesrcset' => '640.png 640w, 800.png 800w, 1024.png 1024w', + ), + // imagesizes only possible when using image, ignore. + array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'imagesizes' => '100vw', + ), + // Duplicated href should be ignored. + array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'type' => 'font/woff2', + 'crossorigin', + ), + array( + 'href' => 'https://example.com/image-640.png', + 'as' => 'image', + 'imagesrcset' => '640.png 640w, 800.png 800w, 1024.png 1024w', + 'imagesizes' => '100vw', + ), + // Omit href so that unsupporting browsers won't request a useless image. + array( + 'as' => 'image', + 'imagesrcset' => '640.png 640w, 800.png 800w, 1024.png 1024w', + 'imagesizes' => '100vw', + ), + // Duplicated imagesrcset should be ignored. + array( + 'as' => 'image', + 'imagesrcset' => '640.png 640w, 800.png 800w, 1024.png 1024w', + 'imagesizes' => '100vw', + ), + array( + 'href' => 'https://example.com/image-wide.png', + 'as' => 'image', + 'media' => '(min-width: 601px)', + ), + // No href but not imagesrcset, should be ignored. + array( + 'as' => 'image', + 'media' => '(min-width: 601px)', + ), + // imagesizes is optional. + array( + 'href' => 'https://example.com/image-800.png', + 'as' => 'image', + 'imagesrcset' => '640.png 640w, 800.png 800w, 1024.png 1024w', + ), + // imagesizes should be ignored since imagesrcset not present. + array( + 'href' => 'https://example.com/image-640.png', + 'as' => 'image', + 'imagesizes' => '100vw', + ), + ), + ), + ); + } + +}