From 5d72f9e55a3c3db769fd8b55990305fa82328c61 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 31 Jul 2024 13:31:16 +1000 Subject: [PATCH] Global Styles: Fix block custom CSS pseudo element selectors (#63980) Unlinked contributors: harlet. Co-authored-by: aaronrobertshaw Co-authored-by: andrewserong Co-authored-by: ramonjd Co-authored-by: dballari Co-authored-by: wongjn --- backport-changelog/6.6/7097.md | 3 ++ lib/class-wp-theme-json-gutenberg.php | 26 +++++++++++-- .../test/use-global-styles-output.js | 16 ++++++-- .../global-styles/use-global-styles-output.js | 37 +++++++++++++++++-- phpunit/class-wp-theme-json-test.php | 18 ++++++++- 5 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 backport-changelog/6.6/7097.md diff --git a/backport-changelog/6.6/7097.md b/backport-changelog/6.6/7097.md new file mode 100644 index 00000000000000..e674d5ea76ba6f --- /dev/null +++ b/backport-changelog/6.6/7097.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7097 + +* https://github.com/WordPress/gutenberg/pull/63980 diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 895730a6a7afe3..5d8b22d71dc216 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -1372,9 +1372,16 @@ public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' protected function process_blocks_custom_css( $css, $selector ) { $processed_css = ''; + if ( empty( $css ) ) { + return $processed_css; + } + // Split CSS nested rules. $parts = explode( '&', $css ); foreach ( $parts as $part ) { + if ( empty( $part ) ) { + continue; + } $is_root_css = ( ! str_contains( $part, '{' ) ); if ( $is_root_css ) { // If the part doesn't contain braces, it applies to the root level. @@ -1387,11 +1394,24 @@ protected function process_blocks_custom_css( $css, $selector ) { } $nested_selector = $part[0]; $css_value = $part[1]; - $part_selector = str_starts_with( $nested_selector, ' ' ) + + /* + * Handle pseudo elements such as ::before, ::after etc. Regex will also + * capture any leading combinator such as >, +, or ~, as well as spaces. + * This allows pseudo elements as descendants e.g. `.parent ::before`. + */ + $matches = array(); + $has_pseudo_element = preg_match( '/([>+~\s]*::[a-zA-Z-]+)/', $nested_selector, $matches ); + $pseudo_part = $has_pseudo_element ? $matches[1] : ''; + $nested_selector = $has_pseudo_element ? str_replace( $pseudo_part, '', $nested_selector ) : $nested_selector; + + // Finalize selector and re-append pseudo element if required. + $part_selector = str_starts_with( $nested_selector, ' ' ) ? static::scope_selector( $selector, $nested_selector ) : static::append_to_selector( $selector, $nested_selector ); - $final_selector = ":root :where($part_selector)"; - $processed_css .= $final_selector . '{' . trim( $css_value ) . '}'; + $final_selector = ":root :where($part_selector)$pseudo_part"; + + $processed_css .= $final_selector . '{' . trim( $css_value ) . '}'; } } return $processed_css; diff --git a/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js index 525a8a1d53d072..3f7fb5462e377c 100644 --- a/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js @@ -1031,11 +1031,19 @@ describe( 'global styles renderer', () => { } ); describe( 'processCSSNesting', () => { + it( 'should return empty string when supplied css is empty', () => { + expect( processCSSNesting( '', '.foo' ) ).toEqual( '' ); + } ); it( 'should return processed CSS without any nested selectors', () => { expect( processCSSNesting( 'color: red; margin: auto;', '.foo' ) ).toEqual( ':root :where(.foo){color: red; margin: auto;}' ); } ); + it( 'should return processed CSS when there are no root selectors', () => { + expect( + processCSSNesting( '&::before{color: red;}', '.foo' ) + ).toEqual( ':root :where(.foo)::before{color: red;}' ); + } ); it( 'should return processed CSS with nested selectors', () => { expect( processCSSNesting( @@ -1049,21 +1057,21 @@ describe( 'global styles renderer', () => { it( 'should return processed CSS with pseudo elements', () => { expect( processCSSNesting( - 'color: red; margin: auto; &::before{color: blue;} & ::before{color: green;} &.one::before{color: yellow;} & .two::before{color: purple;}', + 'color: red; margin: auto; &::before{color: blue;} & ::before{color: green;} &.one::before{color: yellow;} & .two::before{color: purple;} & > ::before{color: darkseagreen;}', '.foo' ) ).toEqual( - ':root :where(.foo){color: red; margin: auto;}:root :where(.foo::before){color: blue;}:root :where(.foo ::before){color: green;}:root :where(.foo.one::before){color: yellow;}:root :where(.foo .two::before){color: purple;}' + ':root :where(.foo){color: red; margin: auto;}:root :where(.foo)::before{color: blue;}:root :where(.foo) ::before{color: green;}:root :where(.foo.one)::before{color: yellow;}:root :where(.foo .two)::before{color: purple;}:root :where(.foo) > ::before{color: darkseagreen;}' ); } ); it( 'should return processed CSS with multiple root selectors', () => { expect( processCSSNesting( - 'color: red; margin: auto; &.one{color: blue;} & .two{color: green;} &::before{color: yellow;} & ::before{color: purple;} &.three::before{color: orange;} & .four::before{color: skyblue;}', + 'color: red; margin: auto; &.one{color: blue;} & .two{color: green;} &::before{color: yellow;} & ::before{color: purple;} &.three::before{color: orange;} & .four::before{color: skyblue;} & > ::before{color: darkseagreen;}', '.foo, .bar' ) ).toEqual( - ':root :where(.foo, .bar){color: red; margin: auto;}:root :where(.foo.one, .bar.one){color: blue;}:root :where(.foo .two, .bar .two){color: green;}:root :where(.foo::before, .bar::before){color: yellow;}:root :where(.foo ::before, .bar ::before){color: purple;}:root :where(.foo.three::before, .bar.three::before){color: orange;}:root :where(.foo .four::before, .bar .four::before){color: skyblue;}' + ':root :where(.foo, .bar){color: red; margin: auto;}:root :where(.foo.one, .bar.one){color: blue;}:root :where(.foo .two, .bar .two){color: green;}:root :where(.foo, .bar)::before{color: yellow;}:root :where(.foo, .bar) ::before{color: purple;}:root :where(.foo.three, .bar.three)::before{color: orange;}:root :where(.foo .four, .bar .four)::before{color: skyblue;}:root :where(.foo, .bar) > ::before{color: darkseagreen;}' ); } ); } ); diff --git a/packages/block-editor/src/components/global-styles/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/use-global-styles-output.js index be1d491d3bf6fe..a815a0f69e8a11 100644 --- a/packages/block-editor/src/components/global-styles/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/use-global-styles-output.js @@ -1312,9 +1312,17 @@ function updateConfigWithSeparator( config ) { export function processCSSNesting( css, blockSelector ) { let processedCSS = ''; + if ( ! css || css.trim() === '' ) { + return processedCSS; + } + // Split CSS nested rules. const parts = css.split( '&' ); parts.forEach( ( part ) => { + if ( ! part || part.trim() === '' ) { + return; + } + const isRootCss = ! part.includes( '{' ); if ( isRootCss ) { // If the part doesn't contain braces, it applies to the root level. @@ -1327,11 +1335,32 @@ export function processCSSNesting( css, blockSelector ) { } const [ nestedSelector, cssValue ] = splittedPart; - const combinedSelector = nestedSelector.startsWith( ' ' ) - ? scopeSelector( blockSelector, nestedSelector ) - : appendToSelector( blockSelector, nestedSelector ); - processedCSS += `:root :where(${ combinedSelector }){${ cssValue.trim() }}`; + // Handle pseudo elements such as ::before, ::after, etc. Regex will also + // capture any leading combinator such as >, +, or ~, as well as spaces. + // This allows pseudo elements as descendants e.g. `.parent ::before`. + const matches = nestedSelector.match( /([>+~\s]*::[a-zA-Z-]+)/ ); + const pseudoPart = matches ? matches[ 1 ] : ''; + const withoutPseudoElement = matches + ? nestedSelector.replace( pseudoPart, '' ).trim() + : nestedSelector.trim(); + + let combinedSelector; + if ( withoutPseudoElement === '' ) { + // Only contained a pseudo element to use the block selector to form + // the final `:root :where()` selector. + combinedSelector = blockSelector; + } else { + // If the nested selector is a descendant of the block scope it with the + // block selector. Otherwise append it to the block selector. + combinedSelector = nestedSelector.startsWith( ' ' ) + ? scopeSelector( blockSelector, withoutPseudoElement ) + : appendToSelector( blockSelector, withoutPseudoElement ); + } + + // Build final rule, re-adding any pseudo element outside the `:where()` + // to maintain valid CSS selector. + processedCSS += `:root :where(${ combinedSelector })${ pseudoPart }{${ cssValue.trim() }}`; } } ); return processedCSS; diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index bd841cf4b710b7..f561e8baf1c781 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -4967,6 +4967,13 @@ public function test_process_blocks_custom_css( $input, $expected ) { public function data_process_blocks_custom_css() { return array( // Simple CSS without any nested selectors. + 'empty css' => array( + 'input' => array( + 'selector' => '.foo', + 'css' => '', + ), + 'expected' => '', + ), 'no nested selectors' => array( 'input' => array( 'selector' => '.foo', @@ -4974,6 +4981,13 @@ public function data_process_blocks_custom_css() { ), 'expected' => ':root :where(.foo){color: red; margin: auto;}', ), + 'no root styles' => array( + 'input' => array( + 'selector' => '.foo', + 'css' => '&::before{color: red;}', + ), + 'expected' => ':root :where(.foo)::before{color: red;}', + ), // CSS with nested selectors. 'with nested selector' => array( 'input' => array( @@ -4988,7 +5002,7 @@ public function data_process_blocks_custom_css() { 'selector' => '.foo', 'css' => 'color: red; margin: auto; &::before{color: blue;} & ::before{color: green;} &.one::before{color: yellow;} & .two::before{color: purple;}', ), - 'expected' => ':root :where(.foo){color: red; margin: auto;}:root :where(.foo::before){color: blue;}:root :where(.foo ::before){color: green;}:root :where(.foo.one::before){color: yellow;}:root :where(.foo .two::before){color: purple;}', + 'expected' => ':root :where(.foo){color: red; margin: auto;}:root :where(.foo)::before{color: blue;}:root :where(.foo) ::before{color: green;}:root :where(.foo.one)::before{color: yellow;}:root :where(.foo .two)::before{color: purple;}', ), // CSS with multiple root selectors. 'with multiple root selectors' => array( @@ -4996,7 +5010,7 @@ public function data_process_blocks_custom_css() { 'selector' => '.foo, .bar', 'css' => 'color: red; margin: auto; &.one{color: blue;} & .two{color: green;} &::before{color: yellow;} & ::before{color: purple;} &.three::before{color: orange;} & .four::before{color: skyblue;}', ), - 'expected' => ':root :where(.foo, .bar){color: red; margin: auto;}:root :where(.foo.one, .bar.one){color: blue;}:root :where(.foo .two, .bar .two){color: green;}:root :where(.foo::before, .bar::before){color: yellow;}:root :where(.foo ::before, .bar ::before){color: purple;}:root :where(.foo.three::before, .bar.three::before){color: orange;}:root :where(.foo .four::before, .bar .four::before){color: skyblue;}', + 'expected' => ':root :where(.foo, .bar){color: red; margin: auto;}:root :where(.foo.one, .bar.one){color: blue;}:root :where(.foo .two, .bar .two){color: green;}:root :where(.foo, .bar)::before{color: yellow;}:root :where(.foo, .bar) ::before{color: purple;}:root :where(.foo.three, .bar.three)::before{color: orange;}:root :where(.foo .four, .bar .four)::before{color: skyblue;}', ), ); }