From 8877a2159f5bbd5c5f0d688a163adf7e05644726 Mon Sep 17 00:00:00 2001 From: Huub <50170696+huubl@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:55:21 +0200 Subject: [PATCH] Add stylesheet link tag API Build external stylesheet links with WP_HTML_Tag_Processor and expose wp_style_attributes. Migrate Core stylesheet link emitters while preserving legacy filters and add API and integration coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/wp-admin/includes/ajax-actions.php | 14 +- src/wp-content/themes/twentyeleven/header.php | 16 +- src/wp-content/themes/twentyten/header.php | 16 +- src/wp-includes/class-wp-styles.php | 40 ++- src/wp-includes/general-template.php | 25 +- src/wp-includes/script-loader.php | 78 ++++- src/wp-includes/theme-compat/header.php | 10 +- src/wp-includes/theme.php | 9 +- tests/phpunit/tests/dependencies/styles.php | 294 ++++++++++++++++-- .../dependencies/wpStylesheetLinkTag.php | 136 ++++++++ 10 files changed, 579 insertions(+), 59 deletions(-) create mode 100644 tests/phpunit/tests/dependencies/wpStylesheetLinkTag.php diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index cbf412ce4c634..7f66f13eb7ded 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -3894,7 +3894,12 @@ function wp_ajax_parse_embed() { $mce_styles = wpview_media_sandbox_styles(); foreach ( $mce_styles as $style ) { - $styles .= sprintf( '', $style ); + $styles .= wp_get_stylesheet_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => $style, + ) + ); } $html = do_shortcode( $parsed ); @@ -4001,7 +4006,12 @@ function wp_ajax_parse_media_shortcode() { $styles = wpview_media_sandbox_styles(); foreach ( $styles as $style ) { - $head .= ''; + $head .= wp_get_stylesheet_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => $style, + ) + ); } if ( ! empty( $wp_scripts ) ) { diff --git a/src/wp-content/themes/twentyeleven/header.php b/src/wp-content/themes/twentyeleven/header.php index 2fb3093edfec9..433ddbc954cfd 100644 --- a/src/wp-content/themes/twentyeleven/header.php +++ b/src/wp-content/themes/twentyeleven/header.php @@ -43,7 +43,21 @@ ?> - + 'stylesheet', + 'media' => 'all', + 'href' => get_stylesheet_uri() . '?ver=20260819', + ) + ); +} else { + ?> + + - + 'stylesheet', + 'media' => 'all', + 'href' => get_stylesheet_uri() . '?ver=20260819', + ) + ); +} else { + ?> + + _css_href( $src, $obj->ver, $handle ); + $raw_href = $this->get_src( $handle ); + $href = esc_url( $raw_href ); if ( ! $href ) { return true; } @@ -201,14 +202,18 @@ 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 ) + $attributes = array( + 'rel' => $rel, + 'id' => "{$handle}-css", ); + if ( $title ) { + $attributes['title'] = $title; + } + $attributes['href'] = $raw_href; + $attributes['media'] = $media; + + // Preserve the single-quoted format expected by style_loader_tag callbacks. + $tag = str_replace( '"', "'", wp_get_stylesheet_link_tag( $attributes ) ); /** * Filters the HTML link tag of an enqueued style. @@ -227,15 +232,18 @@ 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( + 'rel' => $rel, + 'id' => "{$handle}-rtl-css", ); + if ( $title ) { + $rtl_attributes['title'] = $title; + } + $rtl_attributes['href'] = $rtl_src; + $rtl_attributes['media'] = $media; + + $rtl_tag = str_replace( '"', "'", wp_get_stylesheet_link_tag( $rtl_attributes ) ); /** This filter is documented in wp-includes/class-wp-styles.php */ $rtl_tag = apply_filters( 'style_loader_tag', $rtl_tag, $handle, $rtl_href, $media ); diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php index 5d6786db99bc8..d7fb613691979 100644 --- a/src/wp-includes/general-template.php +++ b/src/wp-includes/general-template.php @@ -5400,9 +5400,16 @@ function wp_admin_css( $file = 'wp-admin', $force_echo = false ) { return; } - $stylesheet_link = sprintf( - "\n", - esc_url( wp_admin_css_uri( $file ) ) + // Preserve the historical markup format passed to the wp_admin_css filter. + $stylesheet_link = str_replace( + '"', + "'", + wp_get_stylesheet_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => wp_admin_css_uri( $file ), + ) + ) ); /** @@ -5419,9 +5426,15 @@ 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 = str_replace( + '"', + "'", + wp_get_stylesheet_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => wp_admin_css_uri( "$file-rtl" ), + ) + ) ); /** 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 cd1d53a5fb93f..a120c2a8eb784 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2450,7 +2450,18 @@ function _print_styles() { } $href = $wp_styles->base_url . "/wp-admin/load-styles.php?c={$zip}&dir={$dir}" . $concatenated . '&ver=' . $ver; - echo "\n"; + // Preserve the concatenated link's historical single-quoted markup. + echo str_replace( + '"', + "'", + wp_get_stylesheet_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => $href, + 'media' => 'all', + ) + ) + ); if ( ! empty( $wp_styles->print_code ) ) { $processor = new WP_HTML_Tag_Processor( '' ); @@ -3672,6 +3683,71 @@ function wp_print_inline_script_tag( $data, $attributes = array() ) { echo wp_get_inline_script_tag( $data, $attributes ); } +/** + * Formats `` tags. + * + * 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. Values of + * `true` or `null` add a bare attribute, `false` omits it, and + * strings (including empty strings) are serialized as values. + * @return string String containing a `` tag. + * + * @phpstan-return non-falsy-string + */ +function wp_get_stylesheet_link_tag( $attributes ) { + /** + * Filters attributes to be added to a stylesheet link tag. + * + * @since 7.3.0 + * + * @param array $attributes Key-value pairs representing `` tag attributes. + * Values of `true` or `null` add a bare attribute; `false` + * omits the attribute. + */ + $attributes = apply_filters( 'wp_style_attributes', $attributes ); + + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + $seen_attributes = array(); + foreach ( $attributes as $name => $value ) { + /* + * 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. + */ + $normalized_name = strtolower( $name ); + if ( isset( $seen_attributes[ $normalized_name ] ) ) { + continue; + } + $seen_attributes[ $normalized_name ] = true; + + if ( null !== $processor->get_attribute( $name ) ) { + continue; + } + + $processor->set_attribute( $name, $value ?? true ); + } + + return "{$processor->get_updated_html()}\n"; +} + +/** + * 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. Values of + * `true` or `null` add a bare attribute, `false` omits it, and + * strings (including empty strings) are serialized as values. + */ +function wp_print_stylesheet_link_tag( $attributes ) { + echo wp_get_stylesheet_link_tag( $attributes ); +} + /** * Allows small styles to be inlined. * diff --git a/src/wp-includes/theme-compat/header.php b/src/wp-includes/theme-compat/header.php index 340477fb94f1e..82896f25b5b42 100644 --- a/src/wp-includes/theme-compat/header.php +++ b/src/wp-includes/theme-compat/header.php @@ -23,7 +23,15 @@ <?php echo wp_get_document_title(); ?> - + 'stylesheet', + 'href' => get_bloginfo( 'stylesheet_url', 'display' ), + 'media' => 'screen', + ) +); +?> diff --git a/src/wp-includes/theme.php b/src/wp-includes/theme.php index d81616c6c552f..db1ad1b786d6e 100644 --- a/src/wp-includes/theme.php +++ b/src/wp-includes/theme.php @@ -733,9 +733,12 @@ function locale_stylesheet() { return; } - printf( - '', - $stylesheet + wp_print_stylesheet_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 6f394d8c78697..19c8f5ae40d7b 100644 --- a/tests/phpunit/tests/dependencies/styles.php +++ b/tests/phpunit/tests/dependencies/styles.php @@ -82,6 +82,254 @@ public function test_wp_enqueue_style() { $this->assertSame( '', get_echo( 'wp_print_styles' ) ); } + /** + * @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'; + $filtered_attrs = array(); + $legacy_filter_args = array(); + + $attributes_filter = static function ( $attributes ) use ( &$filtered_attrs ) { + $filtered_attrs[] = $attributes; + $attributes['nonce'] = 'style-nonce'; + return $attributes; + }; + + $legacy_filter = static function ( $tag, $handle, $href, $media ) use ( &$legacy_filter_args ) { + $processor = new WP_HTML_Tag_Processor( $tag ); + $processor->next_tag( 'link' ); + + $legacy_filter_args[] = array( + 'handle' => $handle, + 'href' => $href, + 'media' => $media, + 'nonce' => $processor->get_attribute( 'nonce' ), + 'tag' => $tag, + ); + + return $tag; + }; + + add_filter( 'wp_style_attributes', $attributes_filter ); + add_filter( 'style_loader_tag', $legacy_filter, 10, 4 ); + + wp_enqueue_style( 'filtered-style', $source, array(), null, 'screen' ); + $markup = get_echo( 'wp_print_styles' ); + + remove_filter( 'wp_style_attributes', $attributes_filter ); + remove_filter( 'style_loader_tag', $legacy_filter, 10 ); + + $this->assertCount( 1, $filtered_attrs ); + $this->assertSame( $source, $filtered_attrs[0]['href'] ); + $this->assertSame( + array( + 'handle' => 'filtered-style', + 'href' => esc_url( $source ), + 'media' => 'screen', + 'nonce' => 'style-nonce', + ), + array_diff_key( $legacy_filter_args[0], array( 'tag' => true ) ) + ); + $this->assertStringNotContainsString( '"', $legacy_filter_args[0]['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' ) ); + } + + /** + * @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\""; + $filter = static function ( $attributes ) use ( $attribute_value ) { + $attributes['data-quote-test'] = $attribute_value; + return $attributes; + }; + + add_filter( 'wp_style_attributes', $filter ); + + wp_enqueue_style( 'quoted-style', 'https://example.com/style.css', array(), null ); + $markup = get_echo( 'wp_print_styles' ); + + remove_filter( 'wp_style_attributes', $filter ); + + $processor = new WP_HTML_Tag_Processor( $markup ); + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertSame( $attribute_value, $processor->get_attribute( 'data-quote-test' ) ); + $this->assertNull( $processor->get_attribute( 'data-injected' ) ); + } + + /** + * @covers WP_Styles::do_item + */ + public function test_rtl_alternate_styles_use_both_attribute_and_legacy_filters() { + global $wp_styles; + + $wp_styles->text_direction = 'rtl'; + + $filtered_attrs = array(); + $legacy_hrefs = array(); + + $attributes_filter = static function ( $attributes ) use ( &$filtered_attrs ) { + $filtered_attrs[] = $attributes; + $attributes['data-filter'] = 'applied'; + return $attributes; + }; + + $legacy_filter = static function ( $tag, $handle, $href, $media ) use ( &$legacy_hrefs ) { + $legacy_hrefs[] = $href; + return $tag; + }; + + add_filter( 'wp_style_attributes', $attributes_filter ); + add_filter( 'style_loader_tag', $legacy_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 ); + + $markup = get_echo( 'wp_print_styles' ); + + remove_filter( 'wp_style_attributes', $attributes_filter ); + remove_filter( 'style_loader_tag', $legacy_filter, 10 ); + + $tags = array(); + $processor = new WP_HTML_Tag_Processor( $markup ); + while ( $processor->next_tag( 'link' ) ) { + $tags[] = array( + 'id' => $processor->get_attribute( 'id' ), + 'rel' => $processor->get_attribute( 'rel' ), + 'title' => $processor->get_attribute( 'title' ), + 'href' => $processor->get_attribute( 'href' ), + 'data-filter' => $processor->get_attribute( 'data-filter' ), + ); + } + + $this->assertCount( 2, $filtered_attrs ); + $this->assertCount( 2, $legacy_hrefs ); + $this->assertSame( 'alternate stylesheet', $tags[0]['rel'] ); + $this->assertSame( 'theme-style-css', $tags[0]['id'] ); + $this->assertSame( 'Alternate theme', $tags[0]['title'] ); + $this->assertSame( 'https://example.com/theme.css', $tags[0]['href'] ); + $this->assertSame( 'theme-style-rtl-css', $tags[1]['id'] ); + $this->assertSame( 'https://example.com/theme-rtl.css', $tags[1]['href'] ); + $this->assertSame( 'applied', $tags[0]['data-filter'] ); + $this->assertSame( 'applied', $tags[1]['data-filter'] ); + $this->assertSame( esc_url( 'https://example.com/theme.css' ), $legacy_hrefs[0] ); + $this->assertSame( esc_url( 'https://example.com/theme-rtl.css' ), $legacy_hrefs[1] ); + } + + /** + * @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/' ); + + $attributes_filter_calls = 0; + $legacy_filter_calls = 0; + + $attributes_filter = static function ( $attributes ) use ( &$attributes_filter_calls ) { + ++$attributes_filter_calls; + $attributes['nonce'] = 'concatenated-style'; + return $attributes; + }; + + $legacy_filter = static function ( $tag ) use ( &$legacy_filter_calls ) { + ++$legacy_filter_calls; + return $tag; + }; + + add_filter( 'wp_style_attributes', $attributes_filter ); + add_filter( 'style_loader_tag', $legacy_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' ); + + remove_filter( 'wp_style_attributes', $attributes_filter ); + remove_filter( 'style_loader_tag', $legacy_filter ); + + $this->assertSame( 1, $attributes_filter_calls ); + $this->assertSame( 0, $legacy_filter_calls ); + + $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' ) ); + } + + /** + * @covers ::wp_admin_css + */ + public function test_wp_admin_css_uses_style_attributes_and_legacy_filter() { + $legacy_files = array(); + $legacy_tags = array(); + $filter = static function ( $attributes ) { + $attributes['nonce'] = 'admin-style'; + return $attributes; + }; + $legacy_filter = static function ( $tag, $file ) use ( &$legacy_files, &$legacy_tags ) { + $legacy_files[] = $file; + $legacy_tags[] = $tag; + $processor = new WP_HTML_Tag_Processor( $tag ); + $processor->next_tag( 'link' ); + if ( 'admin-style' !== $processor->get_attribute( 'nonce' ) ) { + return ''; + } + return $tag; + }; + + add_filter( 'wp_style_attributes', $filter ); + add_filter( 'wp_admin_css', $legacy_filter, 10, 2 ); + + $markup = get_echo( 'wp_admin_css', array( 'stylesheet-link-api-test' ) ); + + remove_filter( 'wp_style_attributes', $filter ); + remove_filter( 'wp_admin_css', $legacy_filter, 10 ); + + $this->assertSame( array( 'stylesheet-link-api-test' ), $legacy_files ); + $this->assertStringNotContainsString( '"', $legacy_tags[0] ); + $processor = new WP_HTML_Tag_Processor( $markup ); + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertSame( 'admin-style', $processor->get_attribute( 'nonce' ) ); + $this->assertStringContainsString( 'stylesheet-link-api-test.css', $processor->get_attribute( 'href' ) ); + } + + /** + * @covers ::locale_stylesheet + */ + public function test_locale_stylesheet_uses_style_attributes_filter() { + $stylesheet_uri_filter = static function ( $stylesheet_uri, $stylesheet_dir_uri ) { + return 'https://example.com/locale.css?one=1&two=2'; + }; + $attributes_filter = static function ( $attributes ) { + $attributes['nonce'] = 'locale-style'; + return $attributes; + }; + + add_filter( 'locale_stylesheet_uri', $stylesheet_uri_filter, 10, 2 ); + add_filter( 'wp_style_attributes', $attributes_filter ); + + $markup = get_echo( 'locale_stylesheet' ); + + remove_filter( 'locale_stylesheet_uri', $stylesheet_uri_filter, 10 ); + remove_filter( 'wp_style_attributes', $attributes_filter ); + + $processor = new WP_HTML_Tag_Processor( $markup ); + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertSame( 'locale-style', $processor->get_attribute( 'nonce' ) ); + $this->assertSame( 'https://example.com/locale.css?one=1&two=2', $processor->get_attribute( 'href' ) ); + } + /** * @ticket 42804 */ @@ -109,9 +357,13 @@ public function test_wp_enqueue_style_with_html5_support_does_not_contain_type_a public function test_awkward_handles_are_supported_consistently( $handle ) { wp_enqueue_style( $handle, 'example.com', array(), null ); - $expected = "\n"; + $processor = new WP_HTML_Tag_Processor( get_echo( 'wp_print_styles' ) ); - $this->assertEqualHTML( $expected, get_echo( 'wp_print_styles' ) ); + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertSame( 'stylesheet', $processor->get_attribute( 'rel' ) ); + $this->assertSame( $handle . '-css', $processor->get_attribute( 'id' ) ); + $this->assertSame( 'http://example.com', $processor->get_attribute( 'href' ) ); + $this->assertSame( 'all', $processor->get_attribute( 'media' ) ); } /** @@ -412,37 +664,23 @@ public function test_wp_add_inline_style_for_handle_without_source() { * @ticket 35921 * @dataProvider data_styles_with_media */ - public function test_wp_enqueue_style_with_media( $expected, $media ) { + public function test_wp_enqueue_style_with_media( $media ) { wp_enqueue_style( 'handle', 'http://example.com', array(), 1, $media ); - $this->assertStringContainsString( $expected, get_echo( 'wp_print_styles' ) ); + + $processor = new WP_HTML_Tag_Processor( get_echo( 'wp_print_styles' ) ); + + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertSame( $media, $processor->get_attribute( 'media' ) ); } public function data_styles_with_media() { return array( - array( - "media='all'", - 'all', - ), - array( - "media='(orientation: portrait)'", - '(orientation: portrait)', - ), - array( - "media='(max-width: 640px)'", - '(max-width: 640px)', - ), - array( - "media='print and (min-width: 25cm)'", - 'print and (min-width: 25cm)', - ), - array( - "media='screen and (color), projection and (color)'", - 'screen and (color), projection and (color)', - ), - array( - "media='not screen and (color)'", - 'not screen and (color)', - ), + array( 'all' ), + array( '(orientation: portrait)' ), + array( '(max-width: 640px)' ), + array( 'print and (min-width: 25cm)' ), + array( 'screen and (color), projection and (color)' ), + array( 'not screen and (color)' ), ); } diff --git a/tests/phpunit/tests/dependencies/wpStylesheetLinkTag.php b/tests/phpunit/tests/dependencies/wpStylesheetLinkTag.php new file mode 100644 index 0000000000000..6194d443f29ef --- /dev/null +++ b/tests/phpunit/tests/dependencies/wpStylesheetLinkTag.php @@ -0,0 +1,136 @@ + + +HTML; + + $this->assertEqualHTML( + $expected, + wp_get_stylesheet_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => 'https://example.com/style.css?one=1&two=2', + 'integrity' => 'sha384-example', + 'crossorigin' => true, + ) + ) + ); + } + + /** + * @covers ::wp_get_stylesheet_link_tag + */ + public function test_get_stylesheet_link_tag_attribute_value_types() { + $expected = <<<'HTML' + + +HTML; + + $this->assertEqualHTML( + $expected, + wp_get_stylesheet_link_tag( + array( + 'enabled' => true, + 'disabled' => false, + 'null-value' => null, + 'empty-value' => '', + 'zero-string' => '0', + ) + ) + ); + } + + /** + * Case-insensitive duplicate attributes should keep the first value. + * + * @covers ::wp_get_stylesheet_link_tag + */ + public function test_get_stylesheet_link_tag_ignores_duplicate_attributes() { + $this->assertEqualHTML( + "\n", + wp_get_stylesheet_link_tag( + array( + 'test' => 'first', + 'TEST' => 'second', + 'disabled' => false, + 'DISABLED' => true, + ) + ) + ); + } + + /** + * @covers ::wp_get_stylesheet_link_tag + */ + public function test_get_stylesheet_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_stylesheet_link_tag( array( 'rel' => 'stylesheet' ) ); + + remove_filter( 'wp_style_attributes', $filter ); + + $this->assertEqualHTML( + "\n", + $tag + ); + } + + /** + * @covers ::wp_get_stylesheet_link_tag + */ + public function test_get_stylesheet_link_tag_rejects_disallowed_href_protocols() { + $tag = wp_get_stylesheet_link_tag( + array( + 'rel' => 'stylesheet', + 'href' => 'javascript:alert(1)', + ) + ); + $processor = new WP_HTML_Tag_Processor( $tag ); + + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertNull( $processor->get_attribute( 'href' ) ); + } + + /** + * @covers ::wp_print_stylesheet_link_tag + */ + public function test_print_stylesheet_link_tag_prints_get_stylesheet_link_tag() { + $attributes = array( + 'rel' => 'stylesheet', + 'href' => 'https://example.com/style.css', + ); + + $this->assertSame( + wp_get_stylesheet_link_tag( $attributes ), + get_echo( 'wp_print_stylesheet_link_tag', array( $attributes ) ) + ); + } +}