diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index 32fc8878b6044..c494878943525 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -157,16 +157,6 @@ public function do_item( $handle, $group = false ) { $src = $obj->src; $inline_style = $this->print_inline_style( $handle, false ); - if ( $inline_style ) { - $processor = new WP_HTML_Tag_Processor( '' ); - $processor->next_tag(); - $processor->set_attribute( 'id', "{$handle}-inline-css" ); - $processor->set_modifiable_text( "\n{$inline_style}\n" ); - $inline_style_tag = "{$processor->get_updated_html()}\n"; - } else { - $inline_style_tag = ''; - } - if ( $this->do_concat ) { if ( is_string( $src ) && $this->in_default_dir( $src ) && ! isset( $obj->extra['alt'] ) ) { $this->concat .= "$handle,"; @@ -182,7 +172,12 @@ public function do_item( $handle, $group = false ) { // A single item may alias a set of items, by having dependencies, but no source. if ( ! $src ) { - if ( $inline_style_tag ) { + if ( $inline_style ) { + $inline_style_tag = wp_get_inline_style_tag( + $inline_style, + array( 'id' => "{$handle}-inline-css" ) + ); + if ( $this->do_concat ) { $this->print_html .= $inline_style_tag; } else { @@ -249,8 +244,11 @@ public function do_item( $handle, $group = false ) { if ( $this->do_concat ) { $this->print_html .= $tag; - if ( $inline_style_tag ) { - $this->print_html .= $inline_style_tag; + if ( $inline_style ) { + $this->print_html .= wp_get_inline_style_tag( + $inline_style, + array( 'id' => "{$handle}-inline-css" ) + ); } } else { echo $tag; @@ -376,11 +374,10 @@ public function print_inline_style( $handle, $display = true ) { return $output; } - $processor = new WP_HTML_Tag_Processor( '' ); - $processor->next_tag(); - $processor->set_attribute( 'id', "{$handle}-inline-css" ); - $processor->set_modifiable_text( "\n{$output}\n" ); - echo "{$processor->get_updated_html()}\n"; + wp_print_inline_style_tag( + $output, + array( 'id' => "{$handle}-inline-css" ) + ); return true; } diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index dfaaf73ede55a..e4b07847185e3 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2453,12 +2453,9 @@ function _print_styles() { echo "\n"; if ( ! empty( $wp_styles->print_code ) ) { - $processor = new WP_HTML_Tag_Processor( '' ); - $processor->next_tag(); - $style_tag_contents = "\n{$wp_styles->print_code}\n" - . sprintf( "/*# sourceURL=%s */\n", rawurlencode( $concat_source_url ) ); - $processor->set_modifiable_text( $style_tag_contents ); - echo "{$processor->get_updated_html()}\n"; + $style_tag_contents = $wp_styles->print_code . "\n" + . sprintf( '/*# sourceURL=%s */', rawurlencode( $concat_source_url ) ); + wp_print_inline_style_tag( $style_tag_contents ); } } @@ -3672,6 +3669,110 @@ function wp_print_inline_script_tag( $data, $attributes = array() ) { echo wp_get_inline_script_tag( $data, $attributes ); } +/** + * Constructs an inline style tag. + * + * It is possible to inject attributes in the `' ); + $processor->next_tag(); + + /* + * Set the text first so the filter receives the CSS as it will be printed, which may differ from $data after escaping. + * This cannot fail for a STYLE tag, since any closing tag in the CSS is escaped rather than rejected. + */ + $processor->set_modifiable_text( $data ); + + /** + * Filters attributes to be added to an inline style tag. + * + * This filter may run more than once for the same inline style, in which case only the last result is printed. + * For example, the `wp-block-library` inline style is built again by {@see wp_hoist_late_printed_styles()} + * after a placeholder is removed from its CSS. + * + * The `id` attribute, when supplied, cannot be changed or removed by this filter, since other code may locate the + * `' ); - $processor->next_tag(); - $processor->set_modifiable_text( $style ); - echo "{$processor->get_updated_html()}\n"; + wp_print_inline_style_tag( $style ); }, $priority ); @@ -4584,7 +4682,26 @@ public function replace( string $text ): void { if ( '' === trim( $css_text ) ) { $processor->remove(); } else { - $processor->set_modifiable_text( $css_text ); + /* + * Rebuild the STYLE tag rather than only replacing its text, so that the 'wp_inline_style_attributes' + * filter runs again with the CSS as it is now printed. Otherwise, any attribute derived from the CSS, + * such as a hash for a Content Security Policy, would no longer match it. The attributes already on + * the tag are passed along so that they are preserved, while the filter may still override them. The + * trailing newline is removed since the newline which followed the original tag remains in the buffer. + */ + $attributes = array(); + foreach ( $processor->get_attribute_names_with_prefix( '' ) ?? array() as $attribute_name ) { + $attribute_value = $processor->get_attribute( $attribute_name ); + if ( null !== $attribute_value ) { + $attributes[ $attribute_name ] = $attribute_value; + } + } + $processor->replace( + rtrim( + wp_get_inline_style_tag( $css_text, $attributes ), + "\n" + ) + ); } $inserted_after = $printed_core_block_styles; @@ -4597,11 +4714,10 @@ public function replace( string $text ): void { * a sourceURL comment after the placeholder above is removed. */ if ( ! preg_match( ':^\s*(/\*# sourceURL=\S+? \*/\s*)?$:s', $extra_inline_styles ) ) { - $style_processor = new WP_HTML_Tag_Processor( '' ); - $style_processor->next_tag(); - $style_processor->set_attribute( 'id', 'wp-block-library-inline-css-extra' ); - $style_processor->set_modifiable_text( $extra_inline_styles ); - $inserted_after .= "{$style_processor->get_updated_html()}\n"; + $inserted_after .= wp_get_inline_style_tag( + $extra_inline_styles, + array( 'id' => 'wp-block-library-inline-css-extra' ) + ); } if ( '' !== $inserted_after ) { diff --git a/tests/phpunit/tests/dependencies/styles.php b/tests/phpunit/tests/dependencies/styles.php index 6f394d8c78697..51baacd05c627 100644 --- a/tests/phpunit/tests/dependencies/styles.php +++ b/tests/phpunit/tests/dependencies/styles.php @@ -9,23 +9,19 @@ * @covers ::wp_add_inline_style */ class Tests_Dependencies_Styles extends WP_UnitTestCase { - private $old_wp_styles; - private $old_wp_scripts; + private ?WP_Styles $old_wp_styles; + private ?WP_Scripts $old_wp_scripts; + /** + * @global WP_Scripts|null $wp_scripts + * @global WP_Styles|null $wp_styles + */ public function set_up() { + global $wp_scripts, $wp_styles; parent::set_up(); - if ( empty( $GLOBALS['wp_styles'] ) ) { - $GLOBALS['wp_styles'] = null; - } - - $this->old_wp_styles = $GLOBALS['wp_styles']; - - if ( empty( $GLOBALS['wp_scripts'] ) ) { - $GLOBALS['wp_scripts'] = null; - } - - $this->old_wp_styles = $GLOBALS['wp_scripts']; + $this->old_wp_styles = $wp_styles; + $this->old_wp_scripts = $wp_scripts; remove_action( 'wp_default_styles', 'wp_default_styles' ); remove_action( 'wp_print_styles', 'print_emoji_styles' ); @@ -37,9 +33,14 @@ public function set_up() { $GLOBALS['wp_scripts']->default_version = get_bloginfo( 'version' ); } + /** + * @global WP_Scripts|null $wp_scripts + * @global WP_Styles|null $wp_styles + */ public function tear_down() { - $GLOBALS['wp_styles'] = $this->old_wp_styles; - $GLOBALS['wp_scripts'] = $this->old_wp_scripts; + global $wp_scripts, $wp_styles; + $wp_styles = $this->old_wp_styles; + $wp_scripts = $this->old_wp_scripts; add_action( 'wp_default_styles', 'wp_default_styles' ); add_action( 'wp_print_styles', 'print_emoji_styles' ); @@ -225,6 +226,134 @@ public function test_inline_styles_concat() { $this->assertEqualHTML( $expected, $wp_styles->print_html ); } + /** + * Tests that inline style attributes are filtered during normal style output. + * + * @ticket 51325 + */ + public function test_inline_style_attributes_filter() { + $mock_action = new MockAction(); + add_filter( 'wp_inline_style_attributes', array( $mock_action, 'filter' ) ); + add_filter( + 'wp_inline_style_attributes', + static function ( array $attributes ) { + $attributes['nonce'] = 'test-nonce'; + return $attributes; + } + ); + + wp_enqueue_style( 'handle', 'http://example.com', array(), '1' ); + wp_add_inline_style( 'handle', 'body { color: #123456; }' ); + + $expected = <<<'HTML' + + + + HTML; + + $this->assertEqualHTML( $expected, get_echo( 'wp_print_styles' ) ); + $this->assertSame( 1, $mock_action->get_call_count() ); + $this->assertSame( array( 'id' => 'handle-inline-css' ), $mock_action->get_args()[0][0] ); + } + + /** + * Tests that inline style attributes are filtered for a concatenated non-core stylesheet. + * + * @ticket 51325 + */ + public function test_inline_style_attributes_filter_with_non_core_concat() { + wp_styles()->do_concat = true; + wp_styles()->default_dirs = array( '/wp-admin/', '/wp-includes/css/' ); + + add_filter( + 'wp_inline_style_attributes', + static function ( array $attributes ) { + $attributes['nonce'] = 'test-nonce'; + return $attributes; + } + ); + + wp_enqueue_style( 'handle', 'http://example.com', array(), '1' ); + wp_add_inline_style( 'handle', 'body { color: #123456; }' ); + wp_print_styles(); + + $expected = <<<'HTML' + + + + HTML; + + $this->assertEqualHTML( $expected, wp_styles()->print_html ); + } + + /** + * Tests that inline style attributes are filtered for concatenated core styles. + * + * @ticket 51325 + */ + public function test_inline_style_attributes_filter_with_core_concat() { + wp_styles()->do_concat = true; + wp_styles()->default_dirs = array( '/wp-admin/' ); + + add_filter( + 'wp_inline_style_attributes', + static function ( array $attributes ) { + $attributes['nonce'] = 'test-nonce'; + return $attributes; + } + ); + + wp_enqueue_style( 'handle', '/wp-admin/example.css' ); + wp_add_inline_style( 'handle', 'body { color: #123456; }' ); + wp_print_styles(); + + $processor = new WP_HTML_Tag_Processor( get_echo( '_print_styles' ) ); + + $this->assertTrue( $processor->next_tag(), 'Expected the concatenated stylesheet LINK.' ); + $this->assertSame( 'LINK', $processor->get_tag() ); + $this->assertSame( 'stylesheet', $processor->get_attribute( 'rel' ) ); + $href = $processor->get_attribute( 'href' ); + $this->assertIsString( $href ); + $this->assertStringContainsString( '/wp-admin/load-styles.php?', $href ); + + $this->assertTrue( $processor->next_tag(), 'Expected the concatenated inline STYLE.' ); + $this->assertSame( 'STYLE', $processor->get_tag() ); + $this->assertSame( + array( 'nonce' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Expected the filtered nonce to be the only attribute.' + ); + $this->assertSame( 'test-nonce', $processor->get_attribute( 'nonce' ) ); + $this->assertSame( + "\nbody { color: #123456; }\n/*# sourceURL=css-inline-concat-handle */\n", + $processor->get_modifiable_text() + ); + + $this->assertFalse( $processor->next_tag(), 'Expected no other tags.' ); + } + + /** + * Tests that inline style attributes are not filtered when style output is suppressed. + * + * @ticket 51325 + */ + public function test_inline_style_attributes_filter_not_called_when_output_is_suppressed() { + add_filter( 'style_loader_src', '__return_false' ); + $mock_action = new MockAction(); + add_filter( 'wp_inline_style_attributes', array( $mock_action, 'filter' ) ); + + wp_enqueue_style( 'handle', 'http://example.com', array(), '1' ); + wp_add_inline_style( 'handle', 'body { color: #123456; }' ); + + $this->assertSame( '', get_echo( 'wp_print_styles' ) ); + $this->assertSame( 0, $mock_action->get_call_count() ); + } + /** * Test normalizing relative links in CSS. * diff --git a/tests/phpunit/tests/dependencies/wpInlineStyleTag.php b/tests/phpunit/tests/dependencies/wpInlineStyleTag.php new file mode 100644 index 0000000000000..a22a2463803e2 --- /dev/null +++ b/tests/phpunit/tests/dependencies/wpInlineStyleTag.php @@ -0,0 +1,299 @@ + + body { color: #123456; } + + + HTML; + + $this->assertEqualHTML( + $expected, + wp_get_inline_style_tag( + $this->get_test_css(), + array( + 'id' => 'test-inline-css', + 'nonce' => 'test-nonce', + ) + ) + ); + } + + /** + * @ticket 51325 + */ + public function test_print_inline_style_tag_prints_get_inline_style_tag() { + $attributes = array( 'id' => 'test-inline-css' ); + + $this->assertSame( + wp_get_inline_style_tag( $this->get_test_css(), $attributes ), + get_echo( + 'wp_print_inline_style_tag', + array( $this->get_test_css(), $attributes ) + ) + ); + } + + /** + * @ticket 51325 + */ + public function test_inline_style_attributes_filter() { + add_filter( + 'wp_inline_style_attributes', + static function ( array $attributes ) { + $attributes['nonce'] = 'test-nonce'; + return $attributes; + } + ); + + $filter = new MockAction(); + add_filter( 'wp_inline_style_attributes', array( $filter, 'filter' ), 20, 2 ); + + $expected = <<<'HTML' + + + HTML; + + $this->assertEqualHTML( $expected, wp_get_inline_style_tag( $this->get_test_css() ) ); + $this->assertSame( + array( + array( + array( 'nonce' => 'test-nonce' ), + "\n{$this->get_test_css()}\n", + ), + ), + $filter->get_args() + ); + } + + /** + * Data provider. + * + * @return array ): array }> + */ + public function data_inline_style_attributes_filter_changing_id(): array { + return array( + 'changed' => array( + static function ( array $attributes ): array { + $attributes['id'] = 'changed-inline-css'; + return $attributes; + }, + ), + 'removed' => array( + static function ( array $attributes ): array { + unset( $attributes['id'] ); + return $attributes; + }, + ), + 'lexical_variation' => array( + static function ( array $attributes ): array { + unset( $attributes['id'] ); + $attributes['ID'] = 'changed-inline-css'; + return $attributes; + }, + ), + ); + } + + /** + * Tests that the attributes filter cannot change or remove the supplied ID. + * + * @ticket 51325 + * + * @dataProvider data_inline_style_attributes_filter_changing_id + * + * @param Closure $filter Filter callback for the attributes. + */ + public function test_inline_style_attributes_filter_cannot_change_id( Closure $filter ) { + $this->setExpectedIncorrectUsage( 'wp_get_inline_style_tag' ); + + add_filter( 'wp_inline_style_attributes', $filter ); + + $expected = <<<'HTML' + + + HTML; + + $this->assertEqualHTML( + $expected, + wp_get_inline_style_tag( $this->get_test_css(), array( 'id' => 'test-inline-css' ) ) + ); + } + + /** + * Tests that the attributes filter can add an ID when none is supplied. + * + * @ticket 51325 + */ + public function test_inline_style_attributes_filter_can_add_id() { + add_filter( + 'wp_inline_style_attributes', + static function ( array $attributes ): array { + $attributes['id'] = 'added-inline-css'; + return $attributes; + } + ); + + $expected = <<<'HTML' + + + HTML; + + $this->assertEqualHTML( $expected, wp_get_inline_style_tag( $this->get_test_css() ) ); + } + + /** + * Tests that the attributes filter receives the CSS as printed, after any escaping. + * + * This allows a filter to compute a CSP hash which matches the printed STYLE contents. + * + * @ticket 51325 + */ + public function test_inline_style_attributes_filter_receives_escaped_data() { + $filter = new MockAction(); + add_filter( 'wp_inline_style_attributes', array( $filter, 'filter' ), 10, 2 ); + + $processor = new WP_HTML_Tag_Processor( wp_get_inline_style_tag( 'a::after { content: ""; }' ) ); + + $this->assertSame( 1, $filter->get_call_count(), 'Expected the filter to be called once.' ); + $filtered_data = $filter->get_args()[0][1]; + $this->assertIsString( $filtered_data, 'Expected the filtered data to be a string.' ); + $this->assertTrue( $processor->next_tag( 'STYLE' ), 'Expected a STYLE tag.' ); + $this->assertStringNotContainsString( '', $filtered_data, 'Expected the filtered data to be escaped.' ); + $this->assertSame( $processor->get_modifiable_text(), $filtered_data ); + } + + /** + * Test the behavior of generated style tag attributes passed different values and types of values. + * + * @ticket 51325 + */ + public function test_inline_style_tag_attribute_value_types() { + $expected = <<<'HTML' + + + HTML; + + $this->assertEqualHTML( + $expected, + wp_get_inline_style_tag( + $this->get_test_css(), + array( // @phpstan-ignore argument.type + 'true' => true, + 'false' => false, + 'null' => null, + 'empty-string' => '', + '0-string' => '0', + '1-string' => '1', + '0-numeric' => 0, + '1-numeric' => 1, + ) + ) + ); + } + + /** + * Test the behavior of generated style tag repeated attributes. + * + * HTML will ignore case-insensitive repeated attributes. Ensure that the handling of input + * attributes aligns with expectations. + * + * @ticket 51325 + */ + public function test_inline_style_tag_repeat_attributes() { + $expected = <<<'HTML' + + + HTML; + + $this->assertEqualHTML( + $expected, + wp_get_inline_style_tag( + $this->get_test_css(), + array( + 'test' => 'test-a', + 'tesT' => 'tesT-b', + 'teST' => 'teST-c', + 'tEST' => 'tEST-d', + 'TEST' => 'TEST-e', + ) + ) + ); + } + + /** + * Data provider for {@see self::test_inline_style_tag_normalizes_whitespace()}. + * + * @return array + */ + public function data_inline_style_tag_whitespace(): array { + return array( + 'no whitespace' => array( 'a{}', "\na{}\n" ), + 'newlines' => array( "\n\na{}\n\n", "\na{}\n" ), + 'all CSS whitespace' => array( " \t\f\r\na{}\n\r\f\t ", "\na{}\n" ), + 'inner whitespace kept' => array( "\ta{}\n\n\tb{}\t", "\na{}\n\n\tb{}\n" ), + 'only whitespace' => array( " \t\f\r\n", "\n\n" ), + 'empty string' => array( '', "\n\n" ), + 'null from a filter' => array( null, "\n\n" ), + 'non-whitespace preserved' => array( "\u{00A0}a{}\u{00A0}", "\n\u{00A0}a{}\u{00A0}\n" ), + ); + } + + /** + * Tests that leading and trailing CSS whitespace is replaced by a single newline. + * + * @ticket 51325 + * + * @dataProvider data_inline_style_tag_whitespace + * + * @param string|null $data CSS passed to the function. + * @param non-empty-string $expected Expected contents of the STYLE element. + */ + public function test_inline_style_tag_normalizes_whitespace( ?string $data, string $expected ): void { + $processor = new WP_HTML_Tag_Processor( wp_get_inline_style_tag( $data ) ); // @phpstan-ignore argument.type + + $this->assertTrue( $processor->next_tag( 'STYLE' ), 'Expected a STYLE tag.' ); + $this->assertSame( $expected, $processor->get_modifiable_text() ); + } + + /** + * Gets test CSS code. + * + * @return non-falsy-string + */ + private function get_test_css(): string { + return 'body { color: #123456; }'; + } +} diff --git a/tests/phpunit/tests/template.php b/tests/phpunit/tests/template.php index 767d84e7a52a4..f0231e2df437f 100644 --- a/tests/phpunit/tests/template.php +++ b/tests/phpunit/tests/template.php @@ -1599,6 +1599,104 @@ public function data_wp_hoist_late_printed_styles(): array { ), ), + 'inline_style_attributes_filter_adds_hash' => array( + 'set_up' => static function () { + add_filter( + 'wp_inline_style_attributes', + static function ( array $attributes, string $data ): array { + $attributes['data-hash'] = hash( 'sha256', $data ); + return $attributes; + }, + 10, + 2 + ); + }, + 'content' => $blocks_content, + 'inline_size_limit' => PHP_INT_MAX, + 'expected_styles' => array( + 'HEAD' => array_merge( + $early_common_styles, + array( + 'wp-block-library-inline-css', + 'wp-block-separator-inline-css', + 'classic-theme-styles-inline-css', + 'third-party-test-block-css', + 'custom-block-styles-css', + 'global-styles-inline-css', + ), + $common_at_wp_enqueue_scripts, + $common_late_in_head, + $common_late_in_body + ), + 'BODY' => array(), + ), + 'assert' => function ( string $buffer, string $filtered_buffer ) { + $processor = new WP_HTML_Tag_Processor( $filtered_buffer ); + $found = false; + while ( $processor->next_tag( 'STYLE' ) ) { + $hash = $processor->get_attribute( 'data-hash' ); + if ( null === $hash ) { + continue; + } + $id = $processor->get_attribute( 'id' ); + $this->assertSame( hash( 'sha256', $processor->get_modifiable_text() ), $hash, "Expected the hash to match the printed CSS for the STYLE with ID: $id" ); + if ( 'wp-block-library-inline-css' === $id ) { + $found = true; + } + } + $this->assertTrue( $found, 'Expected the hoisted wp-block-library inline style to have a hash.' ); + }, + ), + + 'block_library_inline_style_attributes_preserved' => array( + 'set_up' => static function () { + // Add an attribute to the block library inline style before wp_hoist_late_printed_styles() rebuilds it. + add_filter( + 'wp_template_enhancement_output_buffer', + static function ( string $buffer ): string { + $processor = new WP_HTML_Tag_Processor( $buffer ); + while ( $processor->next_tag( 'STYLE' ) ) { + if ( 'wp-block-library-inline-css' === $processor->get_attribute( 'id' ) ) { + $processor->set_attribute( 'data-preserved', 'yes' ); + } + } + return $processor->get_updated_html(); + }, + 0 + ); + }, + 'content' => $blocks_content, + 'inline_size_limit' => PHP_INT_MAX, + 'expected_styles' => array( + 'HEAD' => array_merge( + $early_common_styles, + array( + 'wp-block-library-inline-css', + 'wp-block-separator-inline-css', + 'classic-theme-styles-inline-css', + 'third-party-test-block-css', + 'custom-block-styles-css', + 'global-styles-inline-css', + ), + $common_at_wp_enqueue_scripts, + $common_late_in_head, + $common_late_in_body + ), + 'BODY' => array(), + ), + 'assert' => function ( string $buffer, string $filtered_buffer ) { + $processor = new WP_HTML_Tag_Processor( $filtered_buffer ); + $found = false; + while ( $processor->next_tag( 'STYLE' ) ) { + if ( 'wp-block-library-inline-css' === $processor->get_attribute( 'id' ) ) { + $found = true; + $this->assertSame( 'yes', $processor->get_attribute( 'data-preserved' ), 'Expected the existing attribute to be preserved.' ); + } + } + $this->assertTrue( $found, 'Expected the wp-block-library inline style to be present.' ); + }, + ), + 'classic_theme_styles_omitted' => array( 'set_up' => static function () { // Note that wp_enqueue_scripts is used instead of enqueue_block_assets because it runs again at the former action. @@ -2089,7 +2187,7 @@ static function () { // Create a simulated output buffer. $buffer = '' . $head_output . '
' . $content . '
' . $footer_output . ''; - $global_styles_placeholder_regexp = '#