From da52cb2e4d07b80eed69f7a752b6f5972adc6c53 Mon Sep 17 00:00:00 2001
From: huubl <50170696+huubl@users.noreply.github.com>
Date: Mon, 19 Oct 2026 20:33:21 +0200
Subject: [PATCH] Script Loader: Add an API for stylesheet LINK tags.
Introduce `wp_get_style_link_tag()` and `wp_print_style_link_tag()`,
which build a `` tag with `WP_HTML_Tag_Processor`,
and the `wp_style_attributes` filter, so that attributes such as a CSP
`nonce`, `integrity` or `crossorigin` can be added to stylesheet links
without editing HTML strings. This complements `wp_get_script_tag()` and
the `wp_script_attributes` filter, and `wp_get_inline_style_tag()` for
inline STYLE tags.
`WP_Styles::do_item()`, including right-to-left and alternate
stylesheets, the concatenated stylesheet printed by `_print_styles()`,
`wp_admin_css()` and `locale_stylesheet()` now print their LINK tags
with the API.
Backward compatibility:
- The `style_loader_tag` and `wp_admin_css` filters are kept, and the
markup they are passed is unchanged: the values Core supplies are
still escaped with `esc_attr()` or `esc_url()`, single-quoted and in
the same order, since callbacks have long edited that markup as a
string. Attributes added by the `wp_style_attributes` filter are
printed after them.
- `style_loader_tag` is still passed the escaped URL from before the
`wp_style_attributes` filter, and each URL is still escaped, and
passed to the `clean_url` filter, once.
- In the markup passed to `style_loader_tag` and `wp_admin_css`, a
quote returned by a filtered escaping function, such as a
`clean_url` callback, is encoded, so it can no longer end an
attribute value early. Like `wp_get_script_tag()`,
`wp_get_style_link_tag()` prints the `href` as the Tag Processor
escapes it.
- A supplied `id` cannot be changed or removed by the filter, since
code locates LINK tags by it, such as the color scheme picker on the
user profile screen with `colors-css`. A `_doing_it_wrong()` notice
naming the function that printed the tag is issued if the filter
tries.
- `WP_Styles::do_item()` no longer calls `WP_Styles::_css_href()`, so
overriding that method in a subclass no longer changes the printed
URL.
- The URL printed by `locale_stylesheet()` is now escaped.
Attributes are printed in the order they are given. An attribute that
the Tag Processor rejects, such as a URL with a disallowed protocol, is
left out rather than printed empty.
See #66342, #51325.
---
src/wp-includes/class-wp-styles.php | 47 +-
src/wp-includes/general-template.php | 20 +-
src/wp-includes/script-loader.php | 232 +++++++-
src/wp-includes/theme.php | 11 +-
tests/phpunit/tests/dependencies/styles.php | 545 ++++++++++++++++++
.../tests/dependencies/wpStyleLinkTag.php | 308 ++++++++++
6 files changed, 1137 insertions(+), 26 deletions(-)
create mode 100644 tests/phpunit/tests/dependencies/wpStyleLinkTag.php
diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php
index 32fc8878b6044..513fd8731ec0e 100644
--- a/src/wp-includes/class-wp-styles.php
+++ b/src/wp-includes/class-wp-styles.php
@@ -126,6 +126,8 @@ public function __construct() {
*
* @since 2.6.0
* @since 5.5.0 Added the `$group` parameter.
+ * @since 7.3.0 The attributes of the stylesheet link tags can be filtered with
+ * {@see 'wp_style_attributes'}.
*
* @see WP_Dependencies::do_item()
*
@@ -193,7 +195,8 @@ public function do_item( $handle, $group = false ) {
return true;
}
- $href = $this->_css_href( $src, $obj->ver, $handle );
+ $raw_href = $this->get_src( $handle );
+ $href = esc_url( $raw_href );
if ( ! $href ) {
return true;
}
@@ -201,14 +204,22 @@ public function do_item( $handle, $group = false ) {
$rel = isset( $obj->extra['alt'] ) && $obj->extra['alt'] ? 'alternate stylesheet' : 'stylesheet';
$title = $obj->extra['title'] ?? '';
- $tag = sprintf(
- "\n",
- $rel,
- esc_attr( $handle ),
- $title ? sprintf( " title='%s'", esc_attr( $title ) ) : '',
- $href,
- esc_attr( $media )
+ /*
+ * The title and media are cast to strings, as esc_attr() did when the tag was built as a
+ * string, so that a value such as `true` is still printed as `'1'`, and an array passed by
+ * mistake as `'Array'`, rather than as a boolean attribute or a fatal error.
+ */
+ $attributes = array(
+ 'rel' => $rel,
+ 'id' => "{$handle}-css",
);
+ if ( $title ) {
+ $attributes['title'] = (string) $title;
+ }
+ $attributes['href'] = $raw_href;
+ $attributes['media'] = (string) $media;
+
+ $tag = _wp_get_legacy_style_link_tag( $attributes, __METHOD__, array( 'href' => $href ) );
/**
* Filters the HTML link tag of an enqueued style.
@@ -227,16 +238,17 @@ public function do_item( $handle, $group = false ) {
$rtl_src = $this->get_rtl_src( $handle );
if ( null !== $rtl_src ) {
- $rtl_href = esc_url( $rtl_src );
- $rtl_tag = sprintf(
- "\n",
- $rel,
- esc_attr( $handle ),
- $title ? sprintf( " title='%s'", esc_attr( $title ) ) : '',
- $rtl_href,
- esc_attr( $media )
+ $rtl_href = esc_url( $rtl_src );
+ $rtl_attributes = array_merge(
+ $attributes,
+ array(
+ 'id' => "{$handle}-rtl-css",
+ 'href' => $rtl_src,
+ )
);
+ $rtl_tag = _wp_get_legacy_style_link_tag( $rtl_attributes, __METHOD__, array( 'href' => $rtl_href ) );
+
/** This filter is documented in wp-includes/class-wp-styles.php */
$rtl_tag = apply_filters( 'style_loader_tag', $rtl_tag, $handle, $rtl_href, $media );
@@ -440,6 +452,9 @@ public function all_deps( $handles, $recursion = false, $group = false ) {
* Generates an enqueued style's fully-qualified URL.
*
* @since 2.6.0
+ * @since 7.3.0 No longer used by WP_Styles::do_item(), which escapes the URL from
+ * WP_Styles::get_src() instead, so overriding this method in a subclass
+ * no longer changes the printed URL.
*
* @param string $src The source of the enqueued style.
* @param string|false|null $ver The version of the enqueued style.
diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php
index 5d6786db99bc8..313be7b770318 100644
--- a/src/wp-includes/general-template.php
+++ b/src/wp-includes/general-template.php
@@ -5380,6 +5380,8 @@ function wp_admin_css_uri( $file = 'wp-admin' ) {
* stylesheet link to that generated URL is printed.
*
* @since 2.3.0
+ * @since 7.3.0 The attributes of the printed stylesheet link tags can be filtered with
+ * {@see 'wp_style_attributes'}.
*
* @param string $file Optional. Style handle name or file name (without ".css" extension) relative
* to wp-admin/. Defaults to 'wp-admin'.
@@ -5400,9 +5402,12 @@ function wp_admin_css( $file = 'wp-admin', $force_echo = false ) {
return;
}
- $stylesheet_link = sprintf(
- "\n",
- esc_url( wp_admin_css_uri( $file ) )
+ $stylesheet_link = _wp_get_legacy_style_link_tag(
+ array(
+ 'rel' => 'stylesheet',
+ 'href' => wp_admin_css_uri( $file ),
+ ),
+ __FUNCTION__
);
/**
@@ -5419,9 +5424,12 @@ function wp_admin_css( $file = 'wp-admin', $force_echo = false ) {
echo apply_filters( 'wp_admin_css', $stylesheet_link, $file );
if ( function_exists( 'is_rtl' ) && is_rtl() ) {
- $rtl_stylesheet_link = sprintf(
- "\n",
- esc_url( wp_admin_css_uri( "$file-rtl" ) )
+ $rtl_stylesheet_link = _wp_get_legacy_style_link_tag(
+ array(
+ 'rel' => 'stylesheet',
+ 'href' => wp_admin_css_uri( "$file-rtl" ),
+ ),
+ __FUNCTION__
);
/** This filter is documented in wp-includes/general-template.php */
diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php
index 6bd1b22c6d3ad..4be23a612ccb2 100644
--- a/src/wp-includes/script-loader.php
+++ b/src/wp-includes/script-loader.php
@@ -2411,6 +2411,8 @@ function print_late_styles() {
*
* @ignore
* @since 3.3.0
+ * @since 7.3.0 The attributes of the concatenated stylesheet link tag can be filtered with
+ * {@see 'wp_style_attributes'}.
*/
function _print_styles() {
$wp_styles = wp_styles();
@@ -2430,7 +2432,15 @@ function _print_styles() {
}
$href = $wp_styles->base_url . "/wp-admin/load-styles.php?dir={$dir}" . $concatenated . '&ver=' . $ver;
- echo "\n";
+ echo _wp_get_legacy_style_link_tag(
+ array(
+ 'rel' => 'stylesheet',
+ 'href' => $href,
+ 'media' => 'all',
+ ),
+ __FUNCTION__,
+ array( 'href' => esc_attr( $href ) )
+ );
if ( ! empty( $wp_styles->print_code ) ) {
$processor = new WP_HTML_Tag_Processor( '' );
@@ -3650,6 +3660,226 @@ function wp_print_inline_script_tag( $data, $attributes = array() ) {
echo wp_get_inline_script_tag( $data, $attributes );
}
+/**
+ * Formats a `` tag.
+ *
+ * It is possible to inject attributes in the stylesheet link tag via the {@see 'wp_style_attributes'} filter.
+ *
+ * Attributes are printed in the order they are given, so `rel` can be printed first, as is conventional.
+ *
+ * @since 7.3.0
+ *
+ * @param array $attributes Key-value pairs representing `` tag attributes. Only
+ * the attribute name is added for entries whose value is `true`
+ * or `null`, as for wp_get_script_tag(), and entries whose value
+ * is `false` are omitted.
+ * @return string String containing a `` tag.
+ *
+ * @phpstan-param array $attributes
+ * @phpstan-return non-falsy-string
+ */
+function wp_get_style_link_tag( array $attributes ): string {
+ return _wp_build_style_link_tag( _wp_filter_style_link_attributes( $attributes, __FUNCTION__ ) );
+}
+
+/**
+ * Prints a formatted stylesheet link tag.
+ *
+ * It is possible to inject attributes in the stylesheet link tag via the {@see 'wp_style_attributes'} filter.
+ *
+ * @since 7.3.0
+ *
+ * @param array $attributes Key-value pairs representing `` tag attributes. Only
+ * the attribute name is added for entries whose value is `true`
+ * or `null`, as for wp_get_script_tag(), and entries whose value
+ * is `false` are omitted.
+ *
+ * @phpstan-param array $attributes
+ */
+function wp_print_style_link_tag( array $attributes ): void {
+ echo wp_get_style_link_tag( $attributes );
+}
+
+/**
+ * Formats a stylesheet link tag in the markup Core printed before wp_get_style_link_tag().
+ *
+ * Callbacks for filters such as {@see 'style_loader_tag'} and {@see 'wp_admin_css'} have long
+ * edited that markup as a string, so the values supplied here are printed as they were before:
+ * escaped with esc_url() for the `href` attribute and with esc_attr() otherwise, and single-quoted.
+ * A value which the {@see 'wp_style_attributes'} filter adds or changes is escaped by the Tag
+ * Processor instead.
+ *
+ * @since 7.3.0
+ * @access private
+ *
+ * @param array $attributes Key-value pairs representing `` tag attributes.
+ * @param string $function_name The function printing the tag, named in the notice
+ * issued when the filter changes the `id` attribute.
+ * @param array $escaped_values Optional. Values from `$attributes` which have already
+ * been escaped, keyed by attribute name, such as an `href`
+ * escaped with esc_url() for another use, so that they are
+ * not escaped again. Default empty array.
+ * @return string String containing a `` tag.
+ *
+ * @phpstan-param array $attributes
+ * @phpstan-return non-falsy-string
+ */
+function _wp_get_legacy_style_link_tag( array $attributes, string $function_name, array $escaped_values = array() ): string {
+ $filtered_attributes = _wp_filter_style_link_attributes( $attributes, $function_name );
+
+ $legacy_values = array();
+ foreach ( $attributes as $name => $value ) {
+ if ( ! is_string( $value ) || ! array_key_exists( $name, $filtered_attributes ) || $filtered_attributes[ $name ] !== $value ) {
+ continue;
+ }
+
+ if ( isset( $escaped_values[ $name ] ) ) {
+ $legacy_values[ $name ] = $escaped_values[ $name ];
+ } elseif ( 'href' === $name ) {
+ $legacy_values[ $name ] = esc_url( $value );
+ } else {
+ $legacy_values[ $name ] = esc_attr( $value );
+ }
+ }
+
+ // Attribute values are escaped, so the only double quotes in the tag are those delimiting them.
+ return str_replace( '"', "'", _wp_build_style_link_tag( $filtered_attributes, $legacy_values ) );
+}
+
+/**
+ * Applies the {@see 'wp_style_attributes'} filter to the attributes of a stylesheet link tag.
+ *
+ * @since 7.3.0
+ * @access private
+ *
+ * @param array $attributes Key-value pairs representing `` tag attributes.
+ * @param string $function_name The function printing the tag, named in the notice
+ * issued when the filter changes the `id` attribute.
+ * @return array Filtered attributes.
+ *
+ * @phpstan-param array $attributes
+ */
+function _wp_filter_style_link_attributes( array $attributes, string $function_name ) {
+ /**
+ * Filters attributes to be added to a stylesheet link tag.
+ *
+ * This applies to the stylesheet links printed by Core, including those for enqueued styles,
+ * whose `id` attribute is the style's handle with `-css` appended, or `-rtl-css` for a
+ * right-to-left stylesheet. For an enqueued style the URL passed to the
+ * {@see 'style_loader_tag'} filter is the one from before this filter, even when this
+ * filter changes the `href` attribute.
+ *
+ * Attribute values are not yet escaped. The `href` attribute is escaped with esc_url() when the
+ * tag is printed, which applies the {@see 'clean_url'} filter and omits a URL with a disallowed
+ * protocol, so the printed URL may differ from the one passed to this filter.
+ *
+ * The `id` attribute, when supplied, cannot be changed or removed by this filter, since other
+ * code may locate the `` tag by it. For example, the user profile screen locates the
+ * `colors-css` stylesheet to preview a color scheme.
+ *
+ * @since 7.3.0
+ *
+ * @param array $attributes Key-value pairs representing `` tag attributes.
+ * Only the attribute name is added to the `` tag
+ * for entries whose value is `true` or `null`, and
+ * entries whose value is `false` are omitted.
+ */
+ $filtered_attributes = apply_filters( 'wp_style_attributes', $attributes );
+
+ if ( ! isset( $attributes['id'] ) ) {
+ return $filtered_attributes;
+ }
+
+ if ( ( $filtered_attributes['id'] ?? null ) !== $attributes['id'] ) {
+ _doing_it_wrong(
+ $function_name,
+ sprintf(
+ /* translators: 1: id, 2: wp_style_attributes */
+ __( 'The %1$s attribute cannot be changed by the %2$s filter.' ),
+ 'id',
+ 'wp_style_attributes'
+ ),
+ '7.3.0'
+ );
+ }
+
+ // Retain the supplied ID in place of the first lexical variation of its name, or after the others when removed.
+ $retained_attributes = array();
+ foreach ( $filtered_attributes as $name => $value ) {
+ if ( 'id' !== strtolower( (string) $name ) ) {
+ $retained_attributes[ $name ] = $value;
+ } elseif ( ! isset( $retained_attributes['id'] ) ) {
+ $retained_attributes['id'] = $attributes['id'];
+ }
+ }
+ $retained_attributes['id'] = $attributes['id'];
+
+ return $retained_attributes;
+}
+
+/**
+ * Builds a `` tag from attributes which have already been filtered.
+ *
+ * @since 7.3.0
+ * @access private
+ *
+ * @param array $attributes Key-value pairs representing `` tag attributes.
+ * @param array $escaped_values Optional. Values which have already been escaped, keyed
+ * by attribute name, to print as-is. Default empty array.
+ * @return string String containing a `` tag.
+ *
+ * @phpstan-return non-falsy-string
+ */
+function _wp_build_style_link_tag( $attributes, array $escaped_values = array() ): string {
+ $html = ' $value ) {
+ $name = (string) $name;
+
+ /*
+ * Lexical variations of an attribute name may represent the same attribute in HTML.
+ * Calling code should rewrite an attribute's value rather than sending a duplicate,
+ * so only the first is used, even when its value omits the attribute.
+ */
+ $normalized_name = strtolower( $name );
+ if ( isset( $seen[ $normalized_name ] ) ) {
+ continue;
+ }
+ $seen[ $normalized_name ] = true;
+
+ if ( false === $value ) {
+ continue;
+ }
+
+ if ( isset( $escaped_values[ $name ] ) ) {
+ // Escaping functions are filterable, so a quote must not end the value early.
+ $html .= " {$name}=\"" . strtr(
+ $escaped_values[ $name ],
+ array(
+ '"' => '"',
+ "'" => ''',
+ )
+ ) . '"';
+ continue;
+ }
+
+ /*
+ * The Tag Processor adds new attributes to a tag in alphabetical order, so each attribute
+ * is set on a tag of its own, which escapes and validates it, and then printed in the
+ * order given. A rejected name or value, such as a URL with a disallowed protocol, is
+ * left out.
+ */
+ $processor = new WP_HTML_Tag_Processor( '' );
+ $processor->next_tag();
+ if ( $processor->set_attribute( $name, $value ?? true ) ) {
+ $html .= substr( $processor->get_updated_html(), strlen( '\n";
+}
+
/**
* Allows small styles to be inlined.
*
diff --git a/src/wp-includes/theme.php b/src/wp-includes/theme.php
index d81616c6c552f..4a6d25a5d346a 100644
--- a/src/wp-includes/theme.php
+++ b/src/wp-includes/theme.php
@@ -726,6 +726,8 @@ function get_raw_theme_root( $stylesheet_or_template, $skip_cache = false ) {
* Displays localized stylesheet link element.
*
* @since 2.1.0
+ * @since 7.3.0 The stylesheet URL is escaped, and the attributes of the link tag can be filtered
+ * with {@see 'wp_style_attributes'}.
*/
function locale_stylesheet() {
$stylesheet = get_locale_stylesheet_uri();
@@ -733,9 +735,12 @@ function locale_stylesheet() {
return;
}
- printf(
- '',
- $stylesheet
+ wp_print_style_link_tag(
+ array(
+ 'rel' => 'stylesheet',
+ 'href' => $stylesheet,
+ 'media' => 'screen',
+ )
);
}
diff --git a/tests/phpunit/tests/dependencies/styles.php b/tests/phpunit/tests/dependencies/styles.php
index cd0768ecc5a54..67327046fd93a 100644
--- a/tests/phpunit/tests/dependencies/styles.php
+++ b/tests/phpunit/tests/dependencies/styles.php
@@ -82,6 +82,551 @@ public function test_wp_enqueue_style() {
$this->assertSame( '', get_echo( 'wp_print_styles' ) );
}
+ /**
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ */
+ public function test_style_attributes_filter_runs_before_legacy_style_loader_tag_filter() {
+ $source = 'https://example.com/style.css?one=1&two=2';
+
+ add_filter(
+ 'wp_style_attributes',
+ static function ( array $attributes ): array {
+ $attributes['nonce'] = 'style-nonce';
+ return $attributes;
+ }
+ );
+ $attributes_filter = new MockAction();
+ add_filter( 'wp_style_attributes', array( $attributes_filter, 'filter' ), 20 );
+ $legacy_filter = new MockAction();
+ add_filter( 'style_loader_tag', array( $legacy_filter, 'filter' ), 10, 4 );
+
+ wp_enqueue_style( 'filtered-style', $source, array(), null, 'screen' );
+ $markup = get_echo( 'wp_print_styles' );
+
+ $this->assertSame( 1, $attributes_filter->get_call_count() );
+ $this->assertSame( $source, $attributes_filter->get_args()[0][0]['href'], 'The attributes filter should be passed the unescaped URL.' );
+
+ $this->assertSame( 1, $legacy_filter->get_call_count() );
+ list( $tag, $handle, $href, $media ) = $legacy_filter->get_args()[0];
+ $this->assertSame( 'filtered-style', $handle );
+ $this->assertSame( esc_url( $source ), $href, 'The style_loader_tag filter should be passed the escaped URL.' );
+ $this->assertSame( 'screen', $media );
+ $this->assertSame( $markup, $tag );
+
+ $processor = new WP_HTML_Tag_Processor( $markup );
+ $this->assertTrue( $processor->next_tag( 'link' ) );
+ $this->assertSame( 'style-nonce', $processor->get_attribute( 'nonce' ) );
+ $this->assertSame( $source, $processor->get_attribute( 'href' ) );
+ }
+
+ /**
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ */
+ public function test_style_attribute_quotes_do_not_create_extra_attributes() {
+ $attribute_value = "It's O'Reilly's \"quoted\" text; data-injected=\"should-remain-text\"";
+
+ add_filter(
+ 'wp_style_attributes',
+ static function ( array $attributes ) use ( $attribute_value ): array {
+ $attributes['data-quote-test'] = $attribute_value;
+ return $attributes;
+ }
+ );
+
+ wp_enqueue_style( 'quoted-style', 'https://example.com/style.css', array(), null );
+
+ $processor = new WP_HTML_Tag_Processor( get_echo( 'wp_print_styles' ) );
+ $this->assertTrue( $processor->next_tag( 'link' ) );
+ $this->assertSame( $attribute_value, $processor->get_attribute( 'data-quote-test' ) );
+ $this->assertNull( $processor->get_attribute( 'data-injected' ) );
+ }
+
+ /**
+ * Attributes added by the wp_style_attributes filter are printed after Core's, in the same quotes.
+ *
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ * @covers ::_wp_get_legacy_style_link_tag
+ */
+ public function test_rtl_alternate_styles_use_both_attribute_and_legacy_filters() {
+ global $wp_styles;
+
+ $wp_styles->text_direction = 'rtl';
+
+ add_filter(
+ 'wp_style_attributes',
+ static function ( array $attributes ): array {
+ $attributes['data-filter'] = 'applied';
+ return $attributes;
+ }
+ );
+ $legacy_filter = new MockAction();
+ add_filter( 'style_loader_tag', array( $legacy_filter, 'filter' ), 10, 4 );
+
+ wp_enqueue_style( 'theme-style', 'https://example.com/theme.css', array(), null );
+ wp_style_add_data( 'theme-style', 'alt', true );
+ wp_style_add_data( 'theme-style', 'title', 'Alternate theme' );
+ wp_style_add_data( 'theme-style', 'rtl', true );
+
+ $expected = <<<'HTML'
+
+
+
+HTML;
+
+ $this->assertSame( $expected, get_echo( 'wp_print_styles' ) );
+ $this->assertSame(
+ array( esc_url( 'https://example.com/theme.css' ), esc_url( 'https://example.com/theme-rtl.css' ) ),
+ array_column( $legacy_filter->get_args(), 2 ),
+ 'The style_loader_tag filter should be passed each escaped URL.'
+ );
+ }
+
+ /**
+ * @ticket 66342
+ *
+ * @covers ::_print_styles
+ */
+ public function test_concatenated_styles_apply_style_attributes_once_without_legacy_filter() {
+ global $wp_styles;
+
+ $wp_styles->do_concat = true;
+ $wp_styles->default_dirs = array( '/wp-admin/' );
+
+ add_filter(
+ 'wp_style_attributes',
+ static function ( array $attributes ): array {
+ $attributes['nonce'] = 'concatenated-style';
+ return $attributes;
+ }
+ );
+ $attributes_filter = new MockAction();
+ add_filter( 'wp_style_attributes', array( $attributes_filter, 'filter' ), 20 );
+ $legacy_filter = new MockAction();
+ add_filter( 'style_loader_tag', array( $legacy_filter, 'filter' ) );
+
+ wp_enqueue_style( 'concat-one', '/wp-admin/one.css', array(), null );
+ wp_enqueue_style( 'concat-two', '/wp-admin/two.css', array(), null );
+
+ wp_print_styles();
+ $markup = get_echo( '_print_styles' );
+
+ $this->assertSame( 1, $attributes_filter->get_call_count() );
+ $this->assertSame( 0, $legacy_filter->get_call_count() );
+
+ $processor = new WP_HTML_Tag_Processor( $markup );
+ $this->assertTrue( $processor->next_tag( 'link' ) );
+ $this->assertSame( 'concatenated-style', $processor->get_attribute( 'nonce' ) );
+ $this->assertStringContainsString( 'load-styles.php', $processor->get_attribute( 'href' ) );
+ }
+
+ /**
+ * @ticket 66342
+ *
+ * @covers ::wp_admin_css
+ */
+ public function test_wp_admin_css_uses_style_attributes_and_legacy_filter() {
+ add_filter(
+ 'wp_style_attributes',
+ static function ( array $attributes ): array {
+ $attributes['nonce'] = 'admin-style';
+ return $attributes;
+ }
+ );
+ $legacy_filter = new MockAction();
+ add_filter( 'wp_admin_css', array( $legacy_filter, 'filter' ), 10, 2 );
+
+ $markup = get_echo( 'wp_admin_css', array( 'style-link-api-test' ) );
+
+ $this->assertSame(
+ array( array( $markup, 'style-link-api-test' ) ),
+ $legacy_filter->get_args(),
+ 'The wp_admin_css filter should be passed the tag with the filtered attributes.'
+ );
+
+ $processor = new WP_HTML_Tag_Processor( $markup );
+ $this->assertTrue( $processor->next_tag( 'link' ) );
+ $this->assertSame( 'admin-style', $processor->get_attribute( 'nonce' ) );
+ $this->assertStringContainsString( 'style-link-api-test.css', $processor->get_attribute( 'href' ) );
+ }
+
+ /**
+ * The URL printed by locale_stylesheet() is escaped.
+ *
+ * @ticket 66342
+ *
+ * @covers ::locale_stylesheet
+ */
+ public function test_locale_stylesheet_uses_style_attributes_filter() {
+ add_filter(
+ 'locale_stylesheet_uri',
+ static function (): string {
+ return 'https://example.com/locale.css?one=1&two=2';
+ }
+ );
+ add_filter(
+ 'wp_style_attributes',
+ static function ( array $attributes ): array {
+ $attributes['nonce'] = 'locale-style';
+ return $attributes;
+ }
+ );
+
+ $this->assertSame(
+ "\n",
+ get_echo( 'locale_stylesheet' )
+ );
+ }
+
+ /**
+ * Callbacks for the style_loader_tag filter edit the markup as a string, so it must not change.
+ *
+ * This compares exact strings, rather than equivalent HTML, since the quoting style and the order
+ * of the attributes are what such callbacks rely on.
+ *
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ * @covers ::_wp_get_legacy_style_link_tag
+ */
+ public function test_style_loader_tag_markup_is_unchanged() {
+ global $wp_styles;
+
+ $wp_styles->text_direction = 'rtl';
+
+ $legacy_filter = new MockAction();
+ add_filter( 'style_loader_tag', array( $legacy_filter, 'filter' ) );
+
+ wp_enqueue_style( 'unchanged', 'https://example.com/style.css?a=1&b=2', array(), '1.0', 'screen' );
+ wp_style_add_data( 'unchanged', 'alt', true );
+ wp_style_add_data( 'unchanged', 'title', 'Black & "white"' );
+ wp_style_add_data( 'unchanged', 'rtl', true );
+
+ $markup = get_echo( 'wp_print_styles' );
+
+ $expected = array(
+ "\n",
+ "\n",
+ );
+
+ $this->assertSame( $expected, array_column( $legacy_filter->get_args(), 0 ), 'The style_loader_tag filter should be passed the historical markup.' );
+ $this->assertSame( implode( '', $expected ), $markup, 'The historical markup should be printed.' );
+ }
+
+ /**
+ * Values are escaped with esc_attr() in the markup passed to the style_loader_tag filter, as before.
+ *
+ * Each value is one which esc_attr() escapes differently from the Tag Processor, which builds the
+ * tags printed by wp_get_style_link_tag().
+ *
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ * @covers ::_wp_get_legacy_style_link_tag
+ *
+ * @dataProvider data_style_loader_tag_markup_escapes_values_as_before
+ *
+ * @param string $handle Style handle.
+ * @param string|bool $media The media argument passed to wp_enqueue_style().
+ * @param string|bool $title Title of the style, or false for none.
+ * @param string $expected Expected markup.
+ */
+ public function test_style_loader_tag_markup_escapes_values_as_before( $handle, $media, $title, $expected ) {
+ wp_enqueue_style( $handle, 'https://example.com/style.css', array(), null, $media );
+ if ( false !== $title ) {
+ wp_style_add_data( $handle, 'title', $title );
+ }
+
+ $this->assertSame( $expected, get_echo( 'wp_print_styles' ) );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public static function data_style_loader_tag_markup_escapes_values_as_before(): array {
+ return array(
+ 'apostrophe in title' => array( 'apostrophe', 'all', "Editor's choice", "\n" ),
+ 'character references in title' => array( 'references', 'all', 'L'ancienne & co', "\n" ),
+ 'invalid UTF-8 in title' => array( 'invalid', 'all', "bad\xC3\x28", "\n" ),
+ 'true title' => array( 'true-title', 'all', true, "\n" ),
+ 'apostrophe in handle' => array( "editor's", 'all', false, "\n" ),
+ 'character reference in handle' => array( 'a&b', 'all', false, "\n" ),
+ 'true media' => array( 'true-media', true, false, "\n" ),
+ 'false media' => array( 'false-media', false, false, "\n" ),
+ );
+ }
+
+ /**
+ * A media value which is not a string, such as the `$args` array of wp_enqueue_script() passed to
+ * wp_enqueue_style() by mistake, is printed as before rather than causing a fatal error.
+ *
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ */
+ public function test_array_media_is_printed_as_before() {
+ $errors = array();
+ set_error_handler(
+ static function ( $errno, $errstr ) use ( &$errors ) {
+ $errors[] = $errstr;
+ return true;
+ },
+ E_WARNING | E_NOTICE
+ );
+
+ try {
+ wp_enqueue_style( 'array-media', 'https://example.com/style.css', array(), null, array( 'in_footer' => true ) );
+ $markup = get_echo( 'wp_print_styles' );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame( array( 'Array to string conversion' ), $errors );
+ $this->assertSame( "\n", $markup );
+ }
+
+ /**
+ * A right-to-left URL which escaping empties is printed as an empty `href`, as before, without being escaped again.
+ *
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ * @covers ::_wp_get_legacy_style_link_tag
+ */
+ public function test_rtl_style_url_emptied_by_escaping_is_printed_empty_and_escaped_once() {
+ global $wp_styles;
+
+ $wp_styles->text_direction = 'rtl';
+
+ $escaped_urls = array();
+ add_filter(
+ 'clean_url',
+ static function ( $url ) use ( &$escaped_urls ) {
+ $escaped_urls[] = $url;
+ return str_contains( $url, '-rtl.css' ) ? '' : $url;
+ }
+ );
+
+ wp_enqueue_style( 'blocked', 'https://example.com/style.css', array(), null );
+ wp_style_add_data( 'blocked', 'rtl', true );
+
+ $this->assertSame(
+ "\n" .
+ "\n",
+ get_echo( 'wp_print_styles' )
+ );
+ $this->assertSame(
+ array( 'https://example.com/style.css', 'https://example.com/style-rtl.css' ),
+ $escaped_urls,
+ 'Each URL should be escaped once.'
+ );
+ }
+
+ /**
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ * @covers ::_wp_get_legacy_style_link_tag
+ */
+ public function test_style_attributes_are_printed_after_core_attributes() {
+ $filter = static function ( $attributes ) {
+ $attributes['crossorigin'] = 'anonymous';
+ $attributes['fetchpriority'] = 'high';
+ return $attributes;
+ };
+
+ add_filter( 'wp_style_attributes', $filter );
+
+ wp_enqueue_style( 'ordered', 'https://example.com/style.css', array(), null );
+ $markup = get_echo( 'wp_print_styles' );
+
+ $this->assertSame(
+ "\n",
+ $markup
+ );
+ }
+
+ /**
+ * Scripts such as the user profile screen's color scheme picker locate a stylesheet by its ID.
+ *
+ * The notice names the method which printed the tag.
+ *
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ * @covers ::_wp_filter_style_link_attributes
+ */
+ public function test_style_attributes_filter_cannot_change_enqueued_style_id() {
+ $this->setExpectedIncorrectUsage( 'WP_Styles::do_item' );
+
+ $filter = static function ( $attributes ) {
+ $attributes['id'] = 'changed';
+ return $attributes;
+ };
+
+ add_filter( 'wp_style_attributes', $filter );
+
+ wp_enqueue_style( 'scheme', 'https://example.com/scheme.css', array(), null );
+ $markup = get_echo( 'wp_print_styles' );
+
+ $this->assertSame(
+ "\n",
+ $markup
+ );
+ }
+
+ /**
+ * An `href` changed by the wp_style_attributes filter is escaped, while the style_loader_tag filter
+ * is still passed the URL from before that filter.
+ *
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ * @covers ::_wp_get_legacy_style_link_tag
+ */
+ public function test_style_attributes_filter_can_change_href() {
+ $filter = static function ( $attributes ) {
+ $attributes['href'] = 'https://cdn.example.com/style.css?a=1&b=2"onload="alert(1)';
+ return $attributes;
+ };
+
+ add_filter( 'wp_style_attributes', $filter );
+ $legacy_filter = new MockAction();
+ add_filter( 'style_loader_tag', array( $legacy_filter, 'filter' ), 10, 4 );
+
+ wp_enqueue_style( 'cdn', 'https://example.com/style.css?a=1&b=2', array(), null );
+ $markup = get_echo( 'wp_print_styles' );
+
+ $this->assertSame(
+ "\n",
+ $markup
+ );
+ $this->assertSame(
+ array( 'https://example.com/style.css?a=1&b=2' ),
+ array_column( $legacy_filter->get_args(), 2 ),
+ 'The style_loader_tag filter should be passed the escaped URL from before the wp_style_attributes filter.'
+ );
+ }
+
+ /**
+ * An `href` which the wp_style_attributes filter changes to a URL with a disallowed protocol is
+ * left out, without leaving an extra space in the markup passed to the style_loader_tag filter.
+ *
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ * @covers ::_wp_build_style_link_tag
+ */
+ public function test_style_attributes_filter_changing_href_to_disallowed_protocol_omits_it() {
+ add_filter(
+ 'wp_style_attributes',
+ static function ( array $attributes ): array {
+ $attributes['href'] = 'javascript:alert(1)';
+ return $attributes;
+ }
+ );
+
+ wp_enqueue_style( 'disallowed', 'https://example.com/style.css', array(), null );
+
+ $this->assertSame(
+ "\n",
+ get_echo( 'wp_print_styles' )
+ );
+ }
+
+ /**
+ * A double quote from a filtered escaping function must not end the `href` attribute's value.
+ *
+ * @ticket 66342
+ *
+ * @covers WP_Styles::do_item
+ * @covers ::_wp_get_legacy_style_link_tag
+ */
+ public function test_double_quote_from_clean_url_filter_stays_in_href() {
+ $filter = static function ( $url ) {
+ return $url . '"onload="alert(1)';
+ };
+
+ add_filter( 'clean_url', $filter );
+
+ wp_enqueue_style( 'quoted-url', 'https://example.com/style.css', array(), null );
+ $markup = get_echo( 'wp_print_styles' );
+
+ $this->assertSame(
+ "\n",
+ $markup
+ );
+ }
+
+ /**
+ * A double quote from a filtered escaping function must not end the `href` attribute's value
+ * in the tag printed by wp_admin_css(), whose URL was printed in single quotes before.
+ *
+ * @ticket 66342
+ *
+ * @covers ::wp_admin_css
+ * @covers ::_wp_get_legacy_style_link_tag
+ */
+ public function test_double_quote_from_clean_url_filter_stays_in_wp_admin_css_href() {
+ add_filter(
+ 'clean_url',
+ static function ( $url ) {
+ return $url . '"onload="alert(1)';
+ }
+ );
+
+ $markup = get_echo( 'wp_admin_css', array( 'style-link-api-test' ) );
+
+ $processor = new WP_HTML_Tag_Processor( $markup );
+ $this->assertTrue( $processor->next_tag( 'link' ) );
+ $this->assertNull( $processor->get_attribute( 'onload' ), 'The double quote should not end the href attribute.' );
+ $this->assertStringEndsWith( ""onload="alert(1)' />\n", $markup );
+ }
+
+ /**
+ * @ticket 66342
+ *
+ * @covers ::_print_styles
+ * @covers ::_wp_get_legacy_style_link_tag
+ */
+ public function test_concatenated_styles_markup_is_unchanged() {
+ global $wp_styles;
+
+ $wp_styles->do_concat = true;
+ $wp_styles->default_dirs = array( '/wp-admin/' );
+
+ wp_enqueue_style( 'concat-one', '/wp-admin/one.css', array(), null );
+ wp_enqueue_style( 'concat-two', '/wp-admin/two.css', array(), null );
+
+ wp_print_styles();
+ $markup = get_echo( '_print_styles' );
+
+ $this->assertMatchesRegularExpression(
+ "#^\n$#",
+ $markup
+ );
+ }
+
+ /**
+ * @ticket 66342
+ *
+ * @covers ::wp_admin_css
+ * @covers ::_wp_get_legacy_style_link_tag
+ */
+ public function test_wp_admin_css_markup_is_unchanged() {
+ $this->assertSame(
+ "\n",
+ get_echo( 'wp_admin_css', array( 'style-link-api-test' ) )
+ );
+ }
+
/**
* @ticket 42804
*/
diff --git a/tests/phpunit/tests/dependencies/wpStyleLinkTag.php b/tests/phpunit/tests/dependencies/wpStyleLinkTag.php
new file mode 100644
index 0000000000000..47759e9ebd426
--- /dev/null
+++ b/tests/phpunit/tests/dependencies/wpStyleLinkTag.php
@@ -0,0 +1,308 @@
+assertSame(
+ "\n",
+ wp_get_style_link_tag(
+ array(
+ 'rel' => 'stylesheet',
+ 'href' => 'https://example.com/style.css?one=1&two=2',
+ 'integrity' => 'sha384-example',
+ 'crossorigin' => true,
+ )
+ )
+ );
+ }
+
+ /**
+ * Only the name is printed for a value of `true` or `null`, an empty string is printed as an empty value,
+ * and an attribute whose value is `false` is omitted.
+ *
+ * @ticket 66342
+ *
+ * @covers ::wp_get_style_link_tag
+ * @covers ::_wp_build_style_link_tag
+ */
+ public function test_get_style_link_tag_attribute_value_types() {
+ $this->assertSame(
+ "\n",
+ wp_get_style_link_tag(
+ array(
+ 'enabled' => true,
+ 'disabled' => false,
+ 'null-value' => null,
+ 'empty-value' => '',
+ 'zero-string' => '0',
+ )
+ )
+ );
+ }
+
+ /**
+ * Case-insensitive duplicate attributes should keep the first value.
+ *
+ * @ticket 66342
+ *
+ * @covers ::wp_get_style_link_tag
+ */
+ public function test_get_style_link_tag_ignores_duplicate_attributes() {
+ $this->assertSame(
+ "\n",
+ wp_get_style_link_tag(
+ array(
+ 'test' => 'first',
+ 'TEST' => 'second',
+ 'disabled' => false,
+ 'DISABLED' => true,
+ )
+ )
+ );
+ }
+
+ /**
+ * @ticket 66342
+ *
+ * @covers ::wp_get_style_link_tag
+ */
+ public function test_get_style_link_tag_applies_wp_style_attributes_filter() {
+ $filter = static function ( $attributes ) {
+ $attributes['nonce'] = 'filtered-nonce';
+ return $attributes;
+ };
+
+ add_filter( 'wp_style_attributes', $filter );
+
+ $tag = wp_get_style_link_tag( array( 'rel' => 'stylesheet' ) );
+
+ remove_filter( 'wp_style_attributes', $filter );
+
+ $this->assertSame(
+ "\n",
+ $tag
+ );
+ }
+
+ /**
+ * @ticket 66342
+ *
+ * @covers ::wp_get_style_link_tag
+ */
+ public function test_get_style_link_tag_prints_attributes_in_the_order_given() {
+ $this->assertSame(
+ "\n",
+ wp_get_style_link_tag(
+ array(
+ 'rel' => 'preload',
+ 'as' => 'style',
+ 'id' => 'test-css',
+ 'href' => 'https://example.com/style.css?one=1&two=2',
+ 'media' => 'print',
+ 'crossorigin' => true,
+ )
+ )
+ );
+ }
+
+ /**
+ * @ticket 66342
+ *
+ * @covers ::wp_get_style_link_tag
+ *
+ * @expectedIncorrectUsage WP_HTML_Tag_Processor::set_attribute
+ */
+ public function test_get_style_link_tag_rejects_invalid_attribute_names() {
+ $this->assertSame(
+ "\n",
+ wp_get_style_link_tag(
+ array(
+ 'rel' => 'stylesheet',
+ 'onload="alert(1)"' => true,
+ 'x>