diff --git a/src/wp-includes/block-editor.php b/src/wp-includes/block-editor.php index af873178eb7aa..18152756d5b73 100644 --- a/src/wp-includes/block-editor.php +++ b/src/wp-includes/block-editor.php @@ -658,6 +658,8 @@ function get_block_editor_settings( array $custom_settings, $block_editor_contex $editor_settings = apply_filters_deprecated( 'block_editor_settings', array( $editor_settings, $post ), '5.8.0', 'block_editor_settings_all' ); } + $editor_settings['canEditCSS'] = current_user_can( 'edit_css' ); + return $editor_settings; } diff --git a/src/wp-includes/block-supports/custom-css.php b/src/wp-includes/block-supports/custom-css.php new file mode 100644 index 0000000000000..9d5b13426f4ef --- /dev/null +++ b/src/wp-includes/block-supports/custom-css.php @@ -0,0 +1,133 @@ +get_registered( $parsed_block['blockName'] ); + + if ( ! block_has_support( $block_type, 'customCSS', true ) ) { + return $parsed_block; + } + + $custom_css = trim( $parsed_block['attrs']['style']['css'] ?? '' ); + + if ( empty( $custom_css ) ) { + return $parsed_block; + } + + // Validate CSS doesn't contain HTML markup (same validation as global styles REST API). + if ( preg_match( '#next_tag() ) { + $tags->add_class( 'has-custom-css' ); + $tags->add_class( $matches[0] ); + } + + return $tags->get_updated_html(); +} + +add_filter( 'render_block', 'wp_render_custom_css_class_name', 10, 2 ); +add_filter( 'render_block_data', 'wp_render_custom_css_support_styles', 10, 1 ); +add_action( 'wp_enqueue_scripts', 'wp_enqueue_block_custom_css', 1 ); + +/** + * Registers the style block attribute for block types that support it. + * + * @param WP_Block_Type $block_type Block Type. + */ +function wp_register_custom_css_support( $block_type ) { + // Setup attributes and styles within that if needed. + if ( ! $block_type->attributes ) { + $block_type->attributes = array(); + } + + // Check for existing style attribute definition e.g. from block.json. + if ( array_key_exists( 'style', $block_type->attributes ) ) { + return; + } + + $has_custom_css_support = block_has_support( $block_type, array( 'customCSS' ), true ); + + if ( $has_custom_css_support ) { + $block_type->attributes['style'] = array( + 'type' => 'object', + ); + } +} + +// Register the block support. +WP_Block_Supports::get_instance()->register( + 'custom-css', + array( + 'register_attribute' => 'wp_register_custom_css_support', + ) +); diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index c7929dad9e689..b989bab4a6474 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -1520,12 +1520,13 @@ public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' * * @since 6.2.0 * @since 6.6.0 Enforced 0-1-0 specificity for block custom CSS selectors. + * @since 7.0.0 Made public for use in custom-css block support. * * @param string $css The CSS to process. * @param string $selector The selector to nest. * @return string The processed CSS. */ - protected function process_blocks_custom_css( $css, $selector ) { + public static function process_blocks_custom_css( $css, $selector ) { $processed_css = ''; if ( empty( $css ) ) { diff --git a/src/wp-settings.php b/src/wp-settings.php index 60c220100f539..a509c37f5e51c 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -412,6 +412,7 @@ require ABSPATH . WPINC . '/block-supports/aria-label.php'; require ABSPATH . WPINC . '/block-supports/anchor.php'; require ABSPATH . WPINC . '/block-supports/block-visibility.php'; +require ABSPATH . WPINC . '/block-supports/custom-css.php'; require ABSPATH . WPINC . '/style-engine.php'; require ABSPATH . WPINC . '/style-engine/class-wp-style-engine.php'; require ABSPATH . WPINC . '/style-engine/class-wp-style-engine-css-declarations.php'; diff --git a/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php b/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php new file mode 100644 index 0000000000000..0bcbc6c708468 --- /dev/null +++ b/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php @@ -0,0 +1,134 @@ +assertStringContainsString( $expected_class, $result, 'Custom CSS class should be present in the output.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_adds_class_to_content() { + return array( + 'class is added to block content' => array( + 'block_content' => '
Test content
', + 'block' => array( + 'blockName' => 'core/paragraph', + 'attrs' => array( + 'className' => 'wp-custom-css-123abc', + ), + ), + 'expected_class' => 'wp-custom-css-123abc', + ), + 'class is extracted from mixed class names' => array( + 'block_content' => '

Test content

', + 'block' => array( + 'blockName' => 'core/paragraph', + 'attrs' => array( + 'className' => 'my-class wp-custom-css-mixed123 another-class', + ), + ), + 'expected_class' => 'wp-custom-css-mixed123', + ), + ); + } + + /** + * Tests that existing classes are preserved when the custom CSS class is added. + * + * @ticket 64544 + * + * @covers ::wp_render_custom_css_class_name + */ + public function test_preserves_existing_classes() { + $block_content = '
Test content
'; + $block = array( + 'blockName' => 'core/paragraph', + 'attrs' => array( + 'className' => 'wp-custom-css-456def', + ), + ); + + $result = wp_render_custom_css_class_name( $block_content, $block ); + + $this->assertStringContainsString( 'existing-class', $result, 'Existing classes should be preserved.' ); + $this->assertStringContainsString( 'another-class', $result, 'All existing classes should be preserved.' ); + $this->assertStringContainsString( 'wp-custom-css-456def', $result, 'Custom CSS class should be added.' ); + } + + /** + * Tests that block content is returned unchanged when no custom CSS class should be applied. + * + * @ticket 64544 + * + * @covers ::wp_render_custom_css_class_name + * + * @dataProvider data_returns_unchanged_content + * + * @param string $block_content The rendered block content. + * @param array $block The block data. + */ + public function test_returns_unchanged_content( $block_content, $block ) { + $result = wp_render_custom_css_class_name( $block_content, $block ); + + $this->assertSame( $block_content, $result, 'Block content should remain unchanged.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_returns_unchanged_content() { + return array( + 'no custom CSS class in attrs' => array( + 'block_content' => '
Test content
', + 'block' => array( + 'blockName' => 'core/paragraph', + 'attrs' => array( + 'className' => 'some-other-class', + ), + ), + ), + 'className is not set in attrs' => array( + 'block_content' => '
Test content
', + 'block' => array( + 'blockName' => 'core/paragraph', + 'attrs' => array(), + ), + ), + 'block content is empty' => array( + 'block_content' => '', + 'block' => array( + 'blockName' => 'core/paragraph', + 'attrs' => array( + 'className' => 'wp-custom-css-789ghi', + ), + ), + ), + ); + } +} diff --git a/tests/phpunit/tests/block-supports/wpRenderCustomCssSupportStyles.php b/tests/phpunit/tests/block-supports/wpRenderCustomCssSupportStyles.php new file mode 100644 index 0000000000000..0048b379f8f33 --- /dev/null +++ b/tests/phpunit/tests/block-supports/wpRenderCustomCssSupportStyles.php @@ -0,0 +1,267 @@ +test_block_name = null; + } + + public function tear_down() { + if ( $this->test_block_name ) { + unregister_block_type( $this->test_block_name ); + } + $this->test_block_name = null; + parent::tear_down(); + } + + /** + * Tests that custom CSS support adds a class name when valid CSS is present. + * + * @ticket 64544 + * + * @covers ::wp_render_custom_css_support_styles + * + * @dataProvider data_adds_class_name + * + * @param string $block_name The test block name to register. + * @param array $supports The block support configuration. + * @param array $parsed_block The parsed block data. + */ + public function test_adds_class_name( $block_name, $supports, $parsed_block ) { + $this->test_block_name = $block_name; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => $supports, + ) + ); + + $result = wp_render_custom_css_support_styles( $parsed_block ); + + $this->assertArrayHasKey( 'className', $result['attrs'], 'Block should have className added.' ); + $this->assertMatchesRegularExpression( '/wp-custom-css-/', $result['attrs']['className'], 'className should contain wp-custom-css- prefix.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_adds_class_name() { + return array( + 'class name is added when custom CSS is present' => array( + 'block_name' => 'test/custom-css-block', + 'supports' => array( 'customCSS' => true ), + 'parsed_block' => array( + 'blockName' => 'test/custom-css-block', + 'attrs' => array( + 'style' => array( + 'css' => 'color: red;', + ), + ), + ), + ), + 'class name is added when support is not explicitly set' => array( + 'block_name' => 'test/custom-css-default', + 'supports' => array(), + 'parsed_block' => array( + 'blockName' => 'test/custom-css-default', + 'attrs' => array( + 'style' => array( + 'css' => 'font-weight: bold;', + ), + ), + ), + ), + 'class name is added for valid CSS with url() values' => array( + 'block_name' => 'test/custom-css-valid', + 'supports' => array( 'customCSS' => true ), + 'parsed_block' => array( + 'blockName' => 'test/custom-css-valid', + 'attrs' => array( + 'style' => array( + 'css' => 'color: red; background: url("image.png"); font-size: 16px;', + ), + ), + ), + ), + ); + } + + /** + * Tests that existing className is preserved when custom CSS class is added. + * + * @ticket 64544 + * + * @covers ::wp_render_custom_css_support_styles + */ + public function test_preserves_existing_class_name() { + $this->test_block_name = 'test/custom-css-block-existing'; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( 'customCSS' => true ), + ) + ); + + $parsed_block = array( + 'blockName' => 'test/custom-css-block-existing', + 'attrs' => array( + 'className' => 'my-existing-class', + 'style' => array( + 'css' => 'color: blue;', + ), + ), + ); + + $result = wp_render_custom_css_support_styles( $parsed_block ); + + $this->assertStringContainsString( 'my-existing-class', $result['attrs']['className'], 'Existing className should be preserved.' ); + $this->assertMatchesRegularExpression( '/wp-custom-css-/', $result['attrs']['className'], 'className should also contain wp-custom-css- prefix.' ); + } + + /** + * Tests that custom CSS support does not add a class name when CSS should not be applied. + * + * @ticket 64544 + * + * @covers ::wp_render_custom_css_support_styles + * + * @dataProvider data_does_not_add_class_name + * + * @param string $block_name The test block name to register. + * @param array $supports The block support configuration. + * @param array $parsed_block The parsed block data. + */ + public function test_does_not_add_class_name( $block_name, $supports, $parsed_block ) { + $this->test_block_name = $block_name; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => $supports, + ) + ); + + $result = wp_render_custom_css_support_styles( $parsed_block ); + + $this->assertArrayNotHasKey( 'className', $result['attrs'], 'Block should not have className added.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_does_not_add_class_name() { + return array( + 'support is disabled' => array( + 'block_name' => 'test/custom-css-disabled', + 'supports' => array( 'customCSS' => false ), + 'parsed_block' => array( + 'blockName' => 'test/custom-css-disabled', + 'attrs' => array( + 'style' => array( + 'css' => 'color: green;', + ), + ), + ), + ), + 'no CSS attribute present' => array( + 'block_name' => 'test/custom-css-no-css', + 'supports' => array( 'customCSS' => true ), + 'parsed_block' => array( + 'blockName' => 'test/custom-css-no-css', + 'attrs' => array( + 'style' => array( + 'color' => 'red', + ), + ), + ), + ), + 'CSS is empty' => array( + 'block_name' => 'test/custom-css-empty', + 'supports' => array( 'customCSS' => true ), + 'parsed_block' => array( + 'blockName' => 'test/custom-css-empty', + 'attrs' => array( + 'style' => array( + 'css' => '', + ), + ), + ), + ), + 'CSS is whitespace only' => array( + 'block_name' => 'test/custom-css-whitespace', + 'supports' => array( 'customCSS' => true ), + 'parsed_block' => array( + 'blockName' => 'test/custom-css-whitespace', + 'attrs' => array( + 'style' => array( + 'css' => ' ', + ), + ), + ), + ), + 'no style attribute' => array( + 'block_name' => 'test/custom-css-no-style', + 'supports' => array( 'customCSS' => true ), + 'parsed_block' => array( + 'blockName' => 'test/custom-css-no-style', + 'attrs' => array(), + ), + ), + 'CSS contains HTML opening tags' => array( + 'block_name' => 'test/custom-css-html-open', + 'supports' => array( 'customCSS' => true ), + 'parsed_block' => array( + 'blockName' => 'test/custom-css-html-open', + 'attrs' => array( + 'style' => array( + 'css' => '', + ), + ), + ), + ), + 'CSS contains HTML closing tags' => array( + 'block_name' => 'test/custom-css-html-close', + 'supports' => array( 'customCSS' => true ), + 'parsed_block' => array( + 'blockName' => 'test/custom-css-html-close', + 'attrs' => array( + 'style' => array( + 'css' => 'color: red;', + ), + ), + ), + ), + ); + } +} diff --git a/tests/phpunit/tests/blocks/editor.php b/tests/phpunit/tests/blocks/editor.php index 4241161388eb8..1de4dbf1719a6 100644 --- a/tests/phpunit/tests/blocks/editor.php +++ b/tests/phpunit/tests/blocks/editor.php @@ -583,7 +583,8 @@ public function test_get_block_editor_settings_deprecated_filter_post_editor() { $this->assertSameSets( array( - 'filter' => 'deprecated', + 'canEditCSS' => false, + 'filter' => 'deprecated', ), $settings );