diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index e42731c09baf5..ad6d0ecc7a061 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -644,6 +644,19 @@ class WP_Theme_JSON { 'core/button' => array( ':hover', ':focus', ':focus-visible', ':active' ), ); + /** + * Responsive breakpoint state keys and their corresponding CSS media queries. + * These are available for all blocks and wrap their styles in the given media query. + * Keep in sync with RESPONSIVE_BREAKPOINTS in packages/global-styles-engine/src/core/render.tsx. + * + * @since 7.1.0 + * @var array + */ + const RESPONSIVE_BREAKPOINTS = array( + 'mobile' => '@media (width <= 480px)', + 'tablet' => '@media (480px < width <= 782px)', + ); + /** * The valid elements that can be found under styles. * @@ -1054,11 +1067,12 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema_styles_elements = array(); /* - * Set allowed element pseudo selectors based on per element allow list. + * Set allowed element pseudo selectors and responsive breakpoint states. * Target data structure in schema: * e.g. * - top level elements: `$schema['styles']['elements']['link'][':hover']`. * - block level elements: `$schema['styles']['blocks']['core/button']['elements']['link'][':hover']`. + * - block responsive elements: `$schema['styles']['blocks']['core/button']['tablet']['elements']['link'][':hover']`. */ foreach ( $valid_element_names as $element ) { $schema_styles_elements[ $element ] = $styles_non_top_level; @@ -1068,6 +1082,11 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema_styles_elements[ $element ][ $pseudo_selector ] = $styles_non_top_level; } } + + // Add responsive breakpoint states for elements. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) { + $schema_styles_elements[ $element ][ $breakpoint_state ] = $styles_non_top_level; + } } $schema_styles_blocks = array(); @@ -1075,19 +1094,31 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n /* * Generate a schema for blocks. - * - Block styles can contain `elements` & `variations` definitions. + * - Block styles can contain `elements`, `variations`, and responsive breakpoint state definitions. * - Variations definitions cannot be nested. - * - Variations can contain styles for inner `blocks`. - * - Variation inner `blocks` styles can contain `elements`. + * - Variations can contain styles for inner `blocks`, `elements`, and responsive breakpoint states. + * - Variation inner `blocks` styles can contain `elements` and responsive breakpoint states. * - * As each variation needs a `blocks` schema but further nested - * inner `blocks`, the overall schema will be generated in multiple passes. + * As each variation needs both a `blocks` schema and responsive `blocks` schemas + * for further nested inner `blocks`, the overall schema is generated in multiple passes. */ foreach ( $valid_block_names as $block ) { $schema_settings_blocks[ $block ] = static::VALID_SETTINGS; $schema_styles_blocks[ $block ] = $styles_non_top_level; $schema_styles_blocks[ $block ]['elements'] = $schema_styles_elements; + // Add responsive breakpoint states for all blocks. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) { + $schema_styles_blocks[ $block ][ $breakpoint_state ] = $styles_non_top_level; + $schema_styles_blocks[ $block ][ $breakpoint_state ]['elements'] = $schema_styles_elements; + + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { + $schema_styles_blocks[ $block ][ $breakpoint_state ][ $pseudo_selector ] = $styles_non_top_level; + } + } + } + // Add pseudo-selectors for blocks that support them if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { @@ -1119,6 +1150,19 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n foreach ( $style_variation_names as $variation_name ) { $variation_schema = $block_style_variation_styles; + // Add responsive breakpoint states to block style variations. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) { + $variation_schema[ $breakpoint_state ] = $styles_non_top_level; + $variation_schema[ $breakpoint_state ]['elements'] = $schema_styles_elements; + $variation_schema[ $breakpoint_state ]['blocks'] = $schema_styles_blocks; + + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { + $variation_schema[ $breakpoint_state ][ $pseudo_selector ] = $styles_non_top_level; + } + } + } + // Add pseudo-selectors to variations for blocks that support them if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { @@ -1886,6 +1930,11 @@ protected function get_layout_styles( $block_metadata, $options = array() ) { } } } + + if ( ! empty( $options['media_query'] ) && ! empty( $block_rules ) ) { + $block_rules = $options['media_query'] . '{' . $block_rules . '}'; + } + return $block_rules; } @@ -2873,6 +2922,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt } $variation_selectors = array(); + if ( $include_variations && isset( $node['variations'] ) ) { foreach ( $node['variations'] as $variation => $node ) { $variation_selectors[] = array( @@ -2887,56 +2937,166 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt 'path' => $node_path, 'selector' => $selector, 'selectors' => $feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), 'duotone' => $duotone_selector, - 'features' => $feature_selectors, 'variations' => $variation_selectors, 'css' => $selector, ); + // Responsive block nodes: emit one node per breakpoint that has styles. + // These are rendered immediately after the base block node so that + // the cascade order is: .block{} → @media{.block{}} + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ] ) ) { + $nodes[] = array( + 'name' => $name, + 'path' => array( 'styles', 'blocks', $name, $breakpoint ), + 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ], + 'selector' => $selector, + 'selectors' => $feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), + 'variations' => $variation_selectors, + 'css' => $selector, + ); + } + } + // Handle any pseudo selectors for the block. if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) { foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo_selector ) { - if ( isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ) ) { + $has_pseudo = isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ); + $has_responsive_pseudo = false; + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) { + $has_responsive_pseudo = true; + break; + } + } + + if ( ! $has_pseudo && ! $has_responsive_pseudo ) { + continue; + } + + /* + * Append the pseudo-selector to each feature selector so that + * get_feature_declarations_for_node generates CSS scoped to the + * pseudo-state (e.g. '.wp-block-button:hover') rather than the + * default state (e.g. '.wp-block-button'). + */ + $pseudo_feature_selectors = array(); + foreach ( $feature_selectors ?? array() as $feature => $feature_selector ) { + if ( is_array( $feature_selector ) ) { + $pseudo_feature_selectors[ $feature ] = array(); + foreach ( $feature_selector as $subfeature => $subfeature_selector ) { + $pseudo_feature_selectors[ $feature ][ $subfeature ] = static::append_to_selector( $subfeature_selector, $pseudo_selector ); + } + } else { + $pseudo_feature_selectors[ $feature ] = static::append_to_selector( $feature_selector, $pseudo_selector ); + } + } + + if ( $has_pseudo ) { $nodes[] = array( 'name' => $name, 'path' => array( 'styles', 'blocks', $name, $pseudo_selector ), 'selector' => static::append_to_selector( $selector, $pseudo_selector ), - 'selectors' => $feature_selectors, + 'selectors' => $pseudo_feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), 'duotone' => $duotone_selector, 'variations' => $variation_selectors, 'css' => static::append_to_selector( $selector, $pseudo_selector ), ); } + + // Responsive pseudo nodes: emit one node per breakpoint that has + // this pseudo state, immediately after the default pseudo node. + // Cascade order: .block:hover{} → @media{.block:hover{}} + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) { + $nodes[] = array( + 'name' => $name, + 'path' => array( 'styles', 'blocks', $name, $breakpoint, $pseudo_selector ), + 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ], + 'selector' => static::append_to_selector( $selector, $pseudo_selector ), + 'selectors' => $pseudo_feature_selectors, + 'elements' => $selectors[ $name ]['elements'] ?? array(), + 'variations' => $variation_selectors, + 'css' => static::append_to_selector( $selector, $pseudo_selector ), + ); + } + } } } } - if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) { foreach ( $theme_json['styles']['blocks'][ $name ]['elements'] as $element => $node ) { - $node_path = array( 'styles', 'blocks', $name, 'elements', $element ); - + $element_path = array( 'styles', 'blocks', $name, 'elements', $element ); if ( $include_node_paths_only ) { $nodes[] = array( - 'path' => $node_path, + 'path' => $element_path, ); continue; } + $element_selector = $selectors[ $name ]['elements'][ $element ]; + $nodes[] = array( - 'path' => $node_path, - 'selector' => $selectors[ $name ]['elements'][ $element ], + 'path' => $element_path, + 'selector' => $element_selector, ); + // Responsive element nodes: one node per breakpoint that has + // styles for this element. Cascade: a{} → @media{a{}} + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ] ) ) { + $nodes[] = array( + 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ), + 'selector' => $element_selector, + 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ], + ); + } + } + // Handle any pseudo selectors for the element. if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] ) ) { foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] as $pseudo_selector ) { - if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'][ $element ][ $pseudo_selector ] ) ) { - $node_path = array( 'styles', 'blocks', $name, 'elements', $element ); + // Create element pseudo node if default or any responsive breakpoint has the pseudo. + $has_element_pseudo = isset( $theme_json['styles']['blocks'][ $name ]['elements'][ $element ][ $pseudo_selector ] ); + if ( ! $has_element_pseudo ) { + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $bp ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $bp ]['elements'][ $element ][ $pseudo_selector ] ) ) { + $has_element_pseudo = true; + break; + } + } + } + + if ( $has_element_pseudo ) { + $element_pseudo_path = array( 'styles', 'blocks', $name, 'elements', $element ); + if ( $include_node_paths_only ) { + $nodes[] = array( + 'path' => $element_pseudo_path, + ); + continue; + } $nodes[] = array( - 'path' => $node_path, - 'selector' => static::append_to_selector( $selectors[ $name ]['elements'][ $element ], $pseudo_selector ), + 'path' => $element_pseudo_path, + 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ), ); + + // Responsive element pseudo nodes: one node per breakpoint + // that has this pseudo state for this element. + // Cascade: a:hover{} → @media{a:hover{}} + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ][ $pseudo_selector ] ) ) { + $nodes[] = array( + 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ), + 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ), + 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ], + ); + } + } } } } @@ -2965,16 +3125,19 @@ public function get_styles_for_block( $block_metadata ) { $settings = $this->theme_json['settings'] ?? array(); $feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $node ); $is_root_selector = static::ROOT_BLOCK_SELECTOR === $selector; + $media_query = $block_metadata['media_query'] ?? null; // Update text indent selector for paragraph blocks based on the textIndent setting. $block_name = $block_metadata['name'] ?? null; $feature_declarations = static::update_paragraph_text_indent_selector( $feature_declarations, $settings, $block_name ); + $block_elements = $block_metadata['elements'] ?? array(); // If there are style variations, generate the declarations for them, including any feature selectors the block may have. $style_variation_declarations = array(); $style_variation_custom_css = array(); + $style_variation_responsive_css = array(); $style_variation_layout_metadata = array(); - if ( ! empty( $block_metadata['variations'] ) ) { + if ( ! $media_query && ! empty( $block_metadata['variations'] ) ) { foreach ( $block_metadata['variations'] as $style_variation ) { $style_variation_node = _wp_array_get( $this->theme_json, $style_variation['path'], array() ); $clean_style_variation_selector = trim( $style_variation['selector'] ); @@ -3017,7 +3180,7 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { if ( isset( $block_metadata['name'] ) ) { $block_name = $block_metadata['name']; } elseif ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 3 ) { - $block_name = $block_metadata['path'][2]; + $block_name = static::get_block_name_from_metadata_path( $block_metadata ); } else { $block_name = null; } @@ -3040,6 +3203,134 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { 'node' => $style_variation_node, ); } + + // Store responsive breakpoint CSS for the style variation. + // This includes both base properties and feature-level selectors. + $variation_responsive_css = ''; + + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( ! isset( $style_variation_node[ $breakpoint ] ) ) { + continue; + } + + $breakpoint_node = $style_variation_node[ $breakpoint ]; + $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ]; + // Process feature-level declarations for this breakpoint. + $breakpoint_feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $breakpoint_node ); + $breakpoint_feature_declarations = static::update_paragraph_text_indent_selector( $breakpoint_feature_declarations, $settings, $block_name ); + foreach ( $breakpoint_feature_declarations as $feature_selector => $feature_decl ) { + $clean_feature_selector = preg_replace( '/,\s+/', ',', $feature_selector ); + $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_feature_selector ); + + if ( $block_metadata['selector'] && ! str_contains( $clean_feature_selector, $block_metadata['selector'] ) ) { + /* + * Feature selector is block-level (e.g. `.wp-block-button` for + * dimensions/width) — apply the variation class directly to it. + */ + $feature_element_selector = str_replace( $shortened_selector, '', $clean_style_variation_selector ); + $combined_selectors = str_replace( $feature_element_selector, '', $clean_style_variation_selector ); + } else { + // Prepend the variation selector to the current selector. + $split_selectors = explode( ',', $shortened_selector ); + $updated_selectors = array_map( + static function ( $split_selector ) use ( $clean_style_variation_selector ) { + return $clean_style_variation_selector . $split_selector; + }, + $split_selectors + ); + $combined_selectors = implode( ',', $updated_selectors ); + } + + $feature_ruleset = static::to_ruleset( ':root :where(' . $combined_selectors . ')', $feature_decl ); + $variation_responsive_css .= $breakpoint_media . '{' . $feature_ruleset . '}'; + } + + // Process base properties for this breakpoint. + $breakpoint_declarations = static::compute_style_properties( $breakpoint_node, $settings, null, $this->theme_json ); + if ( ! empty( $breakpoint_declarations ) ) { + $base_ruleset = static::to_ruleset( ':root :where(' . $style_variation['selector'] . ')', $breakpoint_declarations ); + $variation_responsive_css .= $breakpoint_media . '{' . $base_ruleset . '}'; + } + + $breakpoint_pseudo_declarations = static::process_pseudo_selectors( $breakpoint_node, $style_variation['selector'], $settings, $block_name ); + foreach ( $breakpoint_pseudo_declarations as $pseudo_selector => $pseudo_declarations ) { + if ( empty( $pseudo_declarations ) ) { + continue; + } + $pseudo_ruleset = static::to_ruleset( ':root :where(' . $pseudo_selector . ')', $pseudo_declarations ); + $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_ruleset . '}'; + } + + // Process custom CSS for this breakpoint. + if ( isset( $breakpoint_node['css'] ) ) { + $breakpoint_custom_css = static::process_blocks_custom_css( $breakpoint_node['css'], $style_variation['selector'] ); + $variation_responsive_css .= $breakpoint_media . '{' . $breakpoint_custom_css . '}'; + } + + // Process blockGap responsive layout styles for this variation. + if ( isset( $breakpoint_node['spacing']['blockGap'] ) ) { + $variation_layout_metadata = $style_variation; + $variation_layout_metadata['selector'] = $style_variation['selector'] . $block_metadata['css']; + $variation_responsive_css .= $this->get_layout_styles( + $variation_layout_metadata, + array( + 'node' => $breakpoint_node, + 'media_query' => $breakpoint_media, + ) + ); + } + + // Process nested element styles for this breakpoint state. + if ( isset( $breakpoint_node['elements'] ) && ! empty( $block_elements ) ) { + foreach ( $breakpoint_node['elements'] as $element_name => $element_node ) { + if ( ! isset( $block_elements[ $element_name ] ) ) { + continue; + } + + $clean_element_selector = preg_replace( '/,\s+/', ',', $block_elements[ $element_name ] ); + $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_element_selector ); + $split_selectors = explode( ',', $shortened_selector ); + $updated_selectors = array_map( + static function ( $split_selector ) use ( $clean_style_variation_selector ) { + return $clean_style_variation_selector . $split_selector; + }, + $split_selectors + ); + $variation_element_selector = implode( ',', $updated_selectors ); + + $element_declarations = static::compute_style_properties( $element_node, $settings, null, $this->theme_json ); + if ( ! empty( $element_declarations ) ) { + $element_ruleset = static::to_ruleset( ':root :where(' . $variation_element_selector . ')', $element_declarations ); + $variation_responsive_css .= $breakpoint_media . '{' . $element_ruleset . '}'; + } + + if ( isset( $element_node['css'] ) ) { + $element_custom_css = static::process_blocks_custom_css( $element_node['css'], $variation_element_selector ); + $variation_responsive_css .= $breakpoint_media . '{' . $element_custom_css . '}'; + } + + if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) { + foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) { + if ( ! isset( $element_node[ $pseudo_selector ] ) ) { + continue; + } + + $pseudo_declarations = static::compute_style_properties( $element_node[ $pseudo_selector ], $settings, null, $this->theme_json ); + if ( empty( $pseudo_declarations ) ) { + continue; + } + + $pseudo_selector_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $variation_element_selector, $pseudo_selector ) . ')', $pseudo_declarations ); + $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_selector_ruleset . '}'; + } + } + } + } + } + + if ( ! empty( $variation_responsive_css ) ) { + $style_variation_responsive_css[ $style_variation['selector'] ] = $variation_responsive_css; + } } } /* @@ -3065,7 +3356,7 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $is_processing_block_pseudo = false; $block_pseudo_selector = null; if ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 4 ) { - $block_name = $block_metadata['path'][2]; // 'core/button' + $block_name = static::get_block_name_from_metadata_path( $block_metadata ); // 'core/button' $last_path_element = $block_metadata['path'][ count( $block_metadata['path'] ) - 1 ]; // ':hover' if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) && @@ -3107,7 +3398,7 @@ static function ( $pseudo_selector ) use ( $selector ) { } elseif ( $is_processing_block_pseudo ) { // Process block pseudo-selector styles // For block pseudo-selectors, we need to get the block data first, then access the pseudo-selector - $block_name = $block_metadata['path'][2]; // 'core/button' + $block_name = static::get_block_name_from_metadata_path( $block_metadata ); // 'core/button' $block_data = _wp_array_get( $this->theme_json, array( 'styles', 'blocks', $block_name ), array() ); $pseudo_data = $block_data[ $block_pseudo_selector ] ?? array(); @@ -3224,6 +3515,9 @@ static function ( $pseudo_selector ) use ( $selector ) { if ( isset( $style_variation_custom_css[ $style_variation_selector ] ) ) { $block_rules .= $style_variation_custom_css[ $style_variation_selector ]; } + if ( isset( $style_variation_responsive_css[ $style_variation_selector ] ) ) { + $block_rules .= $style_variation_responsive_css[ $style_variation_selector ]; + } } // 7. Generate and append any custom CSS rules. @@ -3231,6 +3525,13 @@ static function ( $pseudo_selector ) use ( $selector ) { $block_rules .= $this->process_blocks_custom_css( $node['css'], $selector ); } + // 8. Wrap the entire block output in a media query if this is a responsive node. + // Responsive nodes are created by get_block_nodes() for each breakpoint and carry + // a 'media_query' key. + if ( $media_query && ! empty( $block_rules ) ) { + $block_rules = $media_query . '{' . $block_rules . '}'; + } + return $block_rules; } @@ -3726,6 +4027,10 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme continue; } + $block_name = in_array( 'blocks', $metadata['path'], true ) + ? static::get_block_name_from_metadata_path( $metadata ) + : null; + // The global styles custom CSS is not sanitized, but can only be edited by users with 'edit_css' capability. if ( isset( $input['css'] ) && current_user_can( 'edit_css' ) ) { $output = $input; @@ -3751,6 +4056,34 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme } } + // Re-add and process responsive breakpoint styles. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $input[ $breakpoint ] ) ) { + $output[ $breakpoint ] = static::remove_insecure_styles( $input[ $breakpoint ] ); + + if ( isset( $input[ $breakpoint ]['elements'] ) ) { + $output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $input[ $breakpoint ]['elements'] ); + } + + if ( isset( $input[ $breakpoint ]['blocks'] ) ) { + $output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $input[ $breakpoint ]['blocks'] ); + } + + if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) { + if ( isset( $input[ $breakpoint ][ $pseudo_selector ] ) ) { + $output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $input[ $breakpoint ][ $pseudo_selector ] ); + } + } + } + + // Responsive custom CSS is allowed for users with 'edit_css' capability. + if ( isset( $input[ $breakpoint ]['css'] ) && current_user_can( 'edit_css' ) ) { + $output[ $breakpoint ]['css'] = $input[ $breakpoint ]['css']; + } + } + } + if ( ! empty( $output ) ) { _wp_array_set( $sanitized, $metadata['path'], $output ); } @@ -3772,6 +4105,34 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme $variation_output['elements'] = static::remove_insecure_element_styles( $variation_input['elements'] ); } + // Re-add and process responsive breakpoint styles for variations. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $variation_input[ $breakpoint ] ) ) { + $variation_output[ $breakpoint ] = static::remove_insecure_styles( $variation_input[ $breakpoint ] ); + + if ( isset( $variation_input[ $breakpoint ]['elements'] ) ) { + $variation_output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $variation_input[ $breakpoint ]['elements'] ); + } + + if ( isset( $variation_input[ $breakpoint ]['blocks'] ) ) { + $variation_output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $variation_input[ $breakpoint ]['blocks'] ); + } + + if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) { + if ( isset( $variation_input[ $breakpoint ][ $pseudo_selector ] ) ) { + $variation_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $variation_input[ $breakpoint ][ $pseudo_selector ] ); + } + } + } + + // Responsive custom CSS is allowed for users with 'edit_css' capability. + if ( isset( $variation_input[ $breakpoint ]['css'] ) && current_user_can( 'edit_css' ) ) { + $variation_output[ $breakpoint ]['css'] = $variation_input[ $breakpoint ]['css']; + } + } + } + if ( ! empty( $variation_output ) ) { _wp_array_set( $sanitized, $variation['path'], $variation_output ); } @@ -3832,6 +4193,21 @@ protected static function remove_insecure_element_styles( $elements ) { } } + // Re-add and process responsive breakpoint styles for elements. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $element_input[ $breakpoint ] ) ) { + $element_output[ $breakpoint ] = static::remove_insecure_styles( $element_input[ $breakpoint ] ); + + if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) { + foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) { + if ( isset( $element_input[ $breakpoint ][ $pseudo_selector ] ) ) { + $element_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $element_input[ $breakpoint ][ $pseudo_selector ] ); + } + } + } + } + } + $sanitized[ $element_name ] = $element_output; } } @@ -3855,6 +4231,21 @@ protected static function remove_insecure_inner_block_styles( $blocks ) { $block_output['elements'] = static::remove_insecure_element_styles( $block_input['elements'] ); } + // Re-add and process responsive breakpoint styles for inner blocks. + foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + if ( isset( $block_input[ $breakpoint ] ) ) { + $block_output[ $breakpoint ] = static::remove_insecure_styles( $block_input[ $breakpoint ] ); + + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] as $pseudo_selector ) { + if ( isset( $block_input[ $breakpoint ][ $pseudo_selector ] ) ) { + $block_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $block_input[ $breakpoint ][ $pseudo_selector ] ); + } + } + } + } + } + $sanitized[ $block_type ] = $block_output; } return $sanitized; @@ -4843,4 +5234,16 @@ protected static function get_valid_block_style_variations( $blocks_metadata = a return $valid_variations; } + + /** + * Extracts the block name from the block metadata path. + * + * @since 7.1 + * + * @param array $block_metadata Block metadata. + * @return string|null The block name or null if not found. + */ + private static function get_block_name_from_metadata_path( $block_metadata ) { + return $block_metadata['path'][2] ?? null; + } } diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index ef1da85ce76d3..7eda511e1d9ec 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -936,6 +936,329 @@ public function test_get_styles_for_block_handles_whitelisted_element_pseudo_sel $this->assertSame( $focus_style, $theme_json->get_styles_for_block( $focus_node ) ); } + /** + * @ticket 65164 + */ + public function test_get_styles_for_block_responsive_feature_selector_not_duplicated_on_base_selector() { + register_block_type( + 'test/responsive-feature', + array( + 'api_version' => 3, + 'selectors' => array( + 'root' => '.wp-block-test-responsive-feature', + 'color' => '.wp-block-test-responsive-feature .color-target', + ), + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/responsive-feature' => array( + 'mobile' => array( + 'color' => array( + 'text' => 'red', + ), + ), + ), + ), + ), + ) + ); + + $base_metadata = array( + 'name' => 'test/responsive-feature', + 'path' => array( 'styles', 'blocks', 'test/responsive-feature' ), + 'selector' => '.wp-block-test-responsive-feature', + 'selectors' => array( + 'color' => '.wp-block-test-responsive-feature .color-target', + ), + ); + + $mobile_metadata = array( + 'name' => 'test/responsive-feature', + 'path' => array( 'styles', 'blocks', 'test/responsive-feature', 'mobile' ), + 'selector' => '.wp-block-test-responsive-feature', + 'selectors' => array( + 'color' => '.wp-block-test-responsive-feature .color-target', + ), + 'media_query' => '@media (width <= 480px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $base_metadata ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_metadata ); + + unregister_block_type( 'test/responsive-feature' ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.wp-block-test-responsive-feature .color-target){color: red;}}', + $actual_styles + ); + $this->assertStringNotContainsString( + '@media (width <= 480px){:root :where(.wp-block-test-responsive-feature){color: red;}}', + $actual_styles + ); + } + + /** + * @ticket 65164 + */ + public function test_get_styles_for_block_outputs_responsive_block_gap_after_default_gap() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'spacing' => array( + 'blockGap' => true, + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'spacing' => array( + 'blockGap' => '5rem', + ), + 'mobile' => array( + 'spacing' => array( + 'blockGap' => '2rem', + ), + ), + ), + ), + ), + ) + ); + + $base_metadata = array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + ); + + $mobile_metadata = array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group', 'mobile' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + 'media_query' => '@media (width <= 480px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $base_metadata ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_metadata ); + + $default_gap = ':root :where(.wp-block-group-is-layout-flex){gap: 5rem;}'; + $mobile_gap = ':root :where(.wp-block-group-is-layout-flex){gap: 2rem;}'; + + $this->assertStringContainsString( $default_gap, $actual_styles ); + $this->assertStringContainsString( '@media (width <= 480px)', $actual_styles ); + $this->assertStringContainsString( $mobile_gap, $actual_styles ); + $this->assertLessThan( strpos( $actual_styles, $mobile_gap ), strpos( $actual_styles, $default_gap ) ); + } + + /** + * @ticket 65164 + */ + public function test_get_styles_for_block_responsive_element_pseudo_styles_preserve_order_and_do_not_duplicate_pseudo() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'blue', + ), + ':hover' => array( + 'color' => array( + 'text' => 'navy', + ), + ), + ), + ), + 'mobile' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'red', + ), + ':hover' => array( + 'color' => array( + 'text' => 'darkred', + ), + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $link_selector = '.wp-block-group a:where(:not(.wp-element-button))'; + + // Nodes are assembled in cascade order: default, responsive, pseudo, responsive pseudo. + $link_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'elements', 'link' ), + 'selector' => $link_selector, + ); + + $mobile_link_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'mobile', 'elements', 'link' ), + 'selector' => $link_selector, + 'media_query' => '@media (width <= 480px)', + ); + + $hover_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'elements', 'link' ), + 'selector' => $link_selector . ':hover', + ); + + $mobile_hover_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'mobile', 'elements', 'link' ), + 'selector' => $link_selector . ':hover', + 'media_query' => '@media (width <= 480px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $link_node ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_link_node ); + $actual_styles .= $theme_json->get_styles_for_block( $hover_node ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_hover_node ); + + $default_link = ':root :where(.wp-block-group a:where(:not(.wp-element-button))){color: blue;}'; + $mobile_link = '@media (width <= 480px){:root :where(.wp-block-group a:where(:not(.wp-element-button))){color: red;}}'; + $default_hov = ':root :where(.wp-block-group a:where(:not(.wp-element-button)):hover){color: navy;}'; + $mobile_hov = '@media (width <= 480px){:root :where(.wp-block-group a:where(:not(.wp-element-button)):hover){color: darkred;}}'; + + $this->assertStringContainsString( $default_link, $actual_styles ); + $this->assertStringContainsString( $mobile_link, $actual_styles ); + $this->assertStringContainsString( $default_hov, $actual_styles ); + $this->assertStringContainsString( $mobile_hov, $actual_styles ); + + $this->assertLessThan( strpos( $actual_styles, $mobile_link ), strpos( $actual_styles, $default_link ) ); + $this->assertLessThan( strpos( $actual_styles, $default_hov ), strpos( $actual_styles, $mobile_link ) ); + $this->assertLessThan( strpos( $actual_styles, $mobile_hov ), strpos( $actual_styles, $default_hov ) ); + $this->assertStringNotContainsString( ':hover:hover', $actual_styles ); + } + + /** + * @ticket 65164 + */ + public function test_get_styles_for_block_with_style_variations_and_responsive_block_gap() { + register_block_style( + 'core/group', + array( + 'name' => 'withGap', + 'label' => 'With Gap', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'spacing' => array( + 'blockGap' => true, + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'variations' => array( + 'withGap' => array( + 'spacing' => array( + 'blockGap' => '5rem', + ), + 'mobile' => array( + 'spacing' => array( + 'blockGap' => '2rem', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $metadata = array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + 'variations' => array( + array( + 'path' => array( 'styles', 'blocks', 'core/group', 'variations', 'withGap' ), + 'selector' => '.is-style-withGap.wp-block-group', + ), + ), + ); + + $actual_styles = $theme_json->get_styles_for_block( $metadata ); + + unregister_block_style( 'core/group', 'withGap' ); + + $default_gap = ':root :where(.is-style-withGap.wp-block-group.wp-block-group-is-layout-flex){gap: 5rem;}'; + $mobile_gap = ':root :where(.is-style-withGap.wp-block-group.wp-block-group-is-layout-flex){gap: 2rem;}'; + + $this->assertStringContainsString( $default_gap, $actual_styles ); + $this->assertStringContainsString( '@media (width <= 480px)', $actual_styles ); + $this->assertStringContainsString( $mobile_gap, $actual_styles ); + $this->assertLessThan( strpos( $actual_styles, $mobile_gap ), strpos( $actual_styles, $default_gap ) ); + } + + /** + * @ticket 65164 + */ + public function test_get_styles_for_block_outputs_tablet_responsive_styles_only() { + register_block_type( + 'test/tablet-only', + array( + 'api_version' => 3, + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/tablet-only' => array( + 'tablet' => array( + 'color' => array( + 'text' => 'purple', + ), + ), + ), + ), + ), + ) + ); + + $tablet_metadata = array( + 'name' => 'test/tablet-only', + 'path' => array( 'styles', 'blocks', 'test/tablet-only', 'tablet' ), + 'selector' => '.wp-block-test-tablet-only', + 'media_query' => '@media (480px < width <= 782px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $tablet_metadata ); + + unregister_block_type( 'test/tablet-only' ); + + $this->assertStringContainsString( + '@media (480px < width <= 782px){:root :where(.wp-block-test-tablet-only){color: purple;}}', + $actual_styles + ); + $this->assertStringNotContainsString( '@media (width <= 480px)', $actual_styles ); + } + /** * Tests that if an element has nothing but pseudo selector styles, they are still output by get_stylesheet. * @@ -2859,6 +3182,120 @@ public function test_remove_insecure_properties_removes_unsafe_styles_sub_proper $this->assertEqualSetsWithIndex( $expected, $actual ); } + /** + * @covers WP_Theme_JSON::remove_insecure_properties + * + * @ticket 65164 + */ + public function test_remove_insecure_properties_preserves_responsive_block_element_styles() { + $actual = WP_Theme_JSON::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-gray', + ), + 'mobile' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-pink', + ), + ), + 'tablet' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-red', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-gray)', + ), + 'mobile' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-pink)', + ), + ), + 'tablet' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-red)', + ), + ), + ), + ), + ), + ), + ), + ); + + $this->assertEqualSetsWithIndex( $expected, $actual ); + } + + /** + * @covers WP_Theme_JSON::remove_insecure_properties + * + * @ticket 65164 + */ + public function test_remove_insecure_properties_preserves_responsive_elements_within_block_state() { + $actual = WP_Theme_JSON::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'mobile' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-pink', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'mobile' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-pink)', + ), + ), + ), + ), + ), + ), + ), + ); + + $this->assertEqualSetsWithIndex( $expected, $actual ); + } + /** * @ticket 54336 */