From 6b328e69153bd57c02dfef582a6eaa22f7376ba4 Mon Sep 17 00:00:00 2001 From: Isabel Brison Date: Fri, 3 May 2024 04:45:20 +0000 Subject: [PATCH] Editor: add Style Engine support for nested CSS rules. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for passing a `$rules_group` string to wp_style_engine_get_stylesheet_from_css_rules(), so rules can be nested under a media query, layer or other rule. Props isabel_brison, ramonopoly. Fixes #61099. git-svn-id: https://develop.svn.wordpress.org/trunk@58089 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/style-engine.php | 8 +- .../class-wp-style-engine-css-rule.php | 64 ++++++++++++-- .../class-wp-style-engine-css-rules-store.php | 15 +++- .../class-wp-style-engine-processor.php | 21 ++++- .../style-engine/class-wp-style-engine.php | 7 +- .../tests/style-engine/styleEngine.php | 64 ++++++++++++++ .../style-engine/wpStyleEngineCssRule.php | 18 ++++ .../wpStyleEngineCssRulesStore.php | 18 ++++ .../style-engine/wpStyleEngineProcessor.php | 85 +++++++++++++++++++ 9 files changed, 286 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/style-engine.php b/src/wp-includes/style-engine.php index 5b5545c85e09f..53627612f9504 100644 --- a/src/wp-includes/style-engine.php +++ b/src/wp-includes/style-engine.php @@ -113,11 +113,14 @@ function wp_style_engine_get_styles( $block_styles, $options = array() ) { * .elephant-are-cool{color:gray;width:3em} * * @since 6.1.0 + * @since 6.6.0 Added support for `$rules_group` in the `$css_rules` array. * * @param array $css_rules { * Required. A collection of CSS rules. * * @type array ...$0 { + * @type string $rules_group A parent CSS selector in the case of nested CSS, + * or a CSS nested @rule, such as `@media (min-width: 80rem)` or `@layer module`. * @type string $selector A CSS selector. * @type string[] $declarations An associative array of CSS definitions, * e.g. `array( "$property" => "$value", "$property" => "$value" )`. @@ -154,11 +157,12 @@ function wp_style_engine_get_stylesheet_from_css_rules( $css_rules, $options = a continue; } + $rules_group = $css_rule['rules_group'] ?? null; if ( ! empty( $options['context'] ) ) { - WP_Style_Engine::store_css_rule( $options['context'], $css_rule['selector'], $css_rule['declarations'] ); + WP_Style_Engine::store_css_rule( $options['context'], $css_rule['selector'], $css_rule['declarations'], $rules_group ); } - $css_rule_objects[] = new WP_Style_Engine_CSS_Rule( $css_rule['selector'], $css_rule['declarations'] ); + $css_rule_objects[] = new WP_Style_Engine_CSS_Rule( $css_rule['selector'], $css_rule['declarations'], $rules_group ); } if ( empty( $css_rule_objects ) ) { diff --git a/src/wp-includes/style-engine/class-wp-style-engine-css-rule.php b/src/wp-includes/style-engine/class-wp-style-engine-css-rule.php index 2b31cff0b48c5..291652a615016 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine-css-rule.php +++ b/src/wp-includes/style-engine/class-wp-style-engine-css-rule.php @@ -35,20 +35,33 @@ class WP_Style_Engine_CSS_Rule { */ protected $declarations; + /** + * A parent CSS selector in the case of nested CSS, or a CSS nested @rule, + * such as `@media (min-width: 80rem)` or `@layer module`. + * + * @since 6.6.0 + * @var string + */ + protected $rules_group; + /** * Constructor. * * @since 6.1.0 + * @since 6.6.0 Added the `$rules_group` parameter. * * @param string $selector Optional. The CSS selector. Default empty string. * @param string[]|WP_Style_Engine_CSS_Declarations $declarations Optional. An associative array of CSS definitions, * e.g. `array( "$property" => "$value", "$property" => "$value" )`, * or a WP_Style_Engine_CSS_Declarations object. * Default empty array. + * @param string $rules_group A parent CSS selector in the case of nested CSS, or a CSS nested @rule, + * such as `@media (min-width: 80rem)` or `@layer module`. */ - public function __construct( $selector = '', $declarations = array() ) { + public function __construct( $selector = '', $declarations = array(), $rules_group = '' ) { $this->set_selector( $selector ); $this->add_declarations( $declarations ); + $this->set_rules_group( $rules_group ); } /** @@ -89,6 +102,31 @@ public function add_declarations( $declarations ) { return $this; } + /** + * Sets the rules group. + * + * @since 6.6.0 + * + * @param string $rules_group A parent CSS selector in the case of nested CSS, or a CSS nested @rule, + * such as `@media (min-width: 80rem)` or `@layer module`. + * @return WP_Style_Engine_CSS_Rule Returns the object to allow chaining of methods. + */ + public function set_rules_group( $rules_group ) { + $this->rules_group = $rules_group; + return $this; + } + + /** + * Gets the rules group. + * + * @since 6.6.0 + * + * @return string + */ + public function get_rules_group() { + return $this->rules_group; + } + /** * Gets the declarations object. * @@ -115,6 +153,7 @@ public function get_selector() { * Gets the CSS. * * @since 6.1.0 + * @since 6.6.0 Added support for nested CSS with rules groups. * * @param bool $should_prettify Optional. Whether to add spacing, new lines and indents. * Default false. @@ -123,17 +162,28 @@ public function get_selector() { * @return string */ public function get_css( $should_prettify = false, $indent_count = 0 ) { - $rule_indent = $should_prettify ? str_repeat( "\t", $indent_count ) : ''; - $declarations_indent = $should_prettify ? $indent_count + 1 : 0; - $suffix = $should_prettify ? "\n" : ''; - $spacer = $should_prettify ? ' ' : ''; - $selector = $should_prettify ? str_replace( ',', ",\n", $this->get_selector() ) : $this->get_selector(); - $css_declarations = $this->declarations->get_declarations_string( $should_prettify, $declarations_indent ); + $rule_indent = $should_prettify ? str_repeat( "\t", $indent_count ) : ''; + $nested_rule_indent = $should_prettify ? str_repeat( "\t", $indent_count + 1 ) : ''; + $declarations_indent = $should_prettify ? $indent_count + 1 : 0; + $nested_declarations_indent = $should_prettify ? $indent_count + 2 : 0; + $suffix = $should_prettify ? "\n" : ''; + $spacer = $should_prettify ? ' ' : ''; + // Trims any multiple selectors strings. + $selector = $should_prettify ? implode( ',', array_map( 'trim', explode( ',', $this->get_selector() ) ) ) : $this->get_selector(); + $selector = $should_prettify ? str_replace( array( ',' ), ",\n", $selector ) : $selector; + $rules_group = $this->get_rules_group(); + $has_rules_group = ! empty( $rules_group ); + $css_declarations = $this->declarations->get_declarations_string( $should_prettify, $has_rules_group ? $nested_declarations_indent : $declarations_indent ); if ( empty( $css_declarations ) ) { return ''; } + if ( $has_rules_group ) { + $selector = "{$rule_indent}{$rules_group}{$spacer}{{$suffix}{$nested_rule_indent}{$selector}{$spacer}{{$suffix}{$css_declarations}{$suffix}{$nested_rule_indent}}{$suffix}{$rule_indent}}"; + return $selector; + } + return "{$rule_indent}{$selector}{$spacer}{{$suffix}{$css_declarations}{$suffix}{$rule_indent}}"; } } diff --git a/src/wp-includes/style-engine/class-wp-style-engine-css-rules-store.php b/src/wp-includes/style-engine/class-wp-style-engine-css-rules-store.php index 371e59fb8b112..4a82f28b8a41e 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine-css-rules-store.php +++ b/src/wp-includes/style-engine/class-wp-style-engine-css-rules-store.php @@ -121,19 +121,30 @@ public function get_all_rules() { * If the rule does not exist, it will be created. * * @since 6.1.0 + * @since 6.6.0 Added the $rules_group parameter. * * @param string $selector The CSS selector. + * @param string $rules_group A parent CSS selector in the case of nested CSS, or a CSS nested @rule, + * such as `@media (min-width: 80rem)` or `@layer module`. * @return WP_Style_Engine_CSS_Rule|void Returns a WP_Style_Engine_CSS_Rule object, * or void if the selector is empty. */ - public function add_rule( $selector ) { - $selector = trim( $selector ); + public function add_rule( $selector, $rules_group = '' ) { + $selector = $selector ? trim( $selector ) : ''; + $rules_group = $rules_group ? trim( $rules_group ) : ''; // Bail early if there is no selector. if ( empty( $selector ) ) { return; } + if ( ! empty( $rules_group ) ) { + if ( empty( $this->rules[ "$rules_group $selector" ] ) ) { + $this->rules[ "$rules_group $selector" ] = new WP_Style_Engine_CSS_Rule( $selector, array(), $rules_group ); + } + return $this->rules[ "$rules_group $selector" ]; + } + // Create the rule if it doesn't exist. if ( empty( $this->rules[ $selector ] ) ) { $this->rules[ $selector ] = new WP_Style_Engine_CSS_Rule( $selector ); diff --git a/src/wp-includes/style-engine/class-wp-style-engine-processor.php b/src/wp-includes/style-engine/class-wp-style-engine-processor.php index 0778748498886..d5e6c73f815ca 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine-processor.php +++ b/src/wp-includes/style-engine/class-wp-style-engine-processor.php @@ -58,6 +58,7 @@ public function add_store( $store ) { * Adds rules to be processed. * * @since 6.1.0 + * @since 6.6.0 Added support for rules_group. * * @param WP_Style_Engine_CSS_Rule|WP_Style_Engine_CSS_Rule[] $css_rules A single, or an array of, * WP_Style_Engine_CSS_Rule objects @@ -70,7 +71,24 @@ public function add_rules( $css_rules ) { } foreach ( $css_rules as $rule ) { - $selector = $rule->get_selector(); + $selector = $rule->get_selector(); + $rules_group = $rule->get_rules_group(); + + /** + * If there is a rules_group and it already exists in the css_rules array, + * add the rule to it. + * Otherwise, create a new entry for the rules_group. + */ + if ( ! empty( $rules_group ) ) { + if ( isset( $this->css_rules[ "$rules_group $selector" ] ) ) { + $this->css_rules[ "$rules_group $selector" ]->add_declarations( $rule->get_declarations() ); + continue; + } + $this->css_rules[ "$rules_group $selector" ] = $rule; + continue; + } + + // If the selector already exists, add the declarations to it. if ( isset( $this->css_rules[ $selector ] ) ) { $this->css_rules[ $selector ]->add_declarations( $rule->get_declarations() ); continue; @@ -117,6 +135,7 @@ public function get_css( $options = array() ) { // Build the CSS. $css = ''; foreach ( $this->css_rules as $rule ) { + // See class WP_Style_Engine_CSS_Rule for the get_css method. $css .= $rule->get_css( $options['prettify'] ); $css .= $options['prettify'] ? "\n" : ''; } diff --git a/src/wp-includes/style-engine/class-wp-style-engine.php b/src/wp-includes/style-engine/class-wp-style-engine.php index 99372b5d70c32..8b16cdd4677bb 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine.php +++ b/src/wp-includes/style-engine/class-wp-style-engine.php @@ -364,6 +364,7 @@ protected static function is_valid_style_value( $style_value ) { * Stores a CSS rule using the provided CSS selector and CSS declarations. * * @since 6.1.0 + * @since 6.6.0 Added the `$rules_group` parameter. * * @param string $store_name A valid store key. * @param string $css_selector When a selector is passed, the function will return @@ -371,12 +372,14 @@ protected static function is_valid_style_value( $style_value ) { * otherwise a concatenated string of properties and values. * @param string[] $css_declarations An associative array of CSS definitions, * e.g. `array( "$property" => "$value", "$property" => "$value" )`. + * @param string $rules_group Optional. A parent CSS selector in the case of nested CSS, or a CSS nested @rule, + * such as `@media (min-width: 80rem)` or `@layer module`. */ - public static function store_css_rule( $store_name, $css_selector, $css_declarations ) { + public static function store_css_rule( $store_name, $css_selector, $css_declarations, $rules_group = '' ) { if ( empty( $store_name ) || empty( $css_selector ) || empty( $css_declarations ) ) { return; } - static::get_store( $store_name )->add_rule( $css_selector )->add_declarations( $css_declarations ); + static::get_store( $store_name )->add_rule( $css_selector, $rules_group )->add_declarations( $css_declarations ); } /** diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index 794f540baf422..9092ce5b6df03 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -749,4 +749,68 @@ public function test_should_dedupe_and_merge_css_rules() { $this->assertSame( '.gandalf{color:white;height:190px;border-style:dotted;padding:10px;margin-bottom:100px;}.dumbledore{color:grey;height:90px;border-style:dotted;}.rincewind{color:grey;height:90px;border-style:dotted;}', $compiled_stylesheet ); } + + /** + * Tests returning a generated stylesheet from a set of nested rules and merging their declarations. + * + * @ticket 61099 + * + * @covers ::wp_style_engine_get_stylesheet_from_css_rules + */ + public function test_should_merge_declarations_for_rules_groups() { + $css_rules = array( + array( + 'selector' => '.saruman', + 'rules_group' => '@container (min-width: 700px)', + 'declarations' => array( + 'color' => 'white', + 'height' => '100px', + 'border-style' => 'solid', + 'align-self' => 'stretch', + ), + ), + array( + 'selector' => '.saruman', + 'rules_group' => '@container (min-width: 700px)', + 'declarations' => array( + 'color' => 'black', + 'font-family' => 'The-Great-Eye', + ), + ), + ); + + $compiled_stylesheet = wp_style_engine_get_stylesheet_from_css_rules( $css_rules, array( 'prettify' => false ) ); + + $this->assertSame( '@container (min-width: 700px){.saruman{color:black;height:100px;border-style:solid;align-self:stretch;font-family:The-Great-Eye;}}', $compiled_stylesheet ); + } + + /** + * Tests returning a generated stylesheet from a set of nested rules. + * + * @ticket 61099 + * + * @covers ::wp_style_engine_get_stylesheet_from_css_rules + */ + public function test_should_return_stylesheet_with_nested_rules() { + $css_rules = array( + array( + 'rules_group' => '.foo', + 'selector' => '@media (orientation: landscape)', + 'declarations' => array( + 'background-color' => 'blue', + ), + ), + array( + 'rules_group' => '.foo', + 'selector' => '@media (min-width > 1024px)', + 'declarations' => array( + 'background-color' => 'cotton-blue', + ), + ), + ); + + $compiled_stylesheet = wp_style_engine_get_stylesheet_from_css_rules( $css_rules, array( 'prettify' => false ) ); + + $this->assertSame( '.foo{@media (orientation: landscape){background-color:blue;}}.foo{@media (min-width > 1024px){background-color:cotton-blue;}}', $compiled_stylesheet ); + } } diff --git a/tests/phpunit/tests/style-engine/wpStyleEngineCssRule.php b/tests/phpunit/tests/style-engine/wpStyleEngineCssRule.php index debb09d8d1fc6..a9de39392ade0 100644 --- a/tests/phpunit/tests/style-engine/wpStyleEngineCssRule.php +++ b/tests/phpunit/tests/style-engine/wpStyleEngineCssRule.php @@ -37,6 +37,24 @@ public function test_should_instantiate_with_selector_and_rules() { $this->assertSame( $expected, $css_rule->get_css(), 'Value returned by get_css() does not match expected declarations string.' ); } + /** + * Tests setting and getting a rules group. + * + * @ticket 61099 + * + * @covers ::set_rules_group + * @covers ::get_rules_group + */ + public function test_should_set_rules_group() { + $rule = new WP_Style_Engine_CSS_Rule( '.heres-johnny', array(), '@layer state' ); + + $this->assertSame( '@layer state', $rule->get_rules_group(), 'Return value of get_rules_group() does not match value passed to constructor.' ); + + $rule->set_rules_group( '@layer pony' ); + + $this->assertSame( '@layer pony', $rule->get_rules_group(), 'Return value of get_rules_group() does not match value passed to set_rules_group().' ); + } + /** * Tests that declaration properties are deduplicated. * diff --git a/tests/phpunit/tests/style-engine/wpStyleEngineCssRulesStore.php b/tests/phpunit/tests/style-engine/wpStyleEngineCssRulesStore.php index 4fe6c4c6e2a34..1be7804780052 100644 --- a/tests/phpunit/tests/style-engine/wpStyleEngineCssRulesStore.php +++ b/tests/phpunit/tests/style-engine/wpStyleEngineCssRulesStore.php @@ -187,4 +187,22 @@ public function test_should_get_all_rule_objects_for_a_store() { $this->assertSame( $expected, $new_pizza_store->get_all_rules(), 'Return value for get_all_rules() does not match expectations after adding new rules to store.' ); } + + /** + * Tests adding rules group keys to store. + * + * @ticket 61099 + * + * @covers ::add_rule + */ + public function test_should_store_as_concatenated_rules_groups_and_selector() { + $store_one = WP_Style_Engine_CSS_Rules_Store::get_store( 'one' ); + $store_one_rule = $store_one->add_rule( '.tony', '.one' ); + + $this->assertSame( + '.one .tony', + "{$store_one_rule->get_rules_group()} {$store_one_rule->get_selector()}", + 'add_rule() does not concatenate rules group and selector.' + ); + } } diff --git a/tests/phpunit/tests/style-engine/wpStyleEngineProcessor.php b/tests/phpunit/tests/style-engine/wpStyleEngineProcessor.php index d38f46d0de5b3..070aefe6886f7 100644 --- a/tests/phpunit/tests/style-engine/wpStyleEngineProcessor.php +++ b/tests/phpunit/tests/style-engine/wpStyleEngineProcessor.php @@ -48,6 +48,43 @@ public function test_should_return_rules_as_compiled_css() { ); } + /** + * Tests adding nested rules with at-rules and returning compiled CSS rules. + * + * @ticket 61099 + * + * @covers ::add_rules + * @covers ::get_css + */ + public function test_should_return_nested_rules_as_compiled_css() { + $a_nice_css_rule = new WP_Style_Engine_CSS_Rule( '.a-nice-rule' ); + $a_nice_css_rule->add_declarations( + array( + 'color' => 'var(--nice-color)', + 'background-color' => 'purple', + ) + ); + $a_nice_css_rule->set_rules_group( '@media (min-width: 80rem)' ); + + $a_nicer_css_rule = new WP_Style_Engine_CSS_Rule( '.a-nicer-rule' ); + $a_nicer_css_rule->add_declarations( + array( + 'font-family' => 'Nice sans', + 'font-size' => '1em', + 'background-color' => 'purple', + ) + ); + $a_nicer_css_rule->set_rules_group( '@layer nicety' ); + + $a_nice_processor = new WP_Style_Engine_Processor(); + $a_nice_processor->add_rules( array( $a_nice_css_rule, $a_nicer_css_rule ) ); + + $this->assertSame( + '@media (min-width: 80rem){.a-nice-rule{color:var(--nice-color);background-color:purple;}}@layer nicety{.a-nicer-rule{font-family:Nice sans;font-size:1em;background-color:purple;}}', + $a_nice_processor->get_css( array( 'prettify' => false ) ) + ); + } + /** * Tests compiling CSS rules and formatting them with new lines and indents. * @@ -101,6 +138,54 @@ public function test_should_return_prettified_css_rules() { ); } + /** + * Tests compiling nested CSS rules and formatting them with new lines and indents. + * + * @ticket 61099 + * + * @covers ::get_css + */ + public function test_should_return_prettified_nested_css_rules() { + $a_wonderful_css_rule = new WP_Style_Engine_CSS_Rule( '.a-wonderful-rule' ); + $a_wonderful_css_rule->add_declarations( + array( + 'color' => 'var(--wonderful-color)', + 'background-color' => 'orange', + ) + ); + $a_wonderful_css_rule->set_rules_group( '@media (min-width: 80rem)' ); + + $a_very_wonderful_css_rule = new WP_Style_Engine_CSS_Rule( '.a-very_wonderful-rule' ); + $a_very_wonderful_css_rule->add_declarations( + array( + 'color' => 'var(--wonderful-color)', + 'background-color' => 'orange', + ) + ); + $a_very_wonderful_css_rule->set_rules_group( '@layer wonderfulness' ); + + $a_wonderful_processor = new WP_Style_Engine_Processor(); + $a_wonderful_processor->add_rules( array( $a_wonderful_css_rule, $a_very_wonderful_css_rule ) ); + + $expected = '@media (min-width: 80rem) { + .a-wonderful-rule { + color: var(--wonderful-color); + background-color: orange; + } +} +@layer wonderfulness { + .a-very_wonderful-rule { + color: var(--wonderful-color); + background-color: orange; + } +} +'; + $this->assertSame( + $expected, + $a_wonderful_processor->get_css( array( 'prettify' => true ) ) + ); + } + /** * Tests adding a store and compiling CSS rules from that store. *