diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php
index eb6e185f5b49d..321414eeb7bbc 100644
--- a/src/wp-includes/general-template.php
+++ b/src/wp-includes/general-template.php
@@ -3945,6 +3945,27 @@ function wp_resource_hints() {
'prerender' => array(),
);
+ /*
+ * Attributes which may be printed on a resource hint, mapped to whether the
+ * attribute may be printed without a value when it is supplied as `true`.
+ *
+ * Other attributes of the link element are omitted because the HTML spec only
+ * allows them with other link types: blocking, color, disabled, imagesizes,
+ * imagesrcset, integrity, and sizes.
+ */
+ $allowed_attributes = array(
+ 'as' => false,
+ 'crossorigin' => true,
+ 'fetchpriority' => false,
+ 'href' => false,
+ 'hreflang' => false,
+ 'media' => false,
+ 'referrerpolicy' => false,
+ 'rel' => false,
+ 'title' => false,
+ 'type' => false,
+ );
+
foreach ( $hints as $relation_type => $urls ) {
$unique_urls = array();
@@ -3954,19 +3975,31 @@ function wp_resource_hints() {
* @since 4.6.0
* @since 4.7.0 The `$urls` parameter accepts arrays of specific HTML attributes
* as its child elements.
+ * @since 7.3.0 The obsolete `pr` attribute is no longer printed. The `crossorigin`
+ * attribute may be `true` to print it without a value. Added the
+ * `fetchpriority`, `hreflang`, `media`, `referrerpolicy`, and `title`
+ * attributes.
*
* @param array $urls {
* Array of resources and their attributes, or URLs to print for resource hints.
*
* @type array|string ...$0 {
- * Array of resource attributes, or a URL string.
+ * Array of resource attributes, or a URL string. A valueless attribute
+ * may also be supplied as a list item, as in `array( 'crossorigin' )`.
*
- * @type string $href URL to include in resource hints. 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 float $pr Expected probability that the resource hint will be used.
- * @type string $type Type of the resource (`text/html`, `text/css`, etc).
+ * @type string $href URL to include in resource hints. Required.
+ * @type string $as How the browser should treat the resource
+ * (`script`, `style`, `image`, `document`, etc).
+ * @type string|bool $crossorigin Indicates the CORS policy of the specified resource.
+ * When `true`, it is printed without a value, which
+ * is equivalent to `anonymous`.
+ * @type string $fetchpriority Priority of the fetch relative to other resources
+ * (`high`, `low`, or `auto`).
+ * @type string $hreflang Language of the resource.
+ * @type string $media Media query the resource applies to.
+ * @type string $referrerpolicy Referrer policy for the fetch.
+ * @type string $title Title of the resource hint.
+ * @type string $type Type of the resource (`text/html`, `text/css`, etc).
* }
* }
* @param string $relation_type The relation type the URLs are printed for. One of
@@ -4018,28 +4051,37 @@ function wp_resource_hints() {
}
foreach ( $unique_urls as $atts ) {
- $html = '';
+ $processor = new WP_HTML_Tag_Processor( '' );
+ $processor->next_tag();
foreach ( $atts as $attr => $value ) {
- if ( ! is_scalar( $value )
- || ( ! in_array( $attr, array( 'as', 'crossorigin', 'href', 'pr', 'rel', 'type' ), true ) && ! is_numeric( $attr ) )
- ) {
+ // A list item supplies a valueless attribute, as in `array( 'crossorigin', 'href' => … )`.
+ if ( is_int( $attr ) ) {
+ if ( ! is_string( $value ) ) {
+ continue;
+ }
+ $attr = $value;
+ $value = true;
+ }
+ if ( ! isset( $allowed_attributes[ $attr ] ) ) {
continue;
}
- $value = ( 'href' === $attr ) ? esc_url( $value ) : esc_attr( $value );
-
- if ( ! is_string( $attr ) ) {
- $html .= " $value";
+ if ( is_bool( $value ) ) {
+ if ( ! $allowed_attributes[ $attr ] ) {
+ continue;
+ }
+ } elseif ( is_scalar( $value ) ) {
+ $value = (string) $value;
} else {
- $html .= " $attr='$value'";
+ continue;
}
- }
- $html = trim( $html );
+ $processor->set_attribute( $attr, $value );
+ }
- echo "\n";
+ echo $processor->get_updated_html() . "\n";
}
}
}
@@ -4066,23 +4108,28 @@ function wp_preload_resources() {
*
* @since 6.1.0
* @since 6.6.0 Added the `$fetchpriority` attribute.
+ * @since 7.3.0 The `crossorigin` attribute may be `true` to print it without a value.
+ * List items other than `crossorigin` are no longer printed.
*
* @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. May be omitted
- * for an image preloaded by its `$imagesrcset`.
- * @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.
- * @type string $fetchpriority Fetchpriority value for the resource.
+ * Array of resource attributes. A valueless attribute may also be supplied
+ * as a list item, as in `array( 'crossorigin' )`.
+ *
+ * @type string $href URL to include in resource preloads. May be omitted
+ * for an image preloaded by its `$imagesrcset`.
+ * @type string $as How the browser should treat the resource
+ * (`script`, `style`, `image`, `document`, etc).
+ * @type string|bool $crossorigin Indicates the CORS policy of the specified resource.
+ * When `true`, it is printed without a value, which
+ * is equivalent to `anonymous`.
+ * @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.
+ * @type string $fetchpriority Fetchpriority value for the resource.
* }
* }
*/
@@ -4118,18 +4165,49 @@ function wp_preload_resources() {
}
}
+ /*
+ * Attributes which may be printed on a preload, mapped to whether the
+ * attribute may be printed without a value when it is supplied as `true`.
+ */
+ $allowed_attributes = array(
+ 'as' => false,
+ 'crossorigin' => true,
+ 'fetchpriority' => false,
+ 'href' => false,
+ 'imagesizes' => false,
+ 'imagesrcset' => false,
+ 'media' => false,
+ 'type' => false,
+ );
+
// Build and output the HTML for each unique resource.
foreach ( $unique_resources as $unique_resource ) {
- $html = '';
+ $processor = new WP_HTML_Tag_Processor( '' );
+ $processor->next_tag();
+ $processor->set_attribute( 'rel', 'preload' );
foreach ( $unique_resource as $resource_key => $resource_value ) {
- if ( ! is_scalar( $resource_value ) ) {
- continue;
+ // A list item supplies a valueless attribute, as in `array( 'crossorigin', 'href' => … )`.
+ if ( is_int( $resource_key ) ) {
+ if ( ! is_string( $resource_value ) ) {
+ continue;
+ }
+ $resource_key = $resource_value;
+ $resource_value = true;
}
// Ignore non-supported attributes.
- $non_supported_attributes = array( 'as', 'crossorigin', 'href', 'imagesrcset', 'imagesizes', 'type', 'media', 'fetchpriority' );
- if ( ! in_array( $resource_key, $non_supported_attributes, true ) && ! is_numeric( $resource_key ) ) {
+ if ( ! isset( $allowed_attributes[ $resource_key ] ) ) {
+ continue;
+ }
+
+ if ( is_bool( $resource_value ) ) {
+ if ( ! $allowed_attributes[ $resource_key ] ) {
+ continue;
+ }
+ } elseif ( is_scalar( $resource_value ) ) {
+ $resource_value = (string) $resource_value;
+ } else {
continue;
}
@@ -4145,17 +4223,14 @@ function wp_preload_resources() {
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'";
+ if ( 'href' === $resource_key && is_string( $resource_value ) ) {
+ $resource_value = sanitize_url( $resource_value, array( 'http', 'https' ) );
}
+
+ $processor->set_attribute( $resource_key, $resource_value );
}
- $html = trim( $html );
- printf( "\n", $html );
+ echo $processor->get_updated_html() . "\n";
}
}
diff --git a/tests/phpunit/tests/general/wpPreloadResources.php b/tests/phpunit/tests/general/wpPreloadResources.php
index dbf632ea2709b..cacc03e8f28ac 100644
--- a/tests/phpunit/tests/general/wpPreloadResources.php
+++ b/tests/phpunit/tests/general/wpPreloadResources.php
@@ -12,8 +12,11 @@ class Tests_General_wpPreloadResources extends WP_UnitTestCase {
* @dataProvider data_preload_resources
*
* @ticket 42438
+ *
+ * @param string $expected Expected markup.
+ * @param array> $preload_resources Resources to preload.
*/
- public function test_preload_resources( $expected, $preload_resources ) {
+ public function test_preload_resources( string $expected, array $preload_resources ) {
$callback = static function () use ( $preload_resources ) {
return $preload_resources;
};
@@ -22,11 +25,11 @@ public function test_preload_resources( $expected, $preload_resources ) {
$actual = get_echo( 'wp_preload_resources' );
remove_filter( 'wp_preload_resources', $callback );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
}
/**
- * Test provider for all preload link possible combinations.
+ * Data provider for {@see self::test_preload_resources()}.
*
* @return array[]
*/
@@ -286,4 +289,90 @@ public static function data_preload_resources() {
),
);
}
+
+ /**
+ * Tests that boolean attribute values and list items are only printed when valueless.
+ *
+ * @ticket 66435
+ *
+ * @dataProvider data_boolean_attributes
+ *
+ * @param string $expected Expected markup.
+ * @param array $preload_resource Resource to preload.
+ */
+ public function test_boolean_attributes( string $expected, array $preload_resource ) {
+ add_filter(
+ 'wp_preload_resources',
+ static function () use ( $preload_resource ): array {
+ return array( $preload_resource );
+ }
+ );
+
+ $this->assertEqualHTML( $expected, get_echo( 'wp_preload_resources' ) );
+ }
+
+ /**
+ * Data provider for {@see self::test_boolean_attributes()}.
+ *
+ * @return array }>
+ */
+ public static function data_boolean_attributes(): array {
+ return array(
+ 'crossorigin true' => array(
+ 'expected' => "\n",
+ 'preload_resource' => array(
+ 'href' => 'https://example.com/font.woff2',
+ 'as' => 'font',
+ 'crossorigin' => true,
+ ),
+ ),
+ 'crossorigin false' => array(
+ 'expected' => "\n",
+ 'preload_resource' => array(
+ 'href' => 'https://example.com/font.woff2',
+ 'as' => 'font',
+ 'crossorigin' => false,
+ ),
+ ),
+ 'non-boolean attribute true' => array(
+ 'expected' => "\n",
+ 'preload_resource' => array(
+ 'href' => 'https://example.com/main.js',
+ 'as' => 'script',
+ 'type' => true,
+ ),
+ ),
+ 'unsupported list item' => array(
+ 'expected' => "\n",
+ 'preload_resource' => array(
+ 'href' => 'https://example.com/main.js',
+ 'as' => 'script',
+ 'async',
+ ),
+ ),
+ 'non-string list item' => array(
+ 'expected' => "\n",
+ 'preload_resource' => array(
+ 'href' => 'https://example.com/main.js',
+ 'as' => 'script',
+ true,
+ ),
+ ),
+ 'escaped attribute value' => array(
+ 'expected' => "\n",
+ 'preload_resource' => array(
+ 'href' => 'https://example.com/style.css',
+ 'as' => 'style',
+ 'media' => 'screen and (min-width: 600px)" onload="alert(1)',
+ ),
+ ),
+ 'unsafe href protocol removed' => array(
+ 'expected' => "\n",
+ 'preload_resource' => array(
+ 'href' => 'javascript:alert(1)',
+ 'as' => 'script',
+ ),
+ ),
+ );
+ }
}
diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php
index 3b546dc6e254e..0309fb80f50e8 100644
--- a/tests/phpunit/tests/general/wpResourceHints.php
+++ b/tests/phpunit/tests/general/wpResourceHints.php
@@ -41,7 +41,7 @@ public function test_dns_prefetching() {
remove_filter( 'wp_resource_hints', array( $this, 'add_dns_prefetch_domains' ) );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
}
public function add_dns_prefetch_domains( $hints, $method ) {
@@ -72,7 +72,7 @@ public function test_preconnect() {
remove_filter( 'wp_resource_hints', array( $this, 'add_preconnect_domains' ) );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
}
public function add_preconnect_domains( $hints, $method ) {
@@ -98,7 +98,7 @@ public function test_prerender() {
remove_filter( 'wp_resource_hints', array( $this, 'add_prerender_urls' ) );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
}
public function add_prerender_urls( $hints, $method ) {
@@ -121,7 +121,7 @@ public function test_parse_url_dns_prefetch() {
remove_filter( 'wp_resource_hints', array( $this, 'add_dns_prefetch_long_urls' ) );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
}
public function add_dns_prefetch_long_urls( $hints, $method ) {
@@ -146,7 +146,7 @@ public function test_dns_prefetch_styles() {
wp_dequeue_style( 'googlefonts' );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
}
public function test_dns_prefetch_scripts() {
@@ -163,7 +163,7 @@ public function test_dns_prefetch_scripts() {
wp_dequeue_style( 'googlefonts' );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
}
/**
@@ -179,7 +179,7 @@ public function test_dns_prefetch_scripts_does_not_include_registered_only() {
wp_deregister_script( 'jquery-elsewhere' );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
$this->assertStringNotContainsString( $unexpected, $actual );
}
@@ -193,7 +193,7 @@ public function test_deregistered_scripts_are_ignored() {
wp_deregister_script( 'test-script' );
$actual = get_echo( 'wp_resource_hints' );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
}
/**
@@ -206,13 +206,13 @@ public function test_malformed_urls() {
add_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_errant_colon' ), 10, 2 );
$actual = get_echo( 'wp_resource_hints' );
remove_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_errant_colon' ) );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
// Unsupported Scheme.
add_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_unsupported_scheme' ), 10, 2 );
$actual = get_echo( 'wp_resource_hints' );
remove_filter( 'wp_resource_hints', array( $this, 'add_malformed_url_unsupported_scheme' ) );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
}
public function add_malformed_url_errant_colon( $hints, $method ) {
@@ -233,10 +233,11 @@ public function add_malformed_url_unsupported_scheme( $hints, $method ) {
/**
* @ticket 38121
+ * @ticket 66435
*/
public function test_custom_attributes() {
$expected = "\n" .
- "\n" .
+ "\n" .
"\n" .
"\n";
@@ -246,7 +247,7 @@ public function test_custom_attributes() {
remove_filter( 'wp_resource_hints', array( $this, 'add_url_with_attributes' ) );
- $this->assertSame( $expected, $actual );
+ $this->assertEqualHTML( $expected, $actual );
}
public function add_url_with_attributes( $hints, $method ) {
@@ -265,7 +266,7 @@ public function add_url_with_attributes( $hints, $method ) {
$hints[] = array(
'crossorigin',
'as' => 'image',
- 'pr' => 0.5,
+ 'pr' => 0.5, // Obsolete attribute which should be ignored.
'href' => 'https://example.com/foo.jpeg',
);
$hints[] = array(
@@ -284,4 +285,132 @@ public function add_url_with_attributes( $hints, $method ) {
return $hints;
}
+
+ /**
+ * Tests that link attributes allowed on resource hints are printed, and that
+ * those the HTML spec restricts to other link types are not.
+ *
+ * @ticket 66435
+ */
+ public function test_spec_attributes() {
+ add_filter(
+ 'wp_resource_hints',
+ static function ( array $hints, string $relation_type ): array {
+ if ( 'prefetch' === $relation_type ) {
+ $hints[] = array(
+ 'href' => 'https://example.com/foo.html',
+ 'as' => 'document',
+ 'fetchpriority' => 'low',
+ 'hreflang' => 'en',
+ 'media' => '(min-width: 600px)',
+ 'referrerpolicy' => 'no-referrer',
+ 'title' => 'Foo',
+ 'type' => 'text/html',
+ 'blocking' => 'render',
+ 'color' => '#000',
+ 'imagesizes' => '100vw',
+ 'imagesrcset' => 'https://example.com/foo.jpeg 1x',
+ 'integrity' => 'sha384-foo',
+ 'sizes' => 'any',
+ 'disabled',
+ );
+ }
+ return $hints;
+ },
+ 10,
+ 2
+ );
+
+ $this->assertEqualHTML(
+ "\n",
+ get_echo( 'wp_resource_hints' )
+ );
+ }
+
+ /**
+ * Tests that boolean values only print attributes that may be valueless.
+ *
+ * @ticket 66435
+ *
+ * @dataProvider data_boolean_attributes
+ *
+ * @param array $hint Resource hint attributes.
+ * @param string $expected Expected markup.
+ */
+ public function test_boolean_attributes( array $hint, string $expected ) {
+ add_filter(
+ 'wp_resource_hints',
+ static function ( array $hints, string $relation_type ) use ( $hint ): array {
+ if ( 'prefetch' === $relation_type ) {
+ $hints[] = $hint;
+ }
+ return $hints;
+ },
+ 10,
+ 2
+ );
+
+ $this->assertEqualHTML( $expected, get_echo( 'wp_resource_hints' ) );
+ }
+
+ /**
+ * Data provider for {@see self::test_boolean_attributes()}.
+ *
+ * @return array, expected: string }>
+ */
+ public function data_boolean_attributes(): array {
+ $href = 'https://example.com/foo.css';
+
+ return array(
+ 'crossorigin as true' => array(
+ 'hint' => array(
+ 'crossorigin' => true,
+ 'href' => $href,
+ ),
+ 'expected' => "\n",
+ ),
+ 'crossorigin as list item' => array(
+ 'hint' => array(
+ 'crossorigin',
+ 'href' => $href,
+ ),
+ 'expected' => "\n",
+ ),
+ 'crossorigin as false' => array(
+ 'hint' => array(
+ 'crossorigin' => false,
+ 'href' => $href,
+ ),
+ 'expected' => "\n",
+ ),
+ 'non-boolean as true' => array(
+ 'hint' => array(
+ 'as' => true,
+ 'href' => $href,
+ ),
+ 'expected' => "\n",
+ ),
+ 'non-boolean as list item' => array(
+ 'hint' => array(
+ 'as',
+ 'href' => $href,
+ ),
+ 'expected' => "\n",
+ ),
+ 'unknown list item' => array(
+ 'hint' => array(
+ 'foo',
+ 'href' => $href,
+ ),
+ 'expected' => "\n",
+ ),
+ 'non-string list item' => array(
+ 'hint' => array(
+ 1.5,
+ 'href' => $href,
+ ),
+ 'expected' => "\n",
+ ),
+ );
+ }
}