From e7764ce7b771d6877ee68de7ea2f435b129da050 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 09:39:28 -0800 Subject: [PATCH 01/14] Stop printing the obsolete pr attribute in resource hints The `pr` (hint probability) attribute was only ever part of the W3C Resource Hints working drafts. No browser implemented it, and it was removed from that spec in 2018. The HTML Living Standard, which now defines resource hints, has no such attribute on ``, so printing it only produces invalid markup. Remove `pr` from the allowed attributes and from the `wp_resource_hints` filter documentation. The test keeps passing `pr` to assert that it is now dropped. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 4 ++-- tests/phpunit/tests/general/wpResourceHints.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 5d6786db99bc8..6005a94b97320 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3954,6 +3954,7 @@ 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. * * @param array $urls { * Array of resources and their attributes, or URLs to print for resource hints. @@ -3965,7 +3966,6 @@ function wp_resource_hints() { * @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). * } * } @@ -4022,7 +4022,7 @@ function wp_resource_hints() { foreach ( $atts as $attr => $value ) { if ( ! is_scalar( $value ) - || ( ! in_array( $attr, array( 'as', 'crossorigin', 'href', 'pr', 'rel', 'type' ), true ) && ! is_numeric( $attr ) ) + || ( ! in_array( $attr, array( 'as', 'crossorigin', 'href', 'rel', 'type' ), true ) && ! is_numeric( $attr ) ) ) { continue; diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 3b546dc6e254e..9043b9b367d09 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -236,7 +236,7 @@ public function add_malformed_url_unsupported_scheme( $hints, $method ) { */ public function test_custom_attributes() { $expected = "\n" . - "\n" . + "\n" . "\n" . "\n"; @@ -265,7 +265,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( From c413f6128745c1b5234722eae07d1ad8afa1613e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 09:44:07 -0800 Subject: [PATCH 02/14] Map allowed resource hint attributes to whether they can be valueless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the inline attribute allowlist in `wp_resource_hints()` up into an `$allowed_attributes` map, keyed by attribute name, whose value says whether the attribute may be printed without a value. Of the currently allowed attributes, only `crossorigin` can be. This tightens how valueless attributes are handled: * `'crossorigin' => true` now prints a bare `crossorigin` instead of `crossorigin='1'`, and `false` omits it instead of printing `crossorigin=''`. * A list item, as in `array( 'crossorigin', 'href' => … )`, is now printed only when it names an allowed attribute which may be valueless. Previously any string list item was printed as a bare attribute, which let arbitrary attribute names past the allowlist. * `true` or `false` given for an attribute that requires a value is skipped rather than printed as `'1'` or `''`. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 59 +++++++++---- .../phpunit/tests/general/wpResourceHints.php | 85 +++++++++++++++++++ 2 files changed, 129 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 6005a94b97320..eb32b2cb0dccd 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3945,6 +3945,18 @@ 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`. + */ + $allowed_attributes = array( + 'as' => false, + 'crossorigin' => true, + 'href' => false, + 'rel' => false, + 'type' => false, + ); + foreach ( $hints as $relation_type => $urls ) { $unique_urls = array(); @@ -3954,19 +3966,23 @@ 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. + * @since 7.3.0 The obsolete `pr` attribute is no longer printed. The `crossorigin` + * attribute may be `true` to print it without a value. * * @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 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 $type Type of the resource (`text/html`, `text/css`, etc). * } * } * @param string $relation_type The relation type the URLs are printed for. One of @@ -4021,20 +4037,33 @@ function wp_resource_hints() { $html = ''; foreach ( $atts as $attr => $value ) { - if ( ! is_scalar( $value ) - || ( ! in_array( $attr, array( 'as', 'crossorigin', 'href', '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_bool( $value ) ) { + if ( $value && $allowed_attributes[ $attr ] ) { + $html .= " $attr"; + } + continue; + } - if ( ! is_string( $attr ) ) { - $html .= " $value"; - } else { - $html .= " $attr='$value'"; + if ( ! is_scalar( $value ) ) { + continue; } + + $value = ( 'href' === $attr ) ? esc_url( (string) $value ) : esc_attr( (string) $value ); + + $html .= " $attr='$value'"; } $html = trim( $html ); diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 9043b9b367d09..0d772d0b1c03c 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -284,4 +284,89 @@ public function add_url_with_attributes( $hints, $method ) { return $hints; } + + /** + * Tests that boolean values only print attributes that may be valueless. + * + * @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->assertSame( $expected, get_echo( 'wp_resource_hints' ) ); + } + + /** + * Data provider. + * + * @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", + ), + ); + } } From dc433d7b692cacb706cabf6fb2fc2f96dbfee48e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 09:51:20 -0800 Subject: [PATCH 03/14] Allow more spec-defined link attributes on resource hints Add `fetchpriority`, `hreflang`, `media`, `referrerpolicy`, and `title` to the attributes `wp_resource_hints()` prints. They are the remaining attributes of the HTML link element which the spec allows with the dns-prefetch, preconnect, prefetch, and prerender link types. Of these, `fetchpriority` is the closest successor to the removed `pr` attribute, as it lets a hint signal how important its fetch is. The other link attributes stay disallowed, since the spec only permits them with other link types: `blocking` (stylesheet, expect), `color` (mask-icon), `disabled` (stylesheet), `imagesizes` and `imagesrcset` (preload of an image), `integrity` (stylesheet, preload, modulepreload), and `sizes` (icons). Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 43 +++++++++++++------ .../phpunit/tests/general/wpResourceHints.php | 39 +++++++++++++++++ 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index eb32b2cb0dccd..6c02686f8de6f 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3948,13 +3948,22 @@ function wp_resource_hints() { /* * 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, - 'href' => false, - 'rel' => false, - 'type' => false, + '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 ) { @@ -3967,7 +3976,9 @@ function wp_resource_hints() { * @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. + * 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. @@ -3976,13 +3987,19 @@ function wp_resource_hints() { * 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|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 $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 diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 0d772d0b1c03c..7b8ea8408d8e3 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -285,6 +285,45 @@ 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. + */ + 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->assertSame( + "\n", + get_echo( 'wp_resource_hints' ) + ); + } + /** * Tests that boolean values only print attributes that may be valueless. * From db9db3f2963aef7cf28d3e7bc7b0ed081b4db9d4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 13:22:30 -0800 Subject: [PATCH 04/14] Tag the resource hint attribute tests with their ticket Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- tests/phpunit/tests/general/wpResourceHints.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 7b8ea8408d8e3..ccf4760dccf22 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -233,6 +233,7 @@ public function add_malformed_url_unsupported_scheme( $hints, $method ) { /** * @ticket 38121 + * @ticket 66435 */ public function test_custom_attributes() { $expected = "\n" . @@ -288,6 +289,8 @@ public function add_url_with_attributes( $hints, $method ) { /** * 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( @@ -327,6 +330,8 @@ static function ( array $hints, string $relation_type ): array { /** * Tests that boolean values only print attributes that may be valueless. * + * @ticket 66435 + * * @dataProvider data_boolean_attributes * * @param array $hint Resource hint attributes. From 79b7c150dfbc2f48efb446c843b86466a031c0ca Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 21:38:38 -0800 Subject: [PATCH 05/14] Compare resource hint markup with assertEqualHTML The resource hint tests compared the printed `` tags as exact strings, so they also asserted incidental details such as attribute order and quoting style. Use `assertEqualHTML()` so they assert only that the markup is equivalent HTML. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- .../phpunit/tests/general/wpResourceHints.php | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index ccf4760dccf22..29890a0ac2033 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 ) { @@ -247,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 ) { @@ -321,7 +321,7 @@ static function ( array $hints, string $relation_type ): array { 2 ); - $this->assertSame( + $this->assertEqualHTML( "\n", get_echo( 'wp_resource_hints' ) ); @@ -350,7 +350,7 @@ static function ( array $hints, string $relation_type ) use ( $hint ): array { 2 ); - $this->assertSame( $expected, get_echo( 'wp_resource_hints' ) ); + $this->assertEqualHTML( $expected, get_echo( 'wp_resource_hints' ) ); } /** From 33ea5059ee84c7fb7757c4a26bfa77600df9e98c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 1 Nov 2026 22:06:13 -0800 Subject: [PATCH 06/14] Link the boolean attributes data provider to its test Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- tests/phpunit/tests/general/wpResourceHints.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 29890a0ac2033..0309fb80f50e8 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -354,7 +354,7 @@ static function ( array $hints, string $relation_type ) use ( $hint ): array { } /** - * Data provider. + * Data provider for {@see self::test_boolean_attributes()}. * * @return array, expected: string }> */ From 2addb9a910a57c7c377f718440a0799148948502 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Nov 2026 00:17:25 -0700 Subject: [PATCH 07/14] Build resource hint tags with the HTML Tag Processor Constructing the `` markup by hand meant escaping each value and printing valueless attributes manually. `WP_HTML_Tag_Processor::set_attribute()` handles both: it escapes values (running URL attributes like `href` through `esc_url()`), and it prints `true` as a bare attribute. The allowlist still decides which attributes may be printed and which may be valueless. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 6c02686f8de6f..a4a9fd5efd55d 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -4051,7 +4051,8 @@ function wp_resource_hints() { } foreach ( $unique_urls as $atts ) { - $html = ''; + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); foreach ( $atts as $attr => $value ) { // A list item supplies a valueless attribute, as in `array( 'crossorigin', 'href' => … )`. @@ -4068,24 +4069,19 @@ function wp_resource_hints() { } if ( is_bool( $value ) ) { - if ( $value && $allowed_attributes[ $attr ] ) { - $html .= " $attr"; + if ( ! $allowed_attributes[ $attr ] ) { + continue; } + } elseif ( is_scalar( $value ) ) { + $value = (string) $value; + } else { continue; } - if ( ! is_scalar( $value ) ) { - continue; - } - - $value = ( 'href' === $attr ) ? esc_url( (string) $value ) : esc_attr( (string) $value ); - - $html .= " $attr='$value'"; + $processor->set_attribute( $attr, $value ); } - $html = trim( $html ); - - echo "\n"; + echo $processor->get_updated_html() . "\n"; } } } From 016be5fabc74eb596792007e5cd398efdbf62564 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 4 Nov 2026 00:21:29 -0700 Subject: [PATCH 08/14] Build preload tags with the HTML Tag Processor Like `wp_resource_hints()`, `wp_preload_resources()` assembled its `` markup by hand. Build it with `WP_HTML_Tag_Processor` instead, so attribute values are escaped and valueless attributes are printed by `set_attribute()`. The `href` is still limited to the `http` and `https` protocols. The attribute allowlist becomes an `$allowed_attributes` map recording whether each attribute may be valueless, as in `wp_resource_hints()`. This fixes two cases: * `'crossorigin' => true` printed `crossorigin='1'`, and now prints a bare `crossorigin`. * Any list item was printed as a bare attribute, unchecked against the allowlist. Now only `crossorigin` is. The tests now compare markup with `assertEqualHTML()` and cover these cases. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 83 +++++++++++----- .../tests/general/wpPreloadResources.php | 95 ++++++++++++++++++- 2 files changed, 150 insertions(+), 28 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index cebeae2aa6884..321414eeb7bbc 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -4108,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. * } * } */ @@ -4160,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; } @@ -4187,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', + ), + ), + ); + } } From 2743a2494242caf6851e53d9ee6d6af188c33b35 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 7 Nov 2026 19:15:10 -0800 Subject: [PATCH 09/14] Skip preloads whose href is unusable A preload whose `href` was not a string, or whose protocol was stripped by sanitization (such as `javascript:`), was still printed, either with an empty `href` or with none at all. Such a tag preloads nothing. Sanitize the `href` while collecting unique resources instead of while printing them. An unusable `href` is then treated as absent: the resource falls back to its `imagesrcset` when it is an image, and is skipped otherwise. The sanitized URL also becomes the key for removing duplicates, so two spellings of the same URL yield one preload. A non-string `imagesrcset` is likewise rejected instead of being used as an array key. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 22 +++-- .../tests/general/wpPreloadResources.php | 91 ++++++++++++++----- 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 321414eeb7bbc..78b19a3ba7f02 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -4148,17 +4148,29 @@ function wp_preload_resources() { } $attributes = $resource; - if ( isset( $resource['href'] ) ) { - $href = $resource['href']; + + // An href which is not a string or which uses a disallowed protocol is unusable. + $href = ''; + if ( isset( $resource['href'] ) && is_string( $resource['href'] ) ) { + $href = sanitize_url( $resource['href'], array( 'http', 'https' ) ); + } + + if ( '' !== $href ) { if ( isset( $unique_resources[ $href ] ) ) { continue; } + $attributes['href'] = $href; $unique_resources[ $href ] = $attributes; // Media can use imagesrcset and not href. - } elseif ( isset( $resource['as'], $resource['imagesrcset'] ) && 'image' === $resource['as'] ) { + } elseif ( + isset( $resource['as'], $resource['imagesrcset'] ) && + 'image' === $resource['as'] && + is_string( $resource['imagesrcset'] ) + ) { if ( isset( $unique_resources[ $resource['imagesrcset'] ] ) ) { continue; } + unset( $attributes['href'] ); $unique_resources[ $resource['imagesrcset'] ] = $attributes; } else { continue; @@ -4223,10 +4235,6 @@ function wp_preload_resources() { continue; } - if ( 'href' === $resource_key && is_string( $resource_value ) ) { - $resource_value = sanitize_url( $resource_value, array( 'http', 'https' ) ); - } - $processor->set_attribute( $resource_key, $resource_value ); } diff --git a/tests/phpunit/tests/general/wpPreloadResources.php b/tests/phpunit/tests/general/wpPreloadResources.php index cacc03e8f28ac..b9fade626998d 100644 --- a/tests/phpunit/tests/general/wpPreloadResources.php +++ b/tests/phpunit/tests/general/wpPreloadResources.php @@ -35,7 +35,7 @@ public function test_preload_resources( string $expected, array $preload_resourc */ public static function data_preload_resources() { return array( - 'basic_preload' => array( + 'basic_preload' => array( 'expected' => "\n", 'urls' => array( array( @@ -44,7 +44,7 @@ public static function data_preload_resources() { ), ), ), - 'multiple_links' => array( + 'multiple_links' => array( 'expected' => "\n" . "\n", 'urls' => array( @@ -58,7 +58,7 @@ public static function data_preload_resources() { ), ), ), - 'MIME_types' => array( + 'MIME_types' => array( 'expected' => "\n" . "\n" . "\n", @@ -80,7 +80,7 @@ public static function data_preload_resources() { ), ), ), - 'CORS' => array( + 'CORS' => array( 'expected' => "\n" . "\n" . "\n" . @@ -110,7 +110,7 @@ public static function data_preload_resources() { ), ), ), - 'media' => array( + 'media' => array( 'expected' => "\n" . "\n" . "\n" . @@ -157,7 +157,7 @@ public static function data_preload_resources() { ), ), - 'media_extra_attributes' => array( + 'media_extra_attributes' => array( 'expected' => "\n" . "\n" . "\n" . @@ -250,7 +250,7 @@ public static function data_preload_resources() { ), ), ), - 'fetchpriority' => array( + 'fetchpriority' => array( 'expected' => "\n", 'resources' => array( array( @@ -260,7 +260,7 @@ public static function data_preload_resources() { ), ), ), - 'imagesizes_only' => array( + 'imagesizes_only' => array( 'expected' => "\n", 'resources' => array( // No href and no imagesrcset, so there is nothing to preload. @@ -279,7 +279,7 @@ public static function data_preload_resources() { ), ), ), - 'missing_href_and_as' => array( + 'missing_href_and_as' => array( 'expected' => '', 'resources' => array( array( @@ -287,6 +287,60 @@ public static function data_preload_resources() { ), ), ), + 'unsafe_href' => array( + 'expected' => '', + 'resources' => array( + array( + 'href' => 'javascript:alert(1)', + 'as' => 'script', + ), + ), + ), + 'non_string_href' => array( + 'expected' => '', + 'resources' => array( + array( + 'href' => true, + 'as' => 'script', + ), + array( + 'href' => array( 'https://example.com/main.js' ), + 'as' => 'script', + ), + ), + ), + 'unsafe_href_imagesrcset' => array( + 'expected' => "\n", + 'resources' => array( + array( + 'href' => 'javascript:alert(1)', + 'as' => 'image', + 'imagesrcset' => '640.png 640w, 800.png 800w', + ), + ), + ), + 'non_string_imagesrcset' => array( + 'expected' => '', + 'resources' => array( + array( + 'as' => 'image', + 'imagesrcset' => array( '640.png 640w' ), + ), + ), + ), + 'duplicate_sanitized_href' => array( + 'expected' => "\n", + 'resources' => array( + array( + 'href' => 'https://example.com/a b.js', + 'as' => 'script', + ), + array( + 'href' => 'https://example.com/a%20b.js', + 'as' => 'script', + ), + ), + ), ); } @@ -318,7 +372,7 @@ static function () use ( $preload_resource ): array { */ public static function data_boolean_attributes(): array { return array( - 'crossorigin true' => array( + 'crossorigin true' => array( 'expected' => "\n", 'preload_resource' => array( 'href' => 'https://example.com/font.woff2', @@ -326,7 +380,7 @@ public static function data_boolean_attributes(): array { 'crossorigin' => true, ), ), - 'crossorigin false' => array( + 'crossorigin false' => array( 'expected' => "\n", 'preload_resource' => array( 'href' => 'https://example.com/font.woff2', @@ -334,7 +388,7 @@ public static function data_boolean_attributes(): array { 'crossorigin' => false, ), ), - 'non-boolean attribute true' => array( + 'non-boolean attribute true' => array( 'expected' => "\n", 'preload_resource' => array( 'href' => 'https://example.com/main.js', @@ -342,7 +396,7 @@ public static function data_boolean_attributes(): array { 'type' => true, ), ), - 'unsupported list item' => array( + 'unsupported list item' => array( 'expected' => "\n", 'preload_resource' => array( 'href' => 'https://example.com/main.js', @@ -350,7 +404,7 @@ public static function data_boolean_attributes(): array { 'async', ), ), - 'non-string list item' => array( + 'non-string list item' => array( 'expected' => "\n", 'preload_resource' => array( 'href' => 'https://example.com/main.js', @@ -358,7 +412,7 @@ public static function data_boolean_attributes(): array { true, ), ), - 'escaped attribute value' => array( + 'escaped attribute value' => array( 'expected' => "\n", 'preload_resource' => array( 'href' => 'https://example.com/style.css', @@ -366,13 +420,6 @@ public static function data_boolean_attributes(): array { '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', - ), - ), ); } } From 6111cec79eadcd204e96341137ffc26ac1ffcbc9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 7 Nov 2026 19:15:24 -0800 Subject: [PATCH 10/14] Note the dropped list items in the wp_resource_hints changelog The `@since 7.3.0` note did not mention that list items other than `crossorigin` are no longer printed as valueless attributes. The note on `wp_preload_resources` already says so. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 78b19a3ba7f02..24509d77ce3ff 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3976,7 +3976,8 @@ function wp_resource_hints() { * @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 + * attribute may be `true` to print it without a value. List items + * other than `crossorigin` are no longer printed. Added the * `fetchpriority`, `hreflang`, `media`, `referrerpolicy`, and `title` * attributes. * From 0ef0d796ee095b4cf55c351248b71ed4054a5efb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 7 Nov 2026 19:15:38 -0800 Subject: [PATCH 11/14] Make the resource hints boolean data provider static Newer PHPUnit versions deprecate and then reject non-static data providers, and the matching provider for the preload tests is already static. Also type its keys as `non-falsy-string`. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- tests/phpunit/tests/general/wpResourceHints.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 0309fb80f50e8..2dc72a3ca4770 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -356,9 +356,9 @@ static function ( array $hints, string $relation_type ) use ( $hint ): array { /** * Data provider for {@see self::test_boolean_attributes()}. * - * @return array, expected: string }> + * @return array, expected: string }> */ - public function data_boolean_attributes(): array { + public static function data_boolean_attributes(): array { $href = 'https://example.com/foo.css'; return array( From dde14f8bd18f99c8ded61c3f173b204cf6e4b607 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 7 Nov 2026 19:16:30 -0800 Subject: [PATCH 12/14] Keep the first of duplicate link attributes An attribute can be supplied twice, as in `array( 'crossorigin', 'crossorigin' => 'use-credentials' )`. The hand-built markup printed both, and HTML applies the first. With the HTML API, each `set_attribute()` call overwrites the previous one, so the last would win. Skip an attribute the tag already has, as `wp_get_inline_script_tag()` does, so the first one still wins. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 14 +++++++--- .../tests/general/wpPreloadResources.php | 27 +++++++++++++++++++ .../phpunit/tests/general/wpResourceHints.php | 24 +++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 24509d77ce3ff..6c87d98e6cda0 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -4065,7 +4065,11 @@ function wp_resource_hints() { $value = true; } - if ( ! isset( $allowed_attributes[ $attr ] ) ) { + /* + * An attribute may be supplied twice, as a list item and as a key. As in HTML, + * the first one wins, since the HTML API would otherwise apply the last one. + */ + if ( ! isset( $allowed_attributes[ $attr ] ) || null !== $processor->get_attribute( $attr ) ) { continue; } @@ -4209,8 +4213,12 @@ function wp_preload_resources() { $resource_value = true; } - // Ignore non-supported attributes. - if ( ! isset( $allowed_attributes[ $resource_key ] ) ) { + /* + * Ignore non-supported attributes. An attribute may also be supplied twice, as a list + * item and as a key. As in HTML, the first one wins, since the HTML API would + * otherwise apply the last one. + */ + if ( ! isset( $allowed_attributes[ $resource_key ] ) || null !== $processor->get_attribute( $resource_key ) ) { continue; } diff --git a/tests/phpunit/tests/general/wpPreloadResources.php b/tests/phpunit/tests/general/wpPreloadResources.php index b9fade626998d..82f28b0b56f9f 100644 --- a/tests/phpunit/tests/general/wpPreloadResources.php +++ b/tests/phpunit/tests/general/wpPreloadResources.php @@ -420,6 +420,33 @@ public static function data_boolean_attributes(): array { 'media' => 'screen and (min-width: 600px)" onload="alert(1)', ), ), + 'list item before value' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'crossorigin', + 'crossorigin' => 'use-credentials', + ), + ), + 'value before list item' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'crossorigin' => 'use-credentials', + 'crossorigin', + ), + ), + 'repeated list item' => array( + 'expected' => "\n", + 'preload_resource' => array( + 'href' => 'https://example.com/font.woff2', + 'as' => 'font', + 'crossorigin', + 'crossorigin', + ), + ), ); } } diff --git a/tests/phpunit/tests/general/wpResourceHints.php b/tests/phpunit/tests/general/wpResourceHints.php index 2dc72a3ca4770..eef101382bb14 100644 --- a/tests/phpunit/tests/general/wpResourceHints.php +++ b/tests/phpunit/tests/general/wpResourceHints.php @@ -411,6 +411,30 @@ public static function data_boolean_attributes(): array { ), 'expected' => "\n", ), + 'list item before value' => array( + 'hint' => array( + 'crossorigin', + 'crossorigin' => 'use-credentials', + 'href' => $href, + ), + 'expected' => "\n", + ), + 'value before list item' => array( + 'hint' => array( + 'crossorigin' => 'use-credentials', + 'crossorigin', + 'href' => $href, + ), + 'expected' => "\n", + ), + 'repeated list item' => array( + 'hint' => array( + 'crossorigin', + 'crossorigin', + 'href' => $href, + ), + 'expected' => "\n", + ), ); } } From 8c189b8b4a9447c6137b73b5c4d584102274e84d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 7 Nov 2026 19:16:44 -0800 Subject: [PATCH 13/14] Start preload tags with their rel attribute The `rel` is fixed and cannot be supplied by the filter, so include it in the initial HTML instead of setting it through the HTML API. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 6c87d98e6cda0..37d469ae28f7b 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -4199,9 +4199,8 @@ function wp_preload_resources() { // Build and output the HTML for each unique resource. foreach ( $unique_resources as $unique_resource ) { - $processor = new WP_HTML_Tag_Processor( '' ); + $processor = new WP_HTML_Tag_Processor( '' ); $processor->next_tag(); - $processor->set_attribute( 'rel', 'preload' ); foreach ( $unique_resource as $resource_key => $resource_value ) { // A list item supplies a valueless attribute, as in `array( 'crossorigin', 'href' => … )`. From 3ece5939c8976b44656749ae91960bbc99950596 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 7 Nov 2026 19:16:59 -0800 Subject: [PATCH 14/14] Clarify what the allowed link attribute maps mean Spell out that a key's presence is what allows an attribute, and that a `true` value allows it to also be printed as a boolean attribute. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Session: https://claude.ai/code/session_01Tnky3GYbMqxozZLnF3PvQp Claude-Code-Session-Id: 7f6d7eea-e6bc-4b05-9181-cad948a21000 --- src/wp-includes/general-template.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 37d469ae28f7b..f352e2bfeb7e1 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -3946,8 +3946,10 @@ function wp_resource_hints() { ); /* - * 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`. + * Attributes allowed on a resource hint. Any attribute not listed as a key is + * dropped. A value of `true` means the attribute may also be printed as a + * boolean (valueless) attribute, when it is supplied as `true` or as a list + * item. Attributes with a value of `false` are printed only with a value. * * Other attributes of the link element are omitted because the HTML spec only * allows them with other link types: blocking, color, disabled, imagesizes, @@ -4183,8 +4185,10 @@ 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`. + * Attributes allowed on a preload. Any attribute not listed as a key is + * dropped. A value of `true` means the attribute may also be printed as a + * boolean (valueless) attribute, when it is supplied as `true` or as a list + * item. Attributes with a value of `false` are printed only with a value. */ $allowed_attributes = array( 'as' => false,