From 34c4e0b1c61f718b0e10ba1676b0505b0e32ee3e Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:46:11 +0800 Subject: [PATCH 1/5] Block Supports: Add the transform block support with a rotate feature. Backports the PHP side of Gutenberg PR 86240: the transform block support, the transform.rotate setting and style in WP_Theme_JSON (blocks and block style variations only), the style engine rotate definition, and rotate in KSES limited to a single angle. --- src/wp-includes/block-supports/transform.php | 142 +++++++++++++ src/wp-includes/class-wp-theme-json.php | 29 ++- src/wp-includes/kses.php | 9 + .../style-engine/class-wp-style-engine.php | 38 ++++ src/wp-includes/theme.json | 3 + src/wp-settings.php | 1 + tests/phpunit/tests/block-supports/states.php | 72 +++++++ .../wpRenderTransformSupport.php | 136 +++++++++++++ tests/phpunit/tests/kses.php | 42 ++++ .../tests/style-engine/styleEngine.php | 26 +++ tests/phpunit/tests/theme/wpThemeJson.php | 187 ++++++++++++++++++ 11 files changed, 681 insertions(+), 4 deletions(-) create mode 100644 src/wp-includes/block-supports/transform.php create mode 100644 tests/phpunit/tests/block-supports/wpRenderTransformSupport.php diff --git a/src/wp-includes/block-supports/transform.php b/src/wp-includes/block-supports/transform.php new file mode 100644 index 0000000000000..ec1a1aacd0078 --- /dev/null +++ b/src/wp-includes/block-supports/transform.php @@ -0,0 +1,142 @@ +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; + } + + if ( block_has_support( $block_type, 'transform', false ) ) { + $block_type->attributes['style'] = array( + 'type' => 'object', + ); + } +} + +/** + * Renders a block's transform styles. + * + * Features are output as inline styles on the block wrapper. When the block + * has a transform feature selector, e.g. `.wp-block-image img`, they are + * output instead to the block supports stylesheet under that selector, so the + * wrapper and anything else in it, such as a caption, are left as they are. + * A feature that skips serialization is left for the block to apply. + * + * State styles, such as `:hover` or `@mobile`, are rendered by the block + * states support. + * + * @since 7.3.0 + * @access private + * + * @param string $block_content Rendered block content. + * @param array $block Block object. + * @return string Filtered block content. + */ +function wp_render_transform_support( $block_content, $block ) { + if ( empty( $block_content ) || empty( $block['blockName'] ) ) { + return $block_content; + } + + $transform = $block['attrs']['style']['transform'] ?? null; + if ( empty( $transform ) || ! is_array( $transform ) ) { + return $block_content; + } + + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + if ( ! $block_type || ! block_has_support( $block_type, 'transform', false ) ) { + return $block_content; + } + + $inline_styles = array(); + $selector_styles = array(); + + foreach ( array( 'rotate' ) as $feature ) { + if ( + ! isset( $transform[ $feature ] ) || + ! block_has_support( $block_type, array( 'transform', $feature ), false ) || + wp_should_skip_block_supports_serialization( $block_type, 'transform', $feature ) + ) { + continue; + } + + $selector = wp_get_block_css_selector( $block_type, array( 'transform', $feature ) ) ?? wp_get_block_css_selector( $block_type, 'transform' ); + if ( $selector ) { + $selector_styles[ $selector ][ $feature ] = $transform[ $feature ]; + } else { + $inline_styles[ $feature ] = $transform[ $feature ]; + } + } + + $processor = new WP_HTML_Tag_Processor( $block_content ); + if ( ! $processor->next_tag() ) { + return $block_content; + } + + $inline_css = wp_style_engine_get_styles( array( 'transform' => $inline_styles ) )['css'] ?? ''; + if ( ! empty( $inline_css ) ) { + $existing_style = $processor->get_attribute( 'style' ); + $existing_style = is_string( $existing_style ) && '' !== trim( $existing_style ) ? rtrim( trim( $existing_style ), ';' ) . ';' : ''; + $processor->set_attribute( 'style', $existing_style . $inline_css ); + } + + $css_rules = array(); + foreach ( $selector_styles as $selector => $styles ) { + $declarations = wp_style_engine_get_styles( array( 'transform' => $styles ) )['declarations'] ?? array(); + if ( ! empty( $declarations ) ) { + $css_rules[] = array( + 'selector' => $selector, + 'declarations' => $declarations, + ); + } + } + + if ( ! empty( $css_rules ) ) { + $class_name = wp_unique_id_from_values( $css_rules, 'wp-transform-' ); + foreach ( $css_rules as $index => $css_rule ) { + $css_rules[ $index ]['selector'] = wp_build_state_selector( ".$class_name", $css_rule['selector'], '', $block['blockName'] ); + } + + wp_style_engine_get_stylesheet_from_css_rules( + $css_rules, + array( + 'context' => 'block-supports', + 'prettify' => false, + ) + ); + $processor->add_class( $class_name ); + } + + return $processor->get_updated_html(); +} + +// Register the block support. +WP_Block_Supports::get_instance()->register( + 'transform', + array( + 'register_attribute' => 'wp_register_transform_support', + ) +); + +add_filter( 'render_block', 'wp_render_transform_support', 10, 2 ); diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 15d08fba457a9..0fd360e3b6adb 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -261,6 +261,7 @@ class WP_Theme_JSON { * Added `text-indent` property. * @since 7.1.0 Added `min-width` and `text-shadow`. * @since 7.2.0 Added `background-clip`. + * @since 7.3.0 Added `rotate`. * @var array */ const PROPERTIES_METADATA = array( @@ -318,6 +319,7 @@ class WP_Theme_JSON { 'padding-right' => array( 'spacing', 'padding', 'right' ), 'padding-bottom' => array( 'spacing', 'padding', 'bottom' ), 'padding-left' => array( 'spacing', 'padding', 'left' ), + 'rotate' => array( 'transform', 'rotate' ), '--wp--style--root--padding' => array( 'spacing', 'padding' ), '--wp--style--root--padding-top' => array( 'spacing', 'padding', 'top' ), '--wp--style--root--padding-right' => array( 'spacing', 'padding', 'right' ), @@ -435,6 +437,7 @@ class WP_Theme_JSON { * Added support for `background.gradient`, `dimensions.minWidth` and `blockVisibility.allowEditing`. * @since 7.2.0 Added support for `typography.textShadow`, `typography.textShadowPresets`, * and `typography.defaultTextShadowPresets`. + * @since 7.3.0 Added support for `transform.rotate`. * @var array */ const VALID_SETTINGS = array( @@ -512,6 +515,9 @@ class WP_Theme_JSON { 'presets' => null, 'defaultPresets' => null, ), + 'transform' => array( + 'rotate' => null, + ), 'typography' => array( 'fluid' => null, 'customFontSize' => null, @@ -588,6 +594,7 @@ class WP_Theme_JSON { * @since 7.1.0 Added support for `background.gradient`,`dimensions.minWidth`, * and `typography.textShadow`. * @since 7.2.0 Added support for `background.backgroundClip`. + * @since 7.3.0 Added support for `transform.rotate`, for blocks and block style variations only. * @var array */ const VALID_STYLES = array( @@ -637,6 +644,9 @@ class WP_Theme_JSON { 'padding' => null, 'blockGap' => null, ), + 'transform' => array( + 'rotate' => null, + ), 'typography' => array( 'fontFamily' => null, 'fontSize' => null, @@ -1365,18 +1375,22 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n * - block level elements: `$schema['styles']['blocks']['core/button']['elements']['link'][':hover']`. * - block responsive elements: `$schema['styles']['blocks']['core/button']['@tablet']['elements']['link'][':hover']`. */ + // Transforms are only for blocks and block style variations, not the root or elements. + $element_styles = $styles_non_top_level; + unset( $element_styles['transform'] ); + foreach ( $valid_element_names as $element ) { - $schema_styles_elements[ $element ] = $styles_non_top_level; + $schema_styles_elements[ $element ] = $element_styles; if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] ) ) { foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] as $pseudo_selector ) { - $schema_styles_elements[ $element ][ $pseudo_selector ] = $styles_non_top_level; + $schema_styles_elements[ $element ][ $pseudo_selector ] = $element_styles; } } // Add responsive breakpoint states for elements. foreach ( array_keys( $responsive_media_queries ) as $breakpoint_state ) { - $schema_styles_elements[ $element ][ $breakpoint_state ] = $styles_non_top_level; + $schema_styles_elements[ $element ][ $breakpoint_state ] = $element_styles; } } @@ -1507,6 +1521,7 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema['settings'] = static::VALID_SETTINGS; $schema['settings']['blocks'] = $schema_settings_blocks; $schema['settings']['typography']['fontFamilies'] = static::schema_in_root_and_per_origin( static::FONT_FAMILY_SCHEMA ); + unset( $schema['styles']['transform'] ); /* * Add block style variation states to the top-level styles schema. @@ -1524,7 +1539,8 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n * theme.json files are unaffected. */ if ( ! empty( $input['blockTypes'] ) && is_array( $input['blockTypes'] ) ) { - $variation_pseudo_selectors = array(); + $schema['styles']['transform'] = static::VALID_STYLES['transform']; + $variation_pseudo_selectors = array(); foreach ( $input['blockTypes'] as $variation_block_type ) { if ( is_string( $variation_block_type ) && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $variation_block_type ] ) ) { $variation_pseudo_selectors = array_merge( @@ -3264,6 +3280,11 @@ protected static function compute_style_properties( $styles, $settings = array() continue; } + // Mirrors the style engine, which outputs a single angle only. + if ( 'rotate' === $css_property && ( ! is_string( $value ) || ! preg_match( WP_Style_Engine::VALID_ANGLE_PATTERN, $value ) ) ) { + continue; + } + // Calculates fluid typography rules where available. if ( 'font-size' === $css_property ) { /* diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index f2195e2f3335a..34eb2de041c8b 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3727,6 +3727,7 @@ function kses_init() { * and URLs in the SVG element reference properties. * @since 7.2.0 Added support for CSS anchor positioning properties and `text-shadow`. * Added support for `background-clip`. + * @since 7.3.0 Added support for `rotate`, limited to a single angle. * * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. * @param string $deprecated Not used. @@ -3976,6 +3977,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { 'transform', 'transform-origin', + 'rotate', 'pointer-events', 'visibility', @@ -4047,6 +4049,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $found = false; $url_attr = false; $gradient_attr = false; + $angle_attr = false; $is_custom_var = false; if ( ! str_contains( $css_item, ':' ) ) { @@ -4065,6 +4068,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $found = true; $url_attr = in_array( $css_selector, $css_url_data_types, true ); $gradient_attr = in_array( $css_selector, $css_gradient_data_types, true ); + $angle_attr = 'rotate' === $css_selector; } if ( $is_custom_var ) { @@ -4146,6 +4150,11 @@ function safecss_filter_attr( $css, $deprecated = '' ) { */ $allow_css = 0 === preg_match( '%[\\\(&=}]|/\*%', $css_test_string ); + // Only allow a single angle for `rotate`, mirroring the style engine. + if ( $allow_css && $angle_attr ) { + $allow_css = 1 === preg_match( WP_Style_Engine::VALID_ANGLE_PATTERN, trim( $parts[1] ) ); + } + /** * Filters the check for unsafe CSS in `safecss_filter_attr`. * 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 81499d15e5905..ed6627a06437f 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine.php +++ b/src/wp-includes/style-engine/class-wp-style-engine.php @@ -28,6 +28,7 @@ * @since 6.7.0 Added support for typography.writingMode. * @since 7.0.0 Added support for typography.textIndent. * @since 7.2.0 Added support for typography.textShadow. + * @since 7.3.0 Added support for transform.rotate. */ #[AllowDynamicProperties] final class WP_Style_Engine { @@ -39,6 +40,14 @@ final class WP_Style_Engine { */ const VALID_BACKGROUND_CLIP_VALUES = array( 'border-box', 'padding-box', 'content-box', 'text' ); + /** + * Matches a single CSS angle: a number with a `deg`, `grad`, `rad` or `turn` unit, or `0`. + * + * @since 7.3.0 + * @var string + */ + const VALID_ANGLE_PATTERN = '/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|grad|rad|turn)|0)$/i'; + /** * Style definitions that contain the instructions to parse/output valid Gutenberg styles from a block's attributes. * @@ -59,6 +68,7 @@ final class WP_Style_Engine { * @since 6.1.0 * @since 7.1.0 Added `background.gradient` property. * @since 7.2.0 Added `background.backgroundClip` property. + * @since 7.3.0 Added `transform.rotate` property. * @var array */ const BLOCK_STYLE_DEFINITIONS_METADATA = array( @@ -303,6 +313,15 @@ final class WP_Style_Engine { ), ), ), + 'transform' => array( + 'rotate' => array( + 'property_keys' => array( + 'default' => 'rotate', + ), + 'value_func' => array( self::class, 'get_angle_css_declarations' ), + 'path' => array( 'transform', 'rotate' ), + ), + ), 'typography' => array( 'fontSize' => array( 'property_keys' => array( @@ -821,6 +840,25 @@ protected static function get_background_clip_css_declarations( $style_value, $s return $css_declarations; } + /** + * Style value parser that returns a CSS definition array for a property that takes a single angle. + * + * Lists, functions such as `calc()` or `var()`, and bare numbers are rejected. + * + * @since 7.3.0 + * + * @param string $style_value A single raw style value from $block_styles array. + * @param array $style_definition A single style definition from BLOCK_STYLE_DEFINITIONS_METADATA. + * @return array An associative array of CSS definitions, mapping properties to values. + */ + protected static function get_angle_css_declarations( $style_value, $style_definition ) { + if ( ! is_string( $style_value ) || ! preg_match( static::VALID_ANGLE_PATTERN, $style_value ) ) { + return array(); + } + + return array( $style_definition['property_keys']['default'] => $style_value ); + } + /** * Returns compiled CSS from CSS declarations. * diff --git a/src/wp-includes/theme.json b/src/wp-includes/theme.json index 13f5bf4eee658..cf2624d9b9c9b 100644 --- a/src/wp-includes/theme.json +++ b/src/wp-includes/theme.json @@ -276,6 +276,9 @@ "unit": "rem" } }, + "transform": { + "rotate": true + }, "typography": { "customFontSize": true, "defaultFontSizes": true, diff --git a/src/wp-settings.php b/src/wp-settings.php index 3c5bc8542eaa8..35bdded8d2924 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -463,6 +463,7 @@ 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 . '/block-supports/transform.php'; require ABSPATH . WPINC . '/block-supports/states.php'; require ABSPATH . WPINC . '/style-engine.php'; require ABSPATH . WPINC . '/style-engine/class-wp-style-engine.php'; diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index eacd5d7c893d1..3a739ad80e591 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -1680,6 +1680,78 @@ public function test_responsive_pseudo_state_generates_media_query_scoped_css() ); } + /** + * Tests that a rotation in a state outputs the individual `rotate` property + * under the block's transform feature selector, or its root selector. + * + * @covers ::wp_render_block_states_support + * + * @ticket XXXXX + * + * @dataProvider data_rotation_state_generates_rotate_css + * + * @param string $block_name Block name. + * @param string $block_content Rendered block content. + * @param array $style Block style attribute. + * @param string $expected_css Expected CSS, with `%s` for the state class. + */ + public function test_rotation_state_generates_rotate_css( $block_name, $block_content, $style, $expected_css ) { + register_block_type( + 'test/transform-state', + array( + 'api_version' => 3, + 'selectors' => array( + 'transform' => '.wp-block-test-transform-state img', + ), + ) + ); + + $actual = wp_render_block_states_support( + $block_content, + array( + 'blockName' => $block_name, + 'attrs' => array( 'style' => $style ), + ) + ); + unregister_block_type( 'test/transform-state' ); + + preg_match( '/wp-states-[a-f0-9]{8}/', $actual, $matches ); + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertSame( sprintf( $expected_css, $matches[0] ?? '' ), $actual_stylesheet ); + $this->assertStringNotContainsString( 'transform', $actual_stylesheet ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_rotation_state_generates_rotate_css() { + $button = '
Click
'; + + return array( + 'hover' => array( + 'core/button', + $button, + array( ':hover' => array( 'transform' => array( 'rotate' => '10deg' ) ) ), + '.%s .wp-block-button__link:hover{rotate:10deg !important;}', + ), + 'hover viewport' => array( + 'core/button', + $button, + array( '@mobile' => array( ':hover' => array( 'transform' => array( 'rotate' => '-10deg' ) ) ) ), + '@media (width <= 480px){.%s .wp-block-button__link:hover{rotate:-10deg !important;}}', + ), + 'viewport' => array( + 'test/transform-state', + '
Caption
', + array( '@mobile' => array( 'transform' => array( 'rotate' => '0' ) ) ), + '@media (width <= 480px){.%s img{rotate:0 !important;}}', + ), + ); + } + /** * Tests that a responsive block gap state generates layout spacing CSS. * diff --git a/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php new file mode 100644 index 0000000000000..62863c49c27de --- /dev/null +++ b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php @@ -0,0 +1,136 @@ +test_block_name = null; + WP_Style_Engine_CSS_Rules_Store::remove_all_stores(); + } + + public function tear_down() { + if ( $this->test_block_name ) { + unregister_block_type( $this->test_block_name ); + } + $this->test_block_name = null; + WP_Style_Engine_CSS_Rules_Store::remove_all_stores(); + parent::tear_down(); + } + + /** + * Registers a block for testing transform support. + * + * @param array $supports Transform block support configuration. + * @param array $selectors Optional block selectors. + */ + private function register_transform_block( $supports, $selectors = array() ) { + $this->test_block_name = 'test/transform-block'; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( 'transform' => $supports ), + 'selectors' => $selectors, + ) + ); + } + + /** + * Renders a test block with a rotation. + * + * @param mixed $rotate Rotation value. + * @return string Rendered block content. + */ + private function render_rotated_block( $rotate ) { + return wp_render_transform_support( + '
', + array( + 'blockName' => $this->test_block_name, + 'attrs' => array( + 'style' => array( + 'transform' => array( 'rotate' => $rotate ), + ), + ), + ) + ); + } + + public function test_rotation_is_added_to_the_wrapper_inline_style() { + $this->register_transform_block( array( 'rotate' => true ) ); + + $this->assertSame( + '
', + $this->render_rotated_block( '15deg' ) + ); + } + + public function test_rotation_is_output_under_the_transform_feature_selector() { + $this->register_transform_block( + array( 'rotate' => true ), + array( 'transform' => '.wp-block-test-transform-block img' ) + ); + + $actual = $this->render_rotated_block( '0.25turn' ); + + $this->assertMatchesRegularExpression( + '/^
<\/div>$/', + $actual + ); + preg_match( '/wp-transform-[a-z0-9]+/', $actual, $matches ); + $this->assertSame( + '.' . $matches[0] . ' img{rotate:0.25turn;}', + wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ) + ); + } + + /** + * @dataProvider data_unchanged_block_content + * + * @param array $supports Transform block support configuration. + * @param mixed $rotate Rotation value. + */ + public function test_block_content_is_unchanged( $supports, $rotate ) { + $this->register_transform_block( $supports, array( 'transform' => '.wp-block-test-transform-block img' ) ); + + $this->assertSame( + '
', + $this->render_rotated_block( $rotate ) + ); + $this->assertEmpty( wp_style_engine_get_stylesheet_from_context( 'block-supports', array() ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_unchanged_block_content() { + return array( + 'no rotate support' => array( array( 'scale' => true ), '15deg' ), + 'skip serialization' => array( + array( + 'rotate' => true, + '__experimentalSkipSerialization' => array( 'rotate' ), + ), + '15deg', + ), + 'no rotation' => array( array( 'rotate' => true ), null ), + 'invalid rotation' => array( array( 'rotate' => true ), 'calc(10deg + 5deg)' ), + ); + } +} diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 4fe2eb69b197b..0c8c0c04304ea 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -1287,6 +1287,7 @@ public function test_wp_kses_attr_no_attributes_allowed_with_false() { * @ticket 64974 * @ticket 65832 * @ticket 64972 + * @ticket XXXXX * * @dataProvider data_safecss_filter_attr * @@ -1966,6 +1967,47 @@ public static function data_safecss_filter_attr() { 'css' => 'anchor-name: --tooltip;anchor-scope: all;position-anchor: --tooltip;position-area: top;position-try: flip-block;position-try-fallbacks: --fallback;position-try-order: most-height;position-visibility: anchors-visible', 'expected' => 'anchor-name: --tooltip;anchor-scope: all;position-anchor: --tooltip;position-area: top;position-try: flip-block;position-try-fallbacks: --fallback;position-try-order: most-height;position-visibility: anchors-visible', ), + // `rotate` introduced in 7.3, limited to a single angle. + array( + 'css' => 'rotate: -15deg', + 'expected' => 'rotate: -15deg', + ), + array( + 'css' => 'rotate: 0.25turn', + 'expected' => 'rotate: 0.25turn', + ), + array( + 'css' => 'rotate: 1.5rad', + 'expected' => 'rotate: 1.5rad', + ), + array( + 'css' => 'rotate: 100grad', + 'expected' => 'rotate: 100grad', + ), + array( + 'css' => 'rotate: 0', + 'expected' => 'rotate: 0', + ), + array( + 'css' => 'rotate: 45', + 'expected' => '', + ), + array( + 'css' => 'rotate: x 45deg', + 'expected' => '', + ), + array( + 'css' => 'rotate: 1 0 0 45deg', + 'expected' => '', + ), + array( + 'css' => 'rotate: calc(10deg + 5deg)', + 'expected' => '', + ), + array( + 'css' => 'rotate: var(--angle)', + 'expected' => '', + ), ); } diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index af6444d9bf88d..1010e8b96c5b0 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -26,6 +26,7 @@ class Tests_wpStyleEngine extends WP_UnitTestCase { * @ticket 64974 * @ticket 65037 * @ticket 66094 + * @ticket XXXXX * * @covers ::wp_style_engine_get_styles * @@ -363,6 +364,31 @@ public static function data_wp_style_engine_get_styles() { ), ), + 'inline_valid_transform_rotate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'rotate' => '-0.25turn', + ), + ), + 'options' => null, + 'expected_output' => array( + 'css' => 'rotate:-0.25turn;', + 'declarations' => array( + 'rotate' => '-0.25turn', + ), + ), + ), + + 'inline_invalid_transform_rotate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'rotate' => '1 0 0 45deg', + ), + ), + 'options' => null, + 'expected_output' => array(), + ), + 'valid_classnames_deduped' => array( 'block_styles' => array( 'color' => array( diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 12feba3b3ab4f..850c69c21ad39 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -3201,6 +3201,193 @@ public function test_get_stylesheet_returns_outline_styles() { * @ticket 60343 * @ticket 61165 */ + /** + * Tests that block transforms output the individual `rotate` property, under + * feature selectors, for block styles, block states and block style variations. + * + * @ticket XXXXX + */ + public function test_get_stylesheet_outputs_rotate_for_block_styles_states_and_variations() { + register_block_type( + 'test/rotate-feature', + array( + 'api_version' => 3, + 'selectors' => array( + 'root' => '.wp-block-test-rotate-feature', + 'transform' => '.wp-block-test-rotate-feature img', + ), + ) + ); + register_block_style( + 'test/rotate-feature', + array( + 'name' => 'tilted', + 'label' => 'Tilted', + ) + ); + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/rotate-feature' => array( + 'transform' => array( 'rotate' => '0.25turn' ), + '@mobile' => array( + 'transform' => array( 'rotate' => '0' ), + ), + 'variations' => array( + 'tilted' => array( + 'transform' => array( 'rotate' => '-5deg' ), + ), + ), + ), + 'core/button' => array( + ':hover' => array( + 'transform' => array( 'rotate' => '3deg' ), + ), + '@mobile' => array( + ':hover' => array( + 'transform' => array( 'rotate' => '6deg' ), + ), + ), + ), + ), + ), + ), + 'default' + ); + + $css = $theme_json->get_stylesheet( + array( 'styles' ), + null, + array( + 'skip_root_layout_styles' => true, + 'include_block_style_variations' => true, + ) + ); + unregister_block_style( 'test/rotate-feature', 'tilted' ); + unregister_block_type( 'test/rotate-feature' ); + + $this->assertStringContainsString( ':root :where(.wp-block-test-rotate-feature img){rotate: 0.25turn;}', $css ); + $this->assertStringContainsString( '@media (width <= 480px){:root :where(.wp-block-test-rotate-feature img){rotate: 0;}}', $css ); + $this->assertStringContainsString( ':root :where(.wp-block-test-rotate-feature.is-style-tilted img){rotate: -5deg;}', $css ); + $this->assertStringContainsString( ':root :where(.wp-block-button .wp-block-button__link:hover){rotate: 3deg;}', $css ); + $this->assertStringContainsString( '@media (width <= 480px){:root :where(.wp-block-button .wp-block-button__link:hover){rotate: 6deg;}}', $css ); + $this->assertStringNotContainsString( 'transform', $css ); + } + + /** + * Tests that a rotation that is not a single angle is not output. + * + * @ticket XXXXX + * + * @dataProvider data_get_stylesheet_skips_invalid_rotate_values + * + * @param mixed $rotate Rotation value. + */ + public function test_get_stylesheet_skips_invalid_rotate_values( $rotate ) { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'transform' => array( 'rotate' => $rotate ), + ), + ), + ), + ), + 'default' + ); + + $this->assertStringNotContainsString( 'rotate', $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_get_stylesheet_skips_invalid_rotate_values() { + return array( + 'bare number' => array( '45' ), + 'calc()' => array( 'calc(10deg + 5deg)' ), + 'var()' => array( 'var(--angle)' ), + 'axis list' => array( 'x 45deg' ), + 'number type' => array( 45 ), + ); + } + + /** + * Tests that transform styles are kept for blocks and block style variations + * only, and that the transform setting is kept at every level. + * + * @ticket XXXXX + */ + public function test_sanitize_keeps_transform_styles_for_blocks_and_variations_only() { + register_block_style( + 'core/image', + array( + 'name' => 'tilted', + 'label' => 'Tilted', + ) + ); + $rotate = array( 'rotate' => '15deg' ); + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'transform' => array( 'rotate' => false ), + 'blocks' => array( + 'core/image' => array( + 'transform' => array( 'rotate' => true ), + ), + ), + ), + 'styles' => array( + 'transform' => $rotate, + 'elements' => array( + 'link' => array( 'transform' => $rotate ), + ), + 'blocks' => array( + 'core/image' => array( + 'transform' => $rotate, + 'variations' => array( + 'tilted' => array( 'transform' => $rotate ), + ), + ), + ), + ), + ) + ); + unregister_block_style( 'core/image', 'tilted' ); + + $this->assertEqualSetsWithIndex( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'transform' => array( 'rotate' => false ), + 'blocks' => array( + 'core/image' => array( + 'transform' => array( 'rotate' => true ), + ), + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/image' => array( + 'transform' => $rotate, + 'variations' => array( + 'tilted' => array( 'transform' => $rotate ), + ), + ), + ), + ), + ), + $theme_json->get_raw_data() + ); + } + public function test_get_stylesheet_custom_root_selector() { $theme_json = new WP_Theme_JSON( array( From 1d275e32532e077cba7a64dc3ef47dcdd99f9ea8 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:41:47 +0800 Subject: [PATCH 2/5] Style Engine: Reject a rotate angle with a trailing newline. Adds the D modifier to WP_Style_Engine::VALID_ANGLE_PATTERN. Without it, $ matches before a final newline, so "45deg\n" passed validation. --- src/wp-includes/style-engine/class-wp-style-engine.php | 2 +- tests/phpunit/tests/style-engine/styleEngine.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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 ed6627a06437f..f08ad455c1eb3 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine.php +++ b/src/wp-includes/style-engine/class-wp-style-engine.php @@ -46,7 +46,7 @@ final class WP_Style_Engine { * @since 7.3.0 * @var string */ - const VALID_ANGLE_PATTERN = '/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|grad|rad|turn)|0)$/i'; + const VALID_ANGLE_PATTERN = '/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|grad|rad|turn)|0)$/iD'; /** * Style definitions that contain the instructions to parse/output valid Gutenberg styles from a block's attributes. diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index 1010e8b96c5b0..317cba48b035e 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -389,6 +389,16 @@ public static function data_wp_style_engine_get_styles() { 'expected_output' => array(), ), + 'inline_newline_transform_rotate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'rotate' => "45deg\n", + ), + ), + 'options' => null, + 'expected_output' => array(), + ), + 'valid_classnames_deduped' => array( 'block_styles' => array( 'color' => array( From 9564130981906bdf81a236778ca5746208fc0386 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:39:28 +0800 Subject: [PATCH 3/5] Tests: Add the Trac ticket to the transform block support tests. --- tests/phpunit/tests/block-supports/states.php | 2 +- .../tests/block-supports/wpRenderTransformSupport.php | 2 +- tests/phpunit/tests/kses.php | 2 +- tests/phpunit/tests/style-engine/styleEngine.php | 2 +- tests/phpunit/tests/theme/wpThemeJson.php | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 3a739ad80e591..5ede507fccbe5 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -1686,7 +1686,7 @@ public function test_responsive_pseudo_state_generates_media_query_scoped_css() * * @covers ::wp_render_block_states_support * - * @ticket XXXXX + * @ticket 66468 * * @dataProvider data_rotation_state_generates_rotate_css * diff --git a/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php index 62863c49c27de..7d614d7be87be 100644 --- a/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php @@ -2,7 +2,7 @@ /** * @group block-supports * - * @ticket XXXXX + * @ticket 66468 * * @covers ::wp_render_transform_support */ diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 0c8c0c04304ea..58dac6109906a 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -1287,7 +1287,7 @@ public function test_wp_kses_attr_no_attributes_allowed_with_false() { * @ticket 64974 * @ticket 65832 * @ticket 64972 - * @ticket XXXXX + * @ticket 66468 * * @dataProvider data_safecss_filter_attr * diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index 317cba48b035e..882754ffc04bb 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -26,7 +26,7 @@ class Tests_wpStyleEngine extends WP_UnitTestCase { * @ticket 64974 * @ticket 65037 * @ticket 66094 - * @ticket XXXXX + * @ticket 66468 * * @covers ::wp_style_engine_get_styles * diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 850c69c21ad39..23cdb4d1241f8 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -3205,7 +3205,7 @@ public function test_get_stylesheet_returns_outline_styles() { * Tests that block transforms output the individual `rotate` property, under * feature selectors, for block styles, block states and block style variations. * - * @ticket XXXXX + * @ticket 66468 */ public function test_get_stylesheet_outputs_rotate_for_block_styles_states_and_variations() { register_block_type( @@ -3279,7 +3279,7 @@ public function test_get_stylesheet_outputs_rotate_for_block_styles_states_and_v /** * Tests that a rotation that is not a single angle is not output. * - * @ticket XXXXX + * @ticket 66468 * * @dataProvider data_get_stylesheet_skips_invalid_rotate_values * @@ -3322,7 +3322,7 @@ public function data_get_stylesheet_skips_invalid_rotate_values() { * Tests that transform styles are kept for blocks and block style variations * only, and that the transform setting is kept at every level. * - * @ticket XXXXX + * @ticket 66468 */ public function test_sanitize_keeps_transform_styles_for_blocks_and_variations_only() { register_block_style( From 0a0c16eb3263f61b64e1eb37ea5d8f0ec052826b Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:42:15 +0800 Subject: [PATCH 4/5] Align the array arrows in the transform theme.json test --- tests/phpunit/tests/theme/wpThemeJson.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 23cdb4d1241f8..d38d93696c257 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -3241,7 +3241,7 @@ public function test_get_stylesheet_outputs_rotate_for_block_styles_states_and_v ), ), ), - 'core/button' => array( + 'core/button' => array( ':hover' => array( 'transform' => array( 'rotate' => '3deg' ), ), From 9d0468a1842ab317f0cb6c7659d2c942d217b9eb Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:00:48 +0800 Subject: [PATCH 5/5] Leave theme.json to the Gutenberg package sync. --- src/wp-includes/theme.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/wp-includes/theme.json b/src/wp-includes/theme.json index cf2624d9b9c9b..13f5bf4eee658 100644 --- a/src/wp-includes/theme.json +++ b/src/wp-includes/theme.json @@ -276,9 +276,6 @@ "unit": "rem" } }, - "transform": { - "rotate": true - }, "typography": { "customFontSize": true, "defaultFontSizes": true,