diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 3c11927d6787b..7f821d9f7498e 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -1296,8 +1296,32 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n */ protected static function append_to_selector( $selector, $to_append ) { if ( ! str_contains( $selector, ',' ) ) { - return $selector . $to_append; + return trim( $selector, " \t\n" ) . $to_append; } + + /** + * Check for an opportunity to skip the more-costly selector splitting. + * This should be possible if there are no comments, strings, functions, + * URLs, escapes, or comment declaration openers (CDOs). + * + * Note that this means the fast-path will not apply for selectors like + * the following incomplete list: + * + * - `[class ~= "wide"]` + * - `.wp-block:is(.is-style-a, .is-style-b)` + * - `:nth-child(1)` + * + * These syntax forms all present opportunities where a comma may not + * separate selectors. If none of the start characters are present, + * there should be no way for a comma to mean anything other than a + * comma token. The exception are syntax errors, which are not handled here. + * + * @see https://www.w3.org/TR/css-syntax-3/#parse-comma-separated-list-of-component-values + */ + if ( strlen( $selector ) === strcspn( $selector, '/\'"(<\\' ) ) { + return preg_replace( '~[ \t\n]*,[ \t\n]*~', "{$to_append}, ", trim( $selector, " \t\n" ) ) . $to_append; + } + $new_selectors = array(); $selectors = static::split_selector_list( $selector ); foreach ( $selectors as $sel ) { @@ -1321,8 +1345,32 @@ protected static function append_to_selector( $selector, $to_append ) { */ protected static function prepend_to_selector( $selector, $to_prepend ) { if ( ! str_contains( $selector, ',' ) ) { - return $to_prepend . $selector; + return $to_prepend . trim( $selector, " \t\n" ); } + + /** + * Check for an opportunity to skip the more-costly selector splitting. + * This should be possible if there are no comments, strings, functions, + * URLs, escapes, or comment declaration openers (CDOs). + * + * Note that this means the fast-path will not apply for selectors like + * the following incomplete list: + * + * - `[class ~= "wide"]` + * - `.wp-block:is(.is-style-a, .is-style-b)` + * - `:nth-child(1)` + * + * These syntax forms all present opportunities where a comma may not + * separate selectors. If none of the start characters are present, + * there should be no way for a comma to mean anything other than a + * comma token. The exception are syntax errors, which are not handled here. + * + * @see https://www.w3.org/TR/css-syntax-3/#parse-comma-separated-list-of-component-values + */ + if ( strlen( $selector ) === strcspn( $selector, '/\'"(<\\' ) ) { + return $to_prepend . preg_replace( '~[ \t\n]*,[ \t\n]*~', ", {$to_prepend}", trim( $selector, " \t\n" ) ); + } + $new_selectors = array(); $selectors = static::split_selector_list( $selector ); foreach ( $selectors as $sel ) { diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 724c5ff997abb..cbfe1572d8bc3 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -7731,4 +7731,284 @@ public function test_block_custom_states_ignores_non_whitelisted() { $this->assertStringNotContainsString( '-current', $stylesheet_unsupported ); $this->assertStringNotContainsString( 'current-menu-item', $stylesheet_unsupported ); } + + /** + * Tests that append_to_selector correctly appends to single and + * compound (comma-separated) selectors. + * + * @ticket 65533 + * + * @dataProvider data_append_to_selector + * + * @param string $selector Original CSS selector. + * @param string $to_append Selector to append. + * @param string $expected Expected resulting selector. + */ + public function test_append_to_selector( $selector, $to_append, $expected ) { + $theme_json = new ReflectionClass( 'WP_Theme_JSON' ); + + $func = $theme_json->getMethod( 'append_to_selector' ); + if ( PHP_VERSION_ID < 80100 ) { + $func->setAccessible( true ); + } + + $actual = $func->invoke( null, $selector, $to_append ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_append_to_selector() { + return array( + 'single class selector' => array( + 'selector' => '.inner', + 'to_append' => ':hover', + 'expected' => '.inner:hover', + ), + 'single element selector' => array( + 'selector' => 'p', + 'to_append' => ':hover', + 'expected' => 'p:hover', + ), + 'single id selector' => array( + 'selector' => '#main', + 'to_append' => ':focus', + 'expected' => '#main:focus', + ), + 'two comma-separated selectors without spaces' => array( + 'selector' => 'h1,h2', + 'to_append' => ':hover', + 'expected' => 'h1:hover, h2:hover', + ), + 'three comma-separated selectors without spaces' => array( + 'selector' => 'h1,h2,h3', + 'to_append' => ':focus', + 'expected' => 'h1:focus, h2:focus, h3:focus', + ), + 'comma-separated class selectors without spaces' => array( + 'selector' => '.foo,.bar', + 'to_append' => '.is-active', + 'expected' => '.foo.is-active, .bar.is-active', + ), + 'append without leading pseudo' => array( + 'selector' => '.child', + 'to_append' => '.parent', + 'expected' => '.child.parent', + ), + 'compound selector with class append no comma spaces' => array( + 'selector' => '.a,.b', + 'to_append' => '.parent', + 'expected' => '.a.parent, .b.parent', + ), + 'descendant selector appended' => array( + 'selector' => '.block .inner', + 'to_append' => ':hover', + 'expected' => '.block .inner:hover', + ), + 'descendant selectors comma-separated without spaces' => array( + 'selector' => '.block .inner,.block .alt', + 'to_append' => ':hover', + 'expected' => '.block .inner:hover, .block .alt:hover', + ), + 'empty selector' => array( + 'selector' => '', + 'to_append' => ':hover', + 'expected' => ':hover', + ), + 'empty append' => array( + 'selector' => '.child', + 'to_append' => '', + 'expected' => '.child', + ), + 'both empty' => array( + 'selector' => '', + 'to_append' => '', + 'expected' => '', + ), + 'attribute selector' => array( + 'selector' => '[data-type="example"]', + 'to_append' => ':hover', + 'expected' => '[data-type="example"]:hover', + ), + 'pseudo-class selector' => array( + 'selector' => ':where(.is-layout-flex)', + 'to_append' => ':hover', + 'expected' => ':where(.is-layout-flex):hover', + ), + 'many comma-separated selectors without spaces' => array( + 'selector' => 'h1,h2,h3,h4,h5,h6', + 'to_append' => ':hover', + 'expected' => 'h1:hover, h2:hover, h3:hover, h4:hover, h5:hover, h6:hover', + ), + 'real world block element selector' => array( + 'selector' => 'p', + 'to_append' => '.is-style-custom', + 'expected' => 'p.is-style-custom', + ), + 'real world compound block element selectors' => array( + 'selector' => 'a,.wp-element-button', + 'to_append' => '.is-style-custom', + 'expected' => 'a.is-style-custom, .wp-element-button.is-style-custom', + ), + 'spaces after commas are normalized' => array( + 'selector' => 'h1, h2, h3', + 'to_append' => ':hover', + 'expected' => 'h1:hover, h2:hover, h3:hover', + ), + 'spaces after commas normalized with class selectors' => array( + 'selector' => '.foo, .bar', + 'to_append' => '.is-active', + 'expected' => '.foo.is-active, .bar.is-active', + ), + 'mixed whitespace around commas is normalized' => array( + 'selector' => '.a , .b , .c', + 'to_append' => ':hover', + 'expected' => '.a:hover, .b:hover, .c:hover', + ), + ); + } + + /** + * Tests that prepend_to_selector correctly prepends to single and + * compound (comma-separated) selectors. + * + * @ticket 65533 + * + * @dataProvider data_prepend_to_selector + * + * @param string $selector Original CSS selector. + * @param string $to_prepend Selector to prepend. + * @param string $expected Expected resulting selector. + */ + public function test_prepend_to_selector( $selector, $to_prepend, $expected ) { + $theme_json = new ReflectionClass( 'WP_Theme_JSON' ); + + $func = $theme_json->getMethod( 'prepend_to_selector' ); + if ( PHP_VERSION_ID < 80100 ) { + $func->setAccessible( true ); + } + + $actual = $func->invoke( null, $selector, $to_prepend ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_prepend_to_selector() { + return array( + 'single class selector' => array( + 'selector' => '.inner', + 'to_prepend' => '.wrapper ', + 'expected' => '.wrapper .inner', + ), + 'single element selector' => array( + 'selector' => 'p', + 'to_prepend' => '.wrapper ', + 'expected' => '.wrapper p', + ), + 'single id selector' => array( + 'selector' => '#main', + 'to_prepend' => '.wrapper ', + 'expected' => '.wrapper #main', + ), + 'two comma-separated selectors without spaces' => array( + 'selector' => 'h1,h2', + 'to_prepend' => '.wrapper ', + 'expected' => '.wrapper h1, .wrapper h2', + ), + 'three comma-separated selectors without spaces' => array( + 'selector' => 'h1,h2,h3', + 'to_prepend' => '.some-class ', + 'expected' => '.some-class h1, .some-class h2, .some-class h3', + ), + 'comma-separated class selectors without spaces' => array( + 'selector' => '.foo,.bar', + 'to_prepend' => '.prefix ', + 'expected' => '.prefix .foo, .prefix .bar', + ), + 'prepend without trailing space' => array( + 'selector' => '.child', + 'to_prepend' => '.parent', + 'expected' => '.parent.child', + ), + 'compound selector without trailing space no comma spaces' => array( + 'selector' => '.a,.b', + 'to_prepend' => '.parent', + 'expected' => '.parent.a, .parent.b', + ), + 'descendant selector prepended' => array( + 'selector' => '.block .inner', + 'to_prepend' => '.scope ', + 'expected' => '.scope .block .inner', + ), + 'descendant selectors comma-separated without spaces' => array( + 'selector' => '.block .inner,.block .alt', + 'to_prepend' => '.scope ', + 'expected' => '.scope .block .inner, .scope .block .alt', + ), + 'empty selector' => array( + 'selector' => '', + 'to_prepend' => '.prefix ', + 'expected' => '.prefix ', + ), + 'empty prepend' => array( + 'selector' => '.child', + 'to_prepend' => '', + 'expected' => '.child', + ), + 'both empty' => array( + 'selector' => '', + 'to_prepend' => '', + 'expected' => '', + ), + 'attribute selector' => array( + 'selector' => '[data-type="example"]', + 'to_prepend' => '.scope ', + 'expected' => '.scope [data-type="example"]', + ), + 'pseudo-class selector' => array( + 'selector' => ':where(.is-layout-flex)', + 'to_prepend' => '.editor ', + 'expected' => '.editor :where(.is-layout-flex)', + ), + 'many comma-separated selectors without spaces' => array( + 'selector' => 'h1,h2,h3,h4,h5,h6', + 'to_prepend' => '.content ', + 'expected' => '.content h1, .content h2, .content h3, .content h4, .content h5, .content h6', + ), + 'real world block element selector' => array( + 'selector' => 'p', + 'to_prepend' => '.wp-block-group ', + 'expected' => '.wp-block-group p', + ), + 'real world compound block element selectors' => array( + 'selector' => 'a,.wp-element-button', + 'to_prepend' => '.wp-block-group ', + 'expected' => '.wp-block-group a, .wp-block-group .wp-element-button', + ), + 'spaces after commas are normalized' => array( + 'selector' => 'h1, h2, h3', + 'to_prepend' => '.some-class ', + 'expected' => '.some-class h1, .some-class h2, .some-class h3', + ), + 'spaces after commas normalized with class selectors' => array( + 'selector' => '.foo, .bar', + 'to_prepend' => '.prefix ', + 'expected' => '.prefix .foo, .prefix .bar', + ), + 'mixed whitespace around commas are normalized' => array( + 'selector' => '.a , .b , .c', + 'to_prepend' => '.pre ', + 'expected' => '.pre .a, .pre .b, .pre .c', + ), + ); + } }