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( '#?\w+#', $custom_css ) ) {
+ return $parsed_block;
+ }
+
+ // Generate a unique class name for this block instance.
+ $class_name = wp_unique_id_from_values( $parsed_block, 'wp-custom-css-' );
+ $updated_class_name = isset( $parsed_block['attrs']['className'] )
+ ? $parsed_block['attrs']['className'] . " $class_name"
+ : $class_name;
+
+ _wp_array_set( $parsed_block, array( 'attrs', 'className' ), $updated_class_name );
+
+ // Process the custom CSS using the same method as global styles.
+ $selector = '.' . $class_name;
+ $processed_css = WP_Theme_JSON::process_blocks_custom_css( $custom_css, $selector );
+
+ if ( ! empty( $processed_css ) ) {
+ /*
+ * Register and add inline style for block custom CSS.
+ * The style depends on global-styles to ensure custom CSS loads after
+ * and can override global styles.
+ */
+ wp_register_style( 'wp-block-custom-css', false, array( 'global-styles' ) );
+ wp_add_inline_style( 'wp-block-custom-css', $processed_css );
+ }
+
+ return $parsed_block;
+}
+
+/**
+ * Enqueues the block custom CSS styles.
+ *
+ * @since 7.0.0
+ */
+function wp_enqueue_block_custom_css() {
+ wp_enqueue_style( 'wp-block-custom-css' );
+}
+
+/**
+ * Applies the custom CSS class name to the block's rendered HTML.
+ *
+ * The class name is generated in `wp_render_custom_css_support_styles`
+ * and stored in block attributes. This filter adds it to the actual markup.
+ *
+ * @since 7.0.0
+ *
+ * @param string $block_content Rendered block content.
+ * @param array $block Block object.
+ * @return string Filtered block content.
+ */
+function wp_render_custom_css_class_name( $block_content, $block ) {
+ $class_string = $block['attrs']['className'] ?? '';
+ preg_match( '/\bwp-custom-css-\S+\b/', $class_string, $matches );
+
+ if ( empty( $matches ) ) {
+ return $block_content;
+ }
+
+ $tags = new WP_HTML_Tag_Processor( $block_content );
+
+ if ( $tags->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
);