Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 86 additions & 42 deletions src/wp-includes/block-supports/custom-css.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,13 @@ function wp_process_custom_css_state_entries( $state_entries, $selector ) {
}

/**
* Render the custom CSS stylesheet and add class name to block as required.
* Generates the custom CSS class name for a block and registers its stylesheet.
*
* @since 7.0.0
* @since 7.2.0
* @access private
*
* @param array $parsed_block The parsed block.
* @return array The same parsed block with custom CSS class name added if appropriate.
* @return string|null The generated class name, or null if the block has no custom CSS to render.
*
* @phpstan-param array{
* blockName: string|null,
Expand All @@ -143,51 +144,32 @@ function wp_process_custom_css_state_entries( $state_entries, $selector ) {
* },
* ...
* } $parsed_block
* @phpstan-return array{
* blockName: string|null,
* attrs: array{
* className?: string,
* style?: array{
* css?: string,
* ...
* },
* ...
* },
* ...
* }
*/
function wp_render_custom_css_support_styles( $parsed_block ) {
function wp_generate_custom_css_class_name( $parsed_block ) {
$style = $parsed_block['attrs']['style'] ?? null;
if ( empty( $style ) || ! is_array( $style ) ) {
return $parsed_block;
return null;
}

$block_type = WP_Block_Type_Registry::get_instance()->get_registered( $parsed_block['blockName'] );
if ( ! block_has_support( $block_type, 'customCSS', true ) ) {
return $parsed_block;
return null;
}

$viewport_settings = wp_get_global_settings( array( 'viewport' ) );
$responsive_media_queries = WP_Theme_JSON::get_viewport_media_queries( $viewport_settings );
$state_entries = wp_get_custom_css_state_entries( $style, $parsed_block['blockName'], $responsive_media_queries );
if ( empty( $state_entries ) ) {
return $parsed_block;
return null;
}

// Generate a unique class name for this block instance.
$class_name = wp_unique_id_from_values( $parsed_block, 'wp-custom-css-' );
$processed_css = wp_process_custom_css_state_entries( $state_entries, '.' . $class_name );
if ( '' === $processed_css ) {
return $parsed_block;
return null;
}

$existing_class_name = $parsed_block['attrs']['className'] ?? null;
$updated_class_name = is_string( $existing_class_name )
? "$existing_class_name $class_name"
: $class_name;

$parsed_block['attrs']['className'] = $updated_class_name;

/**
* Reuse one handle so identical custom CSS is enqueued only once via
* {@see wp_unique_id_from_values()}. Explicitly declare the `wp-block-library`
Expand All @@ -206,6 +188,55 @@ function wp_render_custom_css_support_styles( $parsed_block ) {
wp_add_inline_style( $handle, $processed_css );
}

return $class_name;
}

/**
* Render the custom CSS stylesheet and add class name to block as required.
*
* @since 7.0.0
*
* @param array $parsed_block The parsed block.
* @return array The same parsed block with custom CSS class name added if appropriate.
*
* @phpstan-param array{
* blockName: string|null,
* attrs: array{
* className?: string,
* style?: array{
* css?: string,
* ...
* },
* ...
* },
* ...
* } $parsed_block
* @phpstan-return array{
* blockName: string|null,
* attrs: array{
* className?: string,
* style?: array{
* css?: string,
* ...
* },
* ...
* },
* ...
* }
*/
function wp_render_custom_css_support_styles( $parsed_block ) {
$class_name = wp_generate_custom_css_class_name( $parsed_block );
if ( null === $class_name ) {
return $parsed_block;
}

$existing_class_name = $parsed_block['attrs']['className'] ?? null;
$updated_class_name = is_string( $existing_class_name )
? "$existing_class_name $class_name"
: $class_name;

$parsed_block['attrs']['className'] = $updated_class_name;

return $parsed_block;
}

Expand All @@ -221,41 +252,54 @@ function wp_enqueue_block_custom_css() {
/**
* Applies the custom CSS class name to the block's rendered HTML.
*
* The class name is generated in {@see wp_render_custom_css_support_styles()}
* and stored in block attributes. This filter adds it to the actual markup.
* Uses the class name that {@see wp_render_custom_css_support_styles()}
* stored in the block attributes. Blocks that a parent parsed and rendered
* itself never reach that filter, so the class name is generated here instead
* via {@see wp_generate_custom_css_class_name()}.
*
* @since 7.0.0
* @since 7.2.0 Generates the class name when the block attributes lack one.
*
* @param string $block_content Rendered block content.
* @param array $block Block object.
* @return string Filtered block content.
*
* @phpstan-param array{
* blockName: string|null,
* attrs: array{
* className?: string,
* style?: array{
* css?: string,
* ...
* },
* ...
* },
* ...
* } $block
*/
function wp_render_custom_css_class_name( $block_content, $block ) {
$class_name_attr = $block['attrs']['className'] ?? null;
$class_name_prefix = 'wp-custom-css-';
if ( ! is_string( $class_name_attr ) || ! str_contains( $class_name_attr, $class_name_prefix ) ) {
return $block_content;
}
$class_name_attr = $block['attrs']['className'] ?? null;
$class_name_prefix = 'wp-custom-css-';
$matched_class_name = null;

// Parse out the 'wp-custom-css-*' class name added by wp_render_custom_css_support_styles().
$matched_class_name = null;
$token_delimiter = " \t\f\r\n";
$class_token = strtok( $class_name_attr, $token_delimiter );
while ( false !== $class_token ) {
if ( str_starts_with( $class_token, $class_name_prefix ) ) {
$matched_class_name = $class_token;
break;
if ( is_string( $class_name_attr ) && str_contains( $class_name_attr, $class_name_prefix ) ) {
$token_delimiter = " \t\f\r\n";
$class_token = strtok( $class_name_attr, $token_delimiter );
while ( false !== $class_token ) {
if ( str_starts_with( $class_token, $class_name_prefix ) ) {
$matched_class_name = $class_token;
break;
}
$class_token = strtok( $token_delimiter );
}
$class_token = strtok( $token_delimiter );
}

// Direct WP_Block::render() calls skip render_block_data for the block itself.
if ( null === $matched_class_name ) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternative might be

if (
	null === $matched_class_name &&
	false !== has_filter(
		'render_block_data',
		'wp_render_custom_css_support_styles'
	)
) {
	$matched_class_name = wp_generate_custom_css_class_name( $block );
}

That does couple the fallback to the registration state of a private callback. I think it's okay.

A cleaner long term design would be an explicit opt-out filter checked by wp_generate_custom_css_class_name(). Sites that currently use remove_filter() would need to migrate to it.

// after the block supports check
if ( ! apply_filters( 'wp_should_render_block_custom_css', true, $parsed_block ) ) {
	return null;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we need to make either of these changes now. The has_filter() guard would stop the navigation fix for sites that replace the filter rather than remove it, like the theme I mentioned above. We're not sure if a new wp_should_render_block_custom_css filter is needed yet, and supports.customCSS already covers turning it off per block type. Could we handle these in follow-ups if they're required?

$matched_class_name = wp_generate_custom_css_class_name( $block );

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One behaviour change worth knowing about.

If a site has switched off block custom CSS by removing this one filter:

remove_filter( 'render_block_data', 'wp_render_custom_css_support_styles' );

Before this PR, that was enough.

After this PR, it isn't, and that site's block custom CSS shows up on the front end again.

That's because the other callback, wp_render_custom_css_class_name on render_block, now calls wp_generate_custom_css_class_name().

That helper outputs the CSS for any block that doesn't have its custom CSS class yet.

To keep custom CSS switched off, a site needs to remove both callbacks:

remove_filter( 'render_block_data', 'wp_render_custom_css_support_styles' );
remove_filter( 'render_block', 'wp_render_custom_css_class_name' );

To switch it off for one block type only, set supports.customCSS to false with the register_block_type_args filter.

The Gutenberg plugin already removes both callbacks, so it isn't affected.

I don't know whether any sites rely on removing just the one filter.

Should this go in the dev note, or is it too small to mention?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this go in the dev note, or is it too small to mention?

Yeah, I think we could mention this in the dev note, and I don't think we need to make any code changes. I've had a quick search around on GH, and I can only find one theme (xenolito/vertiGO-web) that removes both callbacks, but it removes the filter to replace it with its own class, not to turn custom CSS off. This should still work with this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the confidence check @mikachan 🙇🏻

}

if ( null === $matched_class_name ) {
return $block_content;
}
Expand Down
177 changes: 177 additions & 0 deletions tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@
* @covers ::wp_render_custom_css_class_name
*/
class Tests_Block_Supports_WpRenderCustomCssClassName extends WP_UnitTestCase {
/**
* @var string|null
*/
private $test_block_name;

public function set_up() {
parent::set_up();
$this->test_block_name = null;

global $wp_styles, $wp_script_modules;
$wp_styles = null;
$wp_script_modules = null;
}

public function tear_down() {
if ( $this->test_block_name ) {
unregister_block_type( $this->test_block_name );
}
$this->test_block_name = null;

global $wp_styles, $wp_script_modules;
$wp_styles = null;
$wp_script_modules = null;

parent::tear_down();
}

/**
* Tests that the custom CSS class name is applied to block content.
Expand Down Expand Up @@ -159,4 +185,155 @@ public static function data_returns_unchanged_content() {
),
);
}

/**
* Returns the custom CSS class name found in a string of rendered markup.
*
* @param string $markup Rendered block markup.
* @return string|null The custom CSS class name, or null if there is none.
*/
private function get_custom_css_class_name_from_markup( $markup ) {
return preg_match( '/\bwp-custom-css-[0-9a-f]+\b/', $markup, $matches ) ? $matches[0] : null;
}

/**
* Returns the custom CSS rules currently attached to the custom CSS stylesheet.
*
* @return string The inline custom CSS.
*/
private function get_enqueued_custom_css() {
$after = wp_styles()->get_data( 'wp-block-custom-css', 'after' );

return is_array( $after ) ? implode( "\n", $after ) : '';
}

/**
* Tests that the class name and stylesheet are still generated for blocks that
* are rendered without the `render_block_data` filter running.
*
* Direct `WP_Block::render()` calls skip `render_block_data` for the block
* itself, as when `core/navigation` renders a `wp_navigation` post's blocks.
*
* @ticket 66345
*
* @covers ::wp_render_custom_css_class_name
*/
public function test_generates_class_when_render_block_data_skipped() {
$this->test_block_name = 'test/custom-css-skipped-block-data';
register_block_type(
$this->test_block_name,
array(
'api_version' => 3,
'attributes' => array(
'style' => array(
'type' => 'object',
),
),
'supports' => array( 'customCSS' => true ),
)
);

$block = array(
'blockName' => 'test/custom-css-skipped-block-data',
'attrs' => array(
'style' => array(
'css' => 'padding: 0;',
),
),
);

$result = wp_render_custom_css_class_name( '<li class="wp-block-navigation-item">Home</li>', $block );
$class_name = $this->get_custom_css_class_name_from_markup( $result );

$this->assertNotNull( $class_name, 'A custom CSS class should be added even though render_block_data did not run.' );
$this->assertStringContainsString( 'has-custom-css', $result, 'The has-custom-css class should be added.' );
$this->assertStringContainsString( 'wp-block-navigation-item', $result, 'Existing classes should be preserved.' );
$this->assertStringContainsString(
'.' . $class_name,
$this->get_enqueued_custom_css(),
'The custom CSS stylesheet should contain a rule for the generated class.'
);
}

/**
* Tests that a block is not given a second class name when `render_block_data`
* has already generated one.
*
* @ticket 66345
*
* @covers ::wp_render_custom_css_class_name
*/
public function test_does_not_regenerate_existing_class() {
$this->test_block_name = 'test/custom-css-existing-class';
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-existing-class',
'attrs' => array(
'style' => array(
'css' => 'padding: 0;',
),
),
);

$filtered = wp_render_custom_css_support_styles( $parsed_block );
$result = wp_render_custom_css_class_name( '<p>Test content</p>', $filtered );

$this->assertSame(
1,
preg_match_all( '/wp-custom-css-[0-9a-f]+/', $result ),
'Only the class name generated by render_block_data should be added.'
);
$this->assertStringContainsString(
$filtered['attrs']['className'],
$result,
'The class name generated by render_block_data should be the one added to the markup.'
);
}

/**
* Tests that blocks inside a navigation menu get their custom CSS on the front end.
*
* @ticket 66345
*
* @covers ::wp_render_custom_css_class_name
*/
public function test_custom_css_is_rendered_for_blocks_inside_a_navigation_menu() {
/*
* Custom CSS is stripped on save for users without the `edit_css` capability,
* which the default test user does not have. Hooks are restored on tear down.
*/
remove_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks', 8 );

$navigation_id = self::factory()->post->create(
array(
'post_type' => 'wp_navigation',
'post_title' => 'Custom CSS Menu',
'post_status' => 'publish',
'post_content' => '<!-- wp:navigation-link {"label":"Home","url":"/","kind":"custom","isTopLevelLink":true,"style":{"css":"padding: 0;"}} /-->',
)
);

$rendered = do_blocks( '<!-- wp:navigation {"ref":' . $navigation_id . '} /-->' );

$class_name = $this->get_custom_css_class_name_from_markup( $rendered );

$this->assertNotNull( $class_name, 'The navigation link should be given a custom CSS class.' );
$this->assertStringContainsString(
'.' . $class_name,
$this->get_enqueued_custom_css(),
'The custom CSS stylesheet should contain a rule for the navigation link.'
);
}
}
Loading