PluginProbe
Gutenberg / 24.0.0
Gutenberg v24.0.0
24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 All 403 releases
← All changes | lib/class-wp-theme-json-gutenberg.php +782 -243 23.2.2 → 24.0.0 View file →
@@ -240,9 +240,9 @@
240 240 * removed the `--wp--style--block-gap` property.
241 241 * @since 6.2.0 Added `outline-*`, and `min-height` properties.
242 242 * @since 6.3.0 Added `writing-mode` property.
243 243 * @since 6.6.0 Added `background-[image|position|repeat|size]` properties.
244 - * @since 7.0.0 Added `dimensions.width`, `dimensions.height`. and
244 + * @since 7.0.0 Added `dimensions.width`, `dimensions.height`, and
245 245 * `typography.textIndent` properties.
246 246 *
247 247 * @var array
248 248 */
@@ -305,8 +305,9 @@
305 305 '--wp--style--root--padding-right' => array( 'spacing', 'padding', 'right' ),
306 306 '--wp--style--root--padding-bottom' => array( 'spacing', 'padding', 'bottom' ),
307 307 '--wp--style--root--padding-left' => array( 'spacing', 'padding', 'left' ),
308 308 'text-decoration' => array( 'typography', 'textDecoration' ),
309 + 'text-shadow' => array( 'typography', 'textShadow' ),
309 310 'text-transform' => array( 'typography', 'textTransform' ),
310 311 'text-indent' => array( 'typography', 'textIndent' ),
311 312 'filter' => array( 'filter', 'duotone' ),
312 313 'box-shadow' => array( 'shadow' ),
@@ -388,10 +389,11 @@
388 389 * @since 6.3.0 Removed `layout.definitions`. Added `typography.writingMode`.
389 390 * @since 6.4.0 Added `layout.allowEditing`.
390 391 * @since 6.4.0 Added `lightbox`.
391 392 * @since 7.0.0 Added type markers to the schema for boolean values.
392 - * @since 7.0.0 Added `dimensions.width`, `dimensions.height`. and
393 + * @since 7.0.0 Added `dimensions.width`, `dimensions.height`, and
393 394 * `typography.textIndent` properties.
395 + * @since 7.1.0 Added `viewport` property.
394 396 * @var array
395 397 */
396 398 const VALID_SETTINGS = array(
397 399 'appearanceTools' => null,
@@ -400,8 +402,11 @@
400 402 'backgroundImage' => null,
401 403 'backgroundSize' => null,
402 404 'gradient' => null,
403 405 ),
406 + 'blockVisibility' => array(
407 + 'allowEditing' => true,
408 + ),
404 409 'border' => array(
405 410 'color' => null,
406 411 'radius' => null,
407 412 'style' => null,
@@ -481,8 +486,12 @@
481 486 'textIndent' => null,
482 487 'textTransform' => null,
483 488 'writingMode' => null,
484 489 ),
490 + 'viewport' => array(
491 + 'mobile' => null,
492 + 'tablet' => null,
493 + ),
485 494 );
486 495
487 496 const FONT_FAMILY_SCHEMA = array(
488 497 array(
@@ -521,9 +530,9 @@
521 530 * updated `blockGap` to be allowed at any level.
522 531 * @since 6.2.0 Added `outline`, and `minHeight` properties.
523 532 * @since 6.6.0 Added `background` sub properties to top-level only.
524 533 * @since 6.6.0 Added `dimensions.aspectRatio`.
525 - * @since 7.0.0 Added `dimensions.width`, `dimensions.height`. and
534 + * @since 7.0.0 Added `dimensions.width`, `dimensions.height`, and
526 535 * `typography.textIndent` properties.
527 536 * @var array
528 537 */
529 538 const VALID_STYLES = array(
@@ -582,8 +591,9 @@
582 591 'textAlign' => null,
583 592 'textColumns' => null,
584 593 'textDecoration' => null,
585 594 'textIndent' => null,
595 + 'textShadow' => null,
586 596 'textTransform' => null,
587 597 'writingMode' => null,
588 598 ),
589 599 'css' => null,
@@ -620,29 +630,187 @@
620 630 'core/navigation-link' => array( ':hover', ':focus', ':focus-visible', ':active' ),
621 631 );
622 632
623 633 /**
624 - * Responsive breakpoint state keys and their corresponding CSS media queries.
625 - * These are available for all blocks and wrap their styles in the given media query.
626 - * Keep in sync with RESPONSIVE_BREAKPOINTS in packages/global-styles-engine/src/core/render.tsx.
634 + * Default viewport breakpoint sizes.
627 635 *
628 636 * @since 7.1.0
629 637 * @var array
630 638 */
631 - const RESPONSIVE_BREAKPOINTS = array(
632 - 'mobile' => '@media (width <= 480px)',
633 - 'tablet' => '@media (480px < width <= 782px)',
639 + const DEFAULT_VIEWPORT_BREAKPOINTS = array(
640 + 'mobile' => '480px',
641 + 'tablet' => '782px',
634 642 );
635 643
636 644 /**
645 + * Returns CSS media queries for responsive viewport style states.
646 + *
647 + * Breakpoint values are read from `settings.viewport`, sanitized, and
648 + * normalized before the media query strings are generated. By default, the
649 + * returned keys are the theme.json style-state names (`@mobile`, `@tablet`).
650 + * When `$options['include_desktop']` is truthy, `@desktop` is included.
651 + *
652 + * @since 7.1.0
653 + *
654 + * @param mixed $viewport_settings Viewport settings from theme.json.
655 + * @param array $options {
656 + * Optional. Options for generating media queries.
657 + *
658 + * @type bool $include_desktop Whether to include the desktop media query. Default false.
659 + * }
660 + * @return array Responsive media queries.
661 + */
662 + public static function get_viewport_media_queries( $viewport_settings = null, $options = array() ) {
663 + $breakpoints = static::sanitize_viewport_settings( $viewport_settings );
664 +
665 + $responsive_media_queries = array();
666 +
667 + if ( isset( $breakpoints['mobile'] ) ) {
668 + $responsive_media_queries['@mobile'] = "@media (width <= {$breakpoints['mobile']})";
669 + }
670 +
671 + if ( isset( $breakpoints['tablet'] ) ) {
672 + $responsive_media_queries['@tablet'] = isset( $breakpoints['mobile'] )
673 + ? sprintf(
674 + '@media (%s < width <= %s)',
675 + $breakpoints['mobile'],
676 + $breakpoints['tablet']
677 + )
678 + : "@media (width <= {$breakpoints['tablet']})";
679 + }
680 +
681 + if ( ! empty( $options['include_desktop'] ) ) {
682 + if ( isset( $breakpoints['tablet'] ) ) {
683 + $desktop_breakpoint = $breakpoints['tablet'];
684 + } else {
685 + $desktop_breakpoint = $breakpoints['mobile'];
686 + }
687 +
688 + $responsive_media_queries['@desktop'] =
689 + "@media (width > {$desktop_breakpoint})";
690 + }
691 +
692 + return $responsive_media_queries;
693 + }
694 +
695 + /**
696 + * Checks whether a viewport breakpoint value is a safe CSS length.
697 + *
698 + * Viewport breakpoints are limited to numeric `px`, `em`, and `rem` lengths.
699 + * CSS functions, percentages, and other units are rejected because breakpoint
700 + * values are interpolated into generated media queries.
701 + *
702 + * @since 7.1.0
703 + *
704 + * @param mixed $value Value to check.
705 + * @return bool Whether the value is valid.
706 + */
707 + private static function is_valid_viewport_breakpoint_size( $value ) {
708 + if ( ! is_string( $value ) ) {
709 + return false;
710 + }
711 +
712 + $value = trim( $value );
713 + if ( '' === $value ) {
714 + return false;
715 + }
716 +
717 + return 1 === preg_match( '/^(?:\d+|\d*\.\d+)(?:px|em|rem)$/', $value );
718 + }
719 +
720 + /**
721 + * Converts a valid viewport breakpoint size to pixels for ordering checks.
722 + *
723 + * Generated media queries keep the original units. This method only
724 + * normalizes values so `mobile` and `tablet` can be compared safely. `em`
725 + * and `rem` lengths use a 16px base for comparison.
726 + *
727 + * @since 7.1.0
728 + *
729 + * @param mixed $value Viewport breakpoint size.
730 + * @return float|null Viewport breakpoint size in pixels, or null when invalid.
731 + */
732 + private static function get_viewport_breakpoint_value_in_pixels( $value ) {
733 + if ( ! static::is_valid_viewport_breakpoint_size( $value ) ) {
734 + return null;
735 + }
736 +
737 + $value = trim( $value );
738 + $unit = substr( $value, -3 );
739 + if ( 'rem' === $unit ) {
740 + $number = (float) substr( $value, 0, -3 );
741 + } else {
742 + $unit = substr( $value, -2 );
743 + $number = (float) substr( $value, 0, -2 );
744 + }
745 +
746 + /*
747 + * Use the most common browser default font size as the base for em/rem
748 + * media query conversions. This pixel value is only used to compare
749 + * breakpoint order; generated media queries keep the original units.
750 + */
751 + return 'px' === $unit ? $number : $number * 16;
752 + }
753 +
754 + /**
755 + * Sanitizes and normalizes viewport breakpoint settings.
756 + *
757 + * Keeps only supported breakpoint keys, trims valid CSS lengths, and returns
758 + * the default breakpoints when no valid custom breakpoint is provided. When
759 + * only one breakpoint is valid, it remains keyed by its configured state and
760 + * uses a single max-width media query. When `tablet` is not larger than
761 + * `mobile`, it is removed.
762 + *
763 + * @since 7.1.0
764 + *
765 + * @param mixed $viewport_settings Viewport settings from theme.json.
766 + * @return array Sanitized viewport breakpoint settings.
767 + */
768 + private static function sanitize_viewport_settings( $viewport_settings ) {
769 + if ( ! is_array( $viewport_settings ) ) {
770 + return static::DEFAULT_VIEWPORT_BREAKPOINTS;
771 + }
772 +
773 + $breakpoints = array();
774 + foreach ( array_keys( static::DEFAULT_VIEWPORT_BREAKPOINTS ) as $breakpoint ) {
775 + $value = $viewport_settings[ $breakpoint ] ?? null;
776 + $px = static::get_viewport_breakpoint_value_in_pixels( $value );
777 + if ( null !== $px ) {
778 + $breakpoints[ $breakpoint ] = array(
779 + 'value' => trim( $value ),
780 + 'px' => $px,
781 + );
782 + }
783 + }
784 +
785 + if ( empty( $breakpoints ) ) {
786 + return static::DEFAULT_VIEWPORT_BREAKPOINTS;
787 + }
788 +
789 + if ( 1 === count( $breakpoints ) ) {
790 + $breakpoint = key( $breakpoints );
791 + return array( $breakpoint => $breakpoints[ $breakpoint ]['value'] );
792 + }
793 +
794 + $sanitized = array( 'mobile' => $breakpoints['mobile']['value'] );
795 +
796 + if ( isset( $breakpoints['tablet'] ) && $breakpoints['mobile']['px'] < $breakpoints['tablet']['px']
797 + ) {
798 + $sanitized['tablet'] = $breakpoints['tablet']['value'];
799 + }
800 +
801 + return $sanitized;
802 + }
803 +
804 + /**
637 805 * Custom states for blocks that map to CSS class selectors rather than
638 - * CSS pseudo-selectors. Values use the '@' prefix (e.g. '@current') to
639 - * distinguish them from real CSS pseudo-selectors.
806 + * CSS pseudo-selectors. Values use the '-' prefix (e.g. '-current') to
807 + * distinguish them from real CSS pseudo-selectors and breakpoint states.
640 808 *
641 809 * The CSS selector for each state is defined in the block's block.json
642 810 * under `selectors.states`, e.g.:
643 811 *
644 - * "selectors": { "states": { "@current": ".some-css-selector" } }
812 + * "selectors": { "states": { "-current": ".some-css-selector" } }
645 813 *
646 814 * This constant controls which states are valid in theme.json for a given
647 815 * block. Blocks listed here also inherit their VALID_BLOCK_PSEUDO_SELECTORS
648 816 * as valid sub-states, producing compound selectors such as
@@ -650,9 +818,9 @@
650 818 *
651 819 * @var array
652 820 */
653 821 const VALID_BLOCK_CUSTOM_STATES = array(
654 - 'core/navigation-link' => array( '@current' ),
822 + 'core/navigation-link' => array( '-current' ),
655 823 );
656 824
657 825 /**
658 826 * The valid elements that can be found under styles.
@@ -674,8 +842,9 @@
674 842 'button' => '.wp-element-button, .wp-block-button__link',
675 843 // The block classes are necessary to target older content that won't use the new class names.
676 844 'caption' => '.wp-element-caption, .wp-block-audio figcaption, .wp-block-embed figcaption, .wp-block-gallery figcaption, .wp-block-image figcaption, .wp-block-table figcaption, .wp-block-video figcaption',
677 845 'cite' => 'cite',
846 + 'label' => 'label',
678 847 'select' => 'select',
679 848 'textInput' => 'textarea, input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url])',
680 849 );
681 850
@@ -747,17 +916,33 @@
747 916
748 917 /**
749 918 * Processes pseudo-selectors for any node (block or variation).
750 919 *
751 - * @param array $node The node data (block or variation).
752 - * @param string $base_selector The base selector.
753 - * @param array $settings The theme settings.
754 - * @param string $block_name The block name.
920 + * @param array $node The node data (block or variation).
921 + * @param string $base_selector The base selector.
922 + * @param array $settings The theme settings.
923 + * @param string $block_name The block name.
924 + * @param array|null $block_metadata Metadata about the block to get styles for.
925 + * @param array|null $style_variation Style variation metadata.
755 926 * @return array Array of pseudo-selector declarations.
756 927 */
757 - private static function process_pseudo_selectors( $node, $base_selector, $settings, $block_name ) {
928 + private function process_pseudo_selectors( $node, $base_selector, $settings, $block_name, $block_metadata = null, $style_variation = null ) {
758 929 $pseudo_declarations = array();
930 + $add_declarations = static function ( $selector, $declarations ) use ( &$pseudo_declarations ) {
931 + if ( empty( $declarations ) ) {
932 + return;
933 + }
759 934
935 + if ( isset( $pseudo_declarations[ $selector ] ) ) {
936 + $pseudo_declarations[ $selector ] = array_merge(
937 + $pseudo_declarations[ $selector ],
938 + $declarations
939 + );
940 + } else {
941 + $pseudo_declarations[ $selector ] = $declarations;
942 + }
943 + };
944 +
760 945 if ( ! isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) {
761 946 return $pseudo_declarations;
762 947 }
763 948
@@ -762,11 +947,28 @@
762 947 }
763 948
764 949 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) {
765 950 if ( isset( $node[ $pseudo_selector ] ) ) {
766 - $combined_selector = static::append_to_selector( $base_selector, $pseudo_selector );
767 - $declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, null );
768 - $pseudo_declarations[ $combined_selector ] = $declarations;
951 + $pseudo_node = $node[ $pseudo_selector ];
952 +
953 + if ( is_array( $block_metadata ) ) {
954 + $feature_declarations = $this->get_feature_declarations_for_node( $block_metadata, $pseudo_node );
955 + $feature_declarations = static::update_paragraph_text_indent_selector( $feature_declarations, $settings, $block_name );
956 + $feature_declarations = static::update_button_width_declarations( $feature_declarations, $settings );
957 +
958 + foreach ( $feature_declarations as $feature_selector => $declarations ) {
959 + $target_selector = is_array( $style_variation )
960 + ? static::get_block_style_variation_feature_selector( $style_variation, $feature_selector )
961 + : $feature_selector;
962 + $combined_selector = static::append_to_selector( $target_selector, $pseudo_selector );
963 +
964 + $add_declarations( $combined_selector, $declarations );
965 + }
966 + }
967 +
968 + $combined_selector = static::append_to_selector( $base_selector, $pseudo_selector );
969 + $declarations = static::compute_style_properties( $pseudo_node, $settings, null, null );
970 + $add_declarations( $combined_selector, $declarations );
769 971 }
770 972 }
771 973
772 974 return $pseudo_declarations;
@@ -854,9 +1056,12 @@
854 1056 if ( ! in_array( $origin, static::VALID_ORIGINS, true ) ) {
855 1057 $origin = 'theme';
856 1058 }
857 1059
858 - $this->theme_json = WP_Theme_JSON_Schema_Gutenberg::migrate( $theme_json, $origin );
1060 + $this->theme_json = WP_Theme_JSON_Schema_Gutenberg::migrate( $theme_json, $origin );
1061 + if ( isset( $this->theme_json['styles'] ) ) {
1062 + $this->theme_json['styles'] = gutenberg_resolve_style_state_aliases( $this->theme_json['styles'] );
1063 + }
859 1064 $blocks_metadata = static::get_blocks_metadata();
860 1065 $valid_block_names = array_keys( $blocks_metadata );
861 1066 $valid_element_names = array_keys( static::ELEMENTS );
862 1067 $valid_variations = static::get_valid_block_style_variations( $blocks_metadata );
@@ -1061,10 +1266,11 @@
1061 1266 }
1062 1267 }
1063 1268
1064 1269 // Build the schema based on valid block & element names.
1065 - $schema = array();
1066 - $schema_styles_elements = array();
1270 + $schema = array();
1271 + $schema_styles_elements = array();
1272 + $responsive_media_queries = static::get_viewport_media_queries( $input['settings']['viewport'] ?? null );
1067 1273
1068 1274 /*
1069 1275 * Set allowed element pseudo selectors and responsive breakpoint states.
1070 1276 * Target data structure in schema:
@@ -1070,9 +1276,9 @@
1070 1276 * Target data structure in schema:
1071 1277 * e.g.
1072 1278 * - top level elements: `$schema['styles']['elements']['link'][':hover']`.
1073 1279 * - block level elements: `$schema['styles']['blocks']['core/button']['elements']['link'][':hover']`.
1074 - * - block responsive elements: `$schema['styles']['blocks']['core/button']['tablet']['elements']['link'][':hover']`.
1280 + * - block responsive elements: `$schema['styles']['blocks']['core/button']['@tablet']['elements']['link'][':hover']`.
1075 1281 */
1076 1282 foreach ( $valid_element_names as $element ) {
1077 1283 $schema_styles_elements[ $element ] = $styles_non_top_level;
1078 1284
@@ -1082,9 +1288,9 @@
1082 1288 }
1083 1289 }
1084 1290
1085 1291 // Add responsive breakpoint states for elements.
1086 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) {
1292 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint_state ) {
1087 1293 $schema_styles_elements[ $element ][ $breakpoint_state ] = $styles_non_top_level;
1088 1294 }
1089 1295 }
1090 1296
@@ -1089,9 +1295,17 @@
1089 1295 }
1090 1296
1091 1297 $schema_styles_blocks = array();
1092 1298 $schema_settings_blocks = array();
1299 + $breakpoint_states = array_keys( $responsive_media_queries );
1093 1300
1301 + $common_block_settings = static::VALID_SETTINGS;
1302 + // `viewport` and `blockVisibility` are global-only settings and cannot be set per block for now.
1303 + unset(
1304 + $common_block_settings['viewport'],
1305 + $common_block_settings['blockVisibility']
1306 + );
1307 +
1094 1308 /*
1095 1309 * Generate a schema for blocks.
1096 1310 * - Block styles can contain `elements`, `variations`, and responsive breakpoint state definitions.
1097 1311 * - Variations definitions cannot be nested.
@@ -1099,20 +1313,29 @@
1099 1313 * - Variation inner `blocks` styles can contain `elements` and responsive breakpoint states.
1100 1314 *
1101 1315 * As each variation needs both a `blocks` schema and responsive `blocks` schemas
1102 1316 * for further nested inner `blocks`, the overall schema is generated in multiple passes.
1317 + *
1318 + * All blocks start with the same style schema. Build that common schema
1319 + * once, then add block-specific pseudo and custom states below.
1103 1320 */
1321 + $responsive_block_schema = $styles_non_top_level;
1322 + $responsive_block_schema['elements'] = $schema_styles_elements;
1323 +
1324 + $common_block_schema = $styles_non_top_level;
1325 + $common_block_schema['elements'] = $schema_styles_elements;
1326 +
1327 + foreach ( $breakpoint_states as $breakpoint_state ) {
1328 + $common_block_schema[ $breakpoint_state ] = $responsive_block_schema;
1329 + }
1330 +
1104 1331 foreach ( $valid_block_names as $block ) {
1105 - $schema_settings_blocks[ $block ] = static::VALID_SETTINGS;
1106 - $schema_styles_blocks[ $block ] = $styles_non_top_level;
1107 - $schema_styles_blocks[ $block ]['elements'] = $schema_styles_elements;
1332 + $schema_settings_blocks[ $block ] = $common_block_settings;
1333 + $schema_styles_blocks[ $block ] = $common_block_schema;
1108 1334
1109 - // Add responsive breakpoint states for all blocks.
1110 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) {
1111 - $schema_styles_blocks[ $block ][ $breakpoint_state ] = $styles_non_top_level;
1112 - $schema_styles_blocks[ $block ][ $breakpoint_state ]['elements'] = $schema_styles_elements;
1113 -
1114 - if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
1335 + // Add responsive pseudo-selectors only to blocks that support them.
1336 + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
1337 + foreach ( $breakpoint_states as $breakpoint_state ) {
1115 1338 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) {
1116 1339 $schema_styles_blocks[ $block ][ $breakpoint_state ][ $pseudo_selector ] = $styles_non_top_level;
1117 1340 }
1118 1341 }
@@ -1124,9 +1347,9 @@
1124 1347 $schema_styles_blocks[ $block ][ $pseudo_selector ] = $styles_non_top_level;
1125 1348 }
1126 1349 }
1127 1350
1128 - // Add custom states for blocks that support them (e.g. '@current' for navigation).
1351 + // Add custom states for blocks that support them (e.g. '-current' for navigation).
1129 1352 if ( isset( static::VALID_BLOCK_CUSTOM_STATES[ $block ] ) ) {
1130 1353 foreach ( static::VALID_BLOCK_CUSTOM_STATES[ $block ] as $custom_state ) {
1131 1354 $custom_state_schema = $styles_non_top_level;
1132 1355 // The same pseudo-selectors valid for the block at the top level
@@ -1164,9 +1387,9 @@
1164 1387 foreach ( $style_variation_names as $variation_name ) {
1165 1388 $variation_schema = $block_style_variation_styles;
1166 1389
1167 1390 // Add responsive breakpoint states to block style variations.
1168 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) {
1391 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint_state ) {
1169 1392 $variation_schema[ $breakpoint_state ] = $styles_non_top_level;
1170 1393 $variation_schema[ $breakpoint_state ]['elements'] = $schema_styles_elements;
1171 1394 $variation_schema[ $breakpoint_state ]['blocks'] = $schema_styles_blocks;
1172 1395
@@ -1210,8 +1433,12 @@
1210 1433 }
1211 1434
1212 1435 $result = static::remove_keys_not_in_schema( $input[ $subtree ], $schema[ $subtree ] );
1213 1436
1437 + if ( 'settings' === $subtree && array_key_exists( 'viewport', $input[ $subtree ] ) ) {
1438 + $result['viewport'] = static::sanitize_viewport_settings( $input[ $subtree ]['viewport'] );
1439 + }
1440 +
1214 1441 if ( empty( $result ) ) {
1215 1442 unset( $output[ $subtree ] );
1216 1443 } else {
1217 1444 $output[ $subtree ] = static::resolve_custom_css_format( $result );
@@ -1239,14 +1466,38 @@
1239 1466 protected static function append_to_selector( $selector, $to_append ) {
1240 1467 if ( ! str_contains( $selector, ',' ) ) {
1241 1468 return $selector . $to_append;
1242 1469 }
1470 +
1471 + /**
1472 + * Check for an opportunity to skip the more-costly selector splitting.
1473 + * This should be possible if there are no comments, strings, functions,
1474 + * URLs, escapes, or comment declaration openers (CDOs).
1475 + *
1476 + * Note that this means the fast-path will not apply for selectors like
1477 + * the following incomplete list:
1478 + *
1479 + * - `[class ~= "wide"]`
1480 + * - `.wp-block:is(.is-style-a, .is-style-b)`
1481 + * - `:nth-child(1)`
1482 + *
1483 + * These syntax forms all present opportunities where a comma may not
1484 + * separate selectors. If none of the start characters are present,
1485 + * there should be no way for a comma to mean anything other than a
1486 + * comma token. The exception are syntax errors, which are not handled here.
1487 + *
1488 + * @link https://www.w3.org/TR/css-syntax-3/#parse-comma-separated-list-of-component-values
1489 + */
1490 + if ( strlen( $selector ) === strcspn( $selector, '/\'"(<\\' ) ) {
1491 + return str_replace( ',', $to_append . ',', $selector ) . $to_append;
1492 + }
1493 +
1243 1494 $new_selectors = array();
1244 - $selectors = explode( ',', $selector );
1495 + $selectors = static::split_selector_list( $selector );
1245 1496 foreach ( $selectors as $sel ) {
1246 1497 $new_selectors[] = $sel . $to_append;
1247 1498 }
1248 - return implode( ',', $new_selectors );
1499 + return implode( ', ', $new_selectors );
1249 1500 }
1250 1501
1251 1502 /**
1252 1503 * Prepends a sub-selector to an existing one.
@@ -1264,17 +1515,199 @@
1264 1515 protected static function prepend_to_selector( $selector, $to_prepend ) {
1265 1516 if ( ! str_contains( $selector, ',' ) ) {
1266 1517 return $to_prepend . $selector;
1267 1518 }
1519 +
1520 + /**
1521 + * Check for an opportunity to skip the more-costly selector splitting.
1522 + * This should be possible if there are no comments, strings, functions,
1523 + * URLs, escapes, or comment declaration openers (CDOs).
1524 + *
1525 + * Note that this means the fast-path will not apply for selectors like
1526 + * the following incomplete list:
1527 + *
1528 + * - `[class ~= "wide"]`
1529 + * - `.wp-block:is(.is-style-a, .is-style-b)`
1530 + * - `:nth-child(1)`
1531 + *
1532 + * These syntax forms all present opportunities where a comma may not
1533 + * separate selectors. If none of the start characters are present,
1534 + * there should be no way for a comma to mean anything other than a
1535 + * comma token. The exception are syntax errors, which are not handled here.
1536 + *
1537 + * @link https://www.w3.org/TR/css-syntax-3/#parse-comma-separated-list-of-component-values
1538 + */
1539 + if ( strlen( $selector ) === strcspn( $selector, '/\'"(<\\' ) ) {
1540 + return $to_prepend . str_replace( ',', ',' . $to_prepend, $selector );
1541 + }
1542 +
1268 1543 $new_selectors = array();
1269 - $selectors = explode( ',', $selector );
1544 + $selectors = static::split_selector_list( $selector );
1270 1545 foreach ( $selectors as $sel ) {
1271 1546 $new_selectors[] = $to_prepend . $sel;
1272 1547 }
1273 - return implode( ',', $new_selectors );
1548 +
1549 + return implode( ', ', $new_selectors );
1274 1550 }
1275 1551
1276 1552 /**
1553 + * Splits a selector list into separate selectors.
1554 + *
1555 + * While selectors are joined by commas, not all commas separate top-level selectors.
1556 + * This method only separates top-level selectors, so some commas may appear inside
1557 + * strings, nested selectors, and comments. Leading and trailing CSS whitespace is
1558 + * trimmed from the returned list items.
1559 + *
1560 + * Non-selector content, such as comments, are retained in the list in the same item
1561 + * as the selector content they follow.
1562 + *
1563 + * Example:
1564 + *
1565 + * array( '.wp-block' ) === self::split_selector_list( '.wp-block' );
1566 + * array( '.one', '.two' ) === self::split_selector_list( '.one, .two' );
1567 + *
1568 + * // Nested selector lists are retained within their containing selector.
1569 + * array( ':is(.a, .b)', 'c' ) === self::split_selector_list( ':is(.a, .b), .c' );
1570 + *
1571 + * // Commas within strings do not separate selectors.
1572 + * $selectors = self::split_selector_list( '[data-label="Save, continue"],.fallback' );
1573 + * $selectors === array( '[data-label="Save, continue"]', '.fallback' )
1574 + *
1575 + * array( 'lang(zh, "*-hant")', '.foo' ) === self::split_selector_list( 'lang(zh, "*-hant"), .foo' );
1576 + *
1577 + * // Identifiers may contain escaped commas.
1578 + * array( '.foo\,bar', '.baz' ) === self::split_selector_list( '.foo\,bar,.baz' );
1579 + *
1580 + * // Comments stay with the selector they follow.
1581 + * array( '.a /* a, the first *\/', '.b' ) === self::split_selector_list( '.a /* a, the first *\/,.b' );
1582 + *
1583 + * @link https://www.w3.org/TR/selectors/#parse-selector
1584 + * @link https://www.w3.org/TR/css-syntax-3/
1585 + *
1586 + * @param string $selector CSS selector list.
1587 + * @return string[] Selectors.
1588 + */
1589 + protected static function split_selector_list( $selector ): array {
1590 + if ( ! str_contains( $selector, ',' ) ) {
1591 + // See note on trimming CSS whitespace in main loop.
1592 + return array( trim( $selector, " \t\n" ) );
1593 + }
1594 +
1595 + $selectors = array();
1596 + $selector_length = strlen( $selector );
1597 + $parentheses_depth = 0;
1598 + $at = 0;
1599 + $was_at = 0;
1600 +
1601 + while ( $at < $selector_length ) {
1602 + $next_at = $at + strcspn( $selector, '/,\'"()<-\\', $at );
1603 + if ( $next_at >= $selector_length ) {
1604 + break;
1605 + }
1606 +
1607 + $next_cp = $selector[ $next_at ];
1608 +
1609 + // Escaped syntax characters do not act as delimiters.
1610 + if ( '\\' === $next_cp ) {
1611 + $at = min( $next_at + 2, $selector_length );
1612 + continue;
1613 + }
1614 +
1615 + /*
1616 + * Start of a parenthesized expression, which maintains a stack of parentheses.
1617 + * For the sake of this function, no selector list will be split inside parentheses.
1618 + * Therefore it’s possible to jump ahead until this list completes.
1619 + */
1620 + if ( '(' === $next_cp || ')' === $next_cp ) {
1621 + $parentheses_depth += '(' === $next_cp ? 1 : -1;
1622 + $at = $next_at + 1;
1623 + continue;
1624 + }
1625 +
1626 + // Start of a string, which will be incorporated into the selector in which it’s found.
1627 + if ( "'" === $next_cp || '"' === $next_cp ) {
1628 + $end_of_string = $next_at + 1;
1629 + while ( $end_of_string < $selector_length ) {
1630 + $end_of_string += strcspn( $selector, "{$next_cp}\\", $end_of_string );
1631 + if ( $end_of_string >= $selector_length ) {
1632 + break;
1633 + }
1634 +
1635 + $end_cp = $selector[ $end_of_string ];
1636 +
1637 + // Skip escaped characters.
1638 + if ( '\\' === $end_cp ) {
1639 + $end_of_string = $end_of_string + 2;
1640 + continue;
1641 + }
1642 +
1643 + if ( $next_cp === $end_cp ) {
1644 + ++$end_of_string;
1645 + break;
1646 + }
1647 +
1648 + ++$end_of_string;
1649 + }
1650 +
1651 + $at = $end_of_string;
1652 + continue;
1653 + }
1654 +
1655 + // Start of a comment, which will be incorporated into the selector in which it’s found.
1656 + if ( '/' === $next_cp && ( $next_at + 1 ) < $selector_length && '*' === $selector[ $next_at + 1 ] ) {
1657 + $comment_end_at = strpos( $selector, '*/', $next_at + 1 );
1658 + $is_terminated = false !== $comment_end_at;
1659 + $after_comment = $is_terminated ? $comment_end_at + 2 : strlen( $selector );
1660 + $at = $after_comment;
1661 + continue;
1662 + }
1663 +
1664 + // Start of a CDO or CDC, which will be incorporated into the selector in which it’s found.
1665 + if (
1666 + ( '<' === $next_cp && 0 === substr_compare( $selector, '<!--', $next_at, 4 ) ) ||
1667 + ( '-' === $next_cp && 0 === substr_compare( $selector, '-->', $next_at, 3 ) )
1668 + ) {
1669 + $at = $next_at + ( '<' === $next_cp ? 4 : 3 );
1670 + continue;
1671 + }
1672 +
1673 + // Everything else is either a comma token or part of a selector.
1674 + if ( ',' === $next_cp && 0 === $parentheses_depth ) {
1675 + /**
1676 + * Trim each selector so that downstream code doesn’t see whitespace
1677 + * as the first character in a selector and get confused.
1678 + *
1679 + * There is inconsistency in this because comments and other syntax
1680 + * are included which are also not part of the selector itself, but
1681 + * a tradeoff is made between removing common syntax which carries
1682 + * no meaning and rarer syntax which leaves auxiliary information.
1683 + *
1684 + * > A newline, U+0009 CHARACTER TABULATION, or U+0020 SPACE.
1685 + * > Note that U+000D CARRIAGE RETURN and U+000C FORM FEED are
1686 + * > not included in this definition, as they are converted
1687 + * > to U+000A LINE FEED during preprocessing.
1688 + *
1689 + * @link https://www.w3.org/TR/css-syntax/#whitespace
1690 + * @link https://www.w3.org/TR/css-syntax/#newline
1691 + */
1692 + $selectors[] = trim( substr( $selector, $was_at, $next_at - $was_at ), " \t\n" );
1693 + $at = $next_at + 1;
1694 + $was_at = $at;
1695 + continue;
1696 + }
1697 +
1698 + $at = $next_at + 1;
1699 + }
1700 +
1701 + if ( $was_at < $selector_length ) {
1702 + // See note on trimming CSS whitespace in main loop.
1703 + $selectors[] = trim( substr( $selector, $was_at ), " \t\n" );
1704 + }
1705 +
1706 + return $selectors;
1707 + }
1708 +
1709 + /**
1277 1710 * Returns the metadata for each block.
1278 1711 *
1279 1712 * Example:
1280 1713 *
@@ -1691,9 +2124,9 @@
1691 2124 /**
1692 2125 * Returns the global styles custom CSS for a single block.
1693 2126 * This function is deprecated; please do not sync to core.
1694 2127 *
1695 - * @param array $css The block css node.
2128 + * @param array $css The block css node.
1696 2129 * @param string $selector The block selector.
1697 2130 *
1698 2131 * @return string The global styles custom CSS for the block.
1699 2132 */
@@ -1818,9 +2251,10 @@
1818 2251
1819 2252 // Gap styles will only be output if the theme has block gap support, or supports a fallback gap.
1820 2253 // Default layout gap styles will be skipped for themes that do not explicitly opt-in to blockGap with a `true` or `false` value.
1821 2254 if ( $has_block_gap_support || $has_fallback_gap_support ) {
1822 - $block_gap_value = null;
2255 + $block_gap_value = null;
2256 + $block_gap_row_value = null;
1823 2257 // Use a fallback gap value if block gap support is not available.
1824 2258 if ( ! $has_block_gap_support ) {
1825 2259 $block_gap_value = static::ROOT_BLOCK_SELECTOR === $selector ? '0.5em' : null;
1826 2260 if ( ! empty( $block_type ) ) {
@@ -1828,18 +2262,29 @@
1828 2262 }
1829 2263 } else {
1830 2264 $block_gap_value = static::get_property_value( $node, array( 'spacing', 'blockGap' ) );
1831 2265 }
2266 + $block_gap_row_value = $block_gap_value;
1832 2267
1833 2268 // Support split row / column values and concatenate to a shorthand value.
1834 2269 if ( is_array( $block_gap_value ) ) {
1835 - if ( isset( $block_gap_value['top'] ) && isset( $block_gap_value['left'] ) ) {
1836 - $gap_row = static::get_property_value( $node, array( 'spacing', 'blockGap', 'top' ) );
1837 - $gap_column = static::get_property_value( $node, array( 'spacing', 'blockGap', 'left' ) );
1838 - $block_gap_value = $gap_row === $gap_column ? $gap_row : $gap_row . ' ' . $gap_column;
2270 + $has_block_gap_row_value = isset( $block_gap_value['top'] );
2271 + $has_block_gap_column_value = isset( $block_gap_value['left'] );
2272 +
2273 + if ( $has_block_gap_row_value || $has_block_gap_column_value ) {
2274 + $block_gap_row_value = $has_block_gap_row_value
2275 + ? static::get_property_value( $node, array( 'spacing', 'blockGap', 'top' ) )
2276 + : '0';
2277 + $block_gap_column_value = $has_block_gap_column_value
2278 + ? static::get_property_value( $node, array( 'spacing', 'blockGap', 'left' ) )
2279 + : '0';
2280 + $block_gap_value = $block_gap_row_value === $block_gap_column_value
2281 + ? $block_gap_row_value
2282 + : $block_gap_row_value . ' ' . $block_gap_column_value;
1839 2283 } else {
1840 - // Skip outputting gap value if not all sides are provided.
1841 - $block_gap_value = null;
2284 + // Skip outputting a gap value if neither supported axis is provided.
2285 + $block_gap_value = null;
2286 + $block_gap_row_value = null;
1842 2287 }
1843 2288 }
1844 2289
1845 2290 // If the block should have custom gap, add the gap styles.
@@ -1849,10 +2294,13 @@
1849 2294 if ( ! $has_block_gap_support && 'flex' !== $layout_definition_key && 'grid' !== $layout_definition_key ) {
1850 2295 continue;
1851 2296 }
1852 2297
1853 - $class_name = $layout_definition['className'] ?? false;
1854 - $spacing_rules = $layout_definition['spacingStyles'] ?? array();
2298 + $class_name = $layout_definition['className'] ?? false;
2299 + $spacing_rules = $layout_definition['spacingStyles'] ?? array();
2300 + $layout_gap_value = in_array( $layout_definition_key, array( 'default', 'constrained' ), true )
2301 + ? $block_gap_row_value
2302 + : $block_gap_value;
1855 2303
1856 2304 if (
1857 2305 ! empty( $class_name ) &&
1858 2306 ! empty( $spacing_rules )
@@ -1865,9 +2313,9 @@
1865 2313 ! empty( $spacing_rule['rules'] )
1866 2314 ) {
1867 2315 // Iterate over each of the styling rules and substitute non-string values such as `null` with the real `blockGap` value.
1868 2316 foreach ( $spacing_rule['rules'] as $css_property => $css_value ) {
1869 - $current_css_value = is_string( $css_value ) ? $css_value : $block_gap_value;
2317 + $current_css_value = is_string( $css_value ) ? $css_value : $layout_gap_value;
1870 2318 if ( static::is_safe_css_declaration( $css_property, $current_css_value ) ) {
1871 2319 $declarations[] = array(
1872 2320 'name' => $css_property,
1873 2321 'value' => $current_css_value,
@@ -2006,9 +2454,9 @@
2006 2454 * .has-value-gradient-background {
2007 2455 * background: value;
2008 2456 * }
2009 2457 *
2010 - * p.has-value-gradient-background {
2458 + * :where(p).has-value-gradient-background {
2011 2459 * background: value;
2012 2460 * }
2013 2461 *
2014 2462 * @since 5.9.0
@@ -2209,10 +2657,18 @@
2209 2657 foreach ( $slugs as $slug ) {
2210 2658 $css_var = static::replace_slug_in_string( $preset_metadata['css_vars'], $slug );
2211 2659 $class_name = static::replace_slug_in_string( $class, $slug );
2212 2660
2213 - // $selector is often empty, so we can save ourselves the `append_to_selector()` call then.
2214 - $new_selector = '' === $selector ? $class_name : static::append_to_selector( $selector, $class_name );
2661 + /*
2662 + * $selector is often empty (root-level presets), in which case the
2663 + * bare class is used. For block-level presets the block selector is
2664 + * wrapped in `:where()` so the class keeps the same 0-1-0 specificity
2665 + * as a root-level preset. Without this, block-level palette rules
2666 + * (e.g. `p.has-x-color`) out-rank equally-important rules that also
2667 + * target the same property at 0-1-0, such as per-instance responsive
2668 + * state styles.
2669 + */
2670 + $new_selector = '' === $selector ? $class_name : ':where(' . $selector . ')' . $class_name;
2215 2671 $stylesheet .= static::to_ruleset(
2216 2672 $new_selector,
2217 2673 array(
2218 2674 array(
@@ -2249,16 +2705,14 @@
2249 2705 if ( ! $scope || ! $selector ) {
2250 2706 return $selector;
2251 2707 }
2252 2708
2253 - $scopes = explode( ',', $scope );
2254 - $selectors = explode( ',', $selector );
2709 + $scopes = static::split_selector_list( $scope );
2710 + $selectors = static::split_selector_list( $selector );
2255 2711
2256 2712 $selectors_scoped = array();
2257 2713 foreach ( $scopes as $outer ) {
2258 2714 foreach ( $selectors as $inner ) {
2259 - $outer = trim( $outer );
2260 - $inner = trim( $inner );
2261 2715 if ( ! empty( $outer ) && ! empty( $inner ) ) {
2262 2716 $selectors_scoped[] = $outer . ' ' . $inner;
2263 2717 } elseif ( empty( $outer ) ) {
2264 2718 $selectors_scoped[] = $inner;
@@ -2564,15 +3018,15 @@
2564 3018 * @since 6.5.0 Output a `min-height: unset` rule when `aspect-ratio` is set.
2565 3019 * @since 6.6.0 Passing current theme JSON settings to wp_get_typography_font_size_value(). Using style engine to correctly fetch background CSS values.
2566 3020 * @since 6.7.0 Allow ref resolution of background properties.
2567 3021 *
2568 - * @param array $styles Styles to process.
2569 - * @param array $settings Theme settings.
2570 - * @param array $properties Properties metadata.
2571 - * @param array $theme_json Theme JSON array.
2572 - * @param string $selector The style block selector.
3022 + * @param array $styles Styles to process.
3023 + * @param array $settings Theme settings.
3024 + * @param array $properties Properties metadata.
3025 + * @param array $theme_json Theme JSON array.
3026 + * @param string $selector The style block selector.
2573 3027 * @param boolean $use_root_padding Whether to add custom properties at root level.
2574 - * @return array Returns the modified $declarations.
3028 + * @return array Returns the modified $declarations.
2575 3029 */
2576 3030 protected static function compute_style_properties( $styles, $settings = array(), $properties = null, $theme_json = null, $selector = null, $use_root_padding = null ) {
2577 3031 if ( empty( $styles ) ) {
2578 3032 return array();
@@ -2696,10 +3150,10 @@
2696 3150 * @since 5.9.0 Added support for values of array type, which are returned as is.
2697 3151 * @since 6.1.0 Added the `$theme_json` parameter.
2698 3152 * @since 6.7.0 Added support for background image refs
2699 3153 *
2700 - * @param array $styles Styles subtree.
2701 - * @param array $path Which property to process.
3154 + * @param array $styles Styles subtree.
3155 + * @param array $path Which property to process.
2702 3156 * @param array $theme_json Theme JSON array.
2703 3157 * @return string|array Style property value.
2704 3158 */
2705 3159 protected static function get_property_value( $styles, $path, $theme_json = null ) {
@@ -3071,9 +3525,9 @@
3071 3525 * @since 6.1.0
3072 3526 *
3073 3527 * @param array $theme_json The theme.json converted to an array.
3074 3528 * @param array $selectors Optional list of selectors per block.
3075 - * @param array $options {
3529 + * @param array $options {
3076 3530 * Optional. An array of options for now used for internal purposes only (may change without notice).
3077 3531 *
3078 3532 * @type bool $include_block_style_variations Includes nodes for block style variations. Default false.
3079 3533 * @type bool $include_node_paths_only Return only block nodes node paths. Default false.
@@ -3086,10 +3540,11 @@
3086 3540 if ( ! isset( $theme_json['styles']['blocks'] ) ) {
3087 3541 return $nodes;
3088 3542 }
3089 3543
3090 - $include_variations = $options['include_block_style_variations'] ?? false;
3091 - $include_node_paths_only = $options['include_node_paths_only'] ?? false;
3544 + $include_variations = $options['include_block_style_variations'] ?? false;
3545 + $include_node_paths_only = $options['include_node_paths_only'] ?? false;
3546 + $responsive_media_queries = static::get_viewport_media_queries( $theme_json['settings']['viewport'] ?? null );
3092 3547
3093 3548 // If only node paths are to be returned, skip selector assignment.
3094 3549 if ( ! $include_node_paths_only ) {
3095 3550 $selectors = empty( $selectors ) ? static::get_blocks_metadata() : $selectors;
@@ -3133,8 +3588,9 @@
3133 3588
3134 3589 if ( $include_variations && isset( $node['variations'] ) ) {
3135 3590 foreach ( $node['variations'] as $variation => $node ) {
3136 3591 $variation_selectors[] = array(
3592 + 'name' => $variation,
3137 3593 'path' => array( 'styles', 'blocks', $name, 'variations', $variation ),
3138 3594 'selector' => $selectors[ $name ]['styleVariations'][ $variation ],
3139 3595 );
3140 3596 }
@@ -3153,14 +3609,14 @@
3153 3609
3154 3610 // Responsive block nodes: emit one node per breakpoint that has styles.
3155 3611 // These are rendered immediately after the base block node so that
3156 3612 // the cascade order is: .block{} → @media{.block{}}
3157 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3613 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
3158 3614 if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ] ) ) {
3159 3615 $nodes[] = array(
3160 3616 'name' => $name,
3161 3617 'path' => array( 'styles', 'blocks', $name, $breakpoint ),
3162 - 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
3618 + 'media_query' => $responsive_media_queries[ $breakpoint ],
3163 3619 'selector' => $selector,
3164 3620 'selectors' => $feature_selectors,
3165 3621 'elements' => $selectors[ $name ]['elements'] ?? array(),
3166 3622 'variations' => $variation_selectors,
@@ -3173,9 +3629,9 @@
3173 3629 if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) {
3174 3630 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo_selector ) {
3175 3631 $has_pseudo = isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] );
3176 3632 $has_responsive_pseudo = false;
3177 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3633 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
3178 3634 if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) {
3179 3635 $has_responsive_pseudo = true;
3180 3636 break;
3181 3637 }
@@ -3218,14 +3674,14 @@
3218 3674
3219 3675 // Responsive pseudo nodes: emit one node per breakpoint that has
3220 3676 // this pseudo state, immediately after the default pseudo node.
3221 3677 // Cascade order: .block:hover{} → @media{.block:hover{}}
3222 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3678 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
3223 3679 if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) {
3224 3680 $nodes[] = array(
3225 3681 'name' => $name,
3226 3682 'path' => array( 'styles', 'blocks', $name, $breakpoint, $pseudo_selector ),
3227 - 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
3683 + 'media_query' => $responsive_media_queries[ $breakpoint ],
3228 3684 'selector' => static::append_to_selector( $selector, $pseudo_selector ),
3229 3685 'selectors' => $pseudo_feature_selectors,
3230 3686 'elements' => $selectors[ $name ]['elements'] ?? array(),
3231 3687 'variations' => $variation_selectors,
@@ -3235,9 +3691,9 @@
3235 3691 }
3236 3692 }
3237 3693 }
3238 3694
3239 - // Handle custom states (e.g. '@current' for navigation).
3695 + // Handle custom states (e.g. '-current' for navigation).
3240 3696 if ( isset( static::VALID_BLOCK_CUSTOM_STATES[ $name ] ) ) {
3241 3697 foreach ( static::VALID_BLOCK_CUSTOM_STATES[ $name ] as $custom_state ) {
3242 3698 if (
3243 3699 isset( $theme_json['styles']['blocks'][ $name ][ $custom_state ] ) &&
@@ -3276,33 +3732,56 @@
3276 3732 }
3277 3733 }
3278 3734 }
3279 3735 }
3280 - if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) {
3281 - foreach ( $theme_json['styles']['blocks'][ $name ]['elements'] as $element => $node ) {
3736 + /*
3737 + * Elements can be styled outside any breakpoint, inside one, or both,
3738 + * so collect the names from all of those places before looping. An
3739 + * element styled only inside a breakpoint still needs a node.
3740 + */
3741 + $block_node = $theme_json['styles']['blocks'][ $name ] ?? array();
3742 + $element_names = array_keys( $block_node['elements'] ?? array() );
3743 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
3744 + $element_names = array_merge(
3745 + $element_names,
3746 + array_keys( $block_node[ $breakpoint ]['elements'] ?? array() )
3747 + );
3748 + }
3749 + $element_names = array_unique( $element_names );
3750 +
3751 + if ( ! empty( $element_names ) ) {
3752 + foreach ( $element_names as $element ) {
3282 3753 $element_path = array( 'styles', 'blocks', $name, 'elements', $element );
3283 3754 if ( $include_node_paths_only ) {
3284 - $nodes[] = array(
3285 - 'path' => $element_path,
3286 - );
3755 + if ( isset( $block_node['elements'][ $element ] ) ) {
3756 + $nodes[] = array(
3757 + 'path' => $element_path,
3758 + );
3759 + }
3287 3760 continue;
3288 3761 }
3289 3762
3763 + if ( ! isset( $selectors[ $name ]['elements'][ $element ] ) ) {
3764 + continue;
3765 + }
3766 +
3290 3767 $element_selector = $selectors[ $name ]['elements'][ $element ];
3291 3768
3292 - $nodes[] = array(
3293 - 'path' => $element_path,
3294 - 'selector' => $element_selector,
3295 - );
3769 + if ( isset( $block_node['elements'][ $element ] ) ) {
3770 + $nodes[] = array(
3771 + 'path' => $element_path,
3772 + 'selector' => $element_selector,
3773 + );
3774 + }
3296 3775
3297 3776 // Responsive element nodes: one node per breakpoint that has
3298 3777 // styles for this element. Cascade: a{} → @media{a{}}
3299 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3778 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
3300 3779 if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ] ) ) {
3301 3780 $nodes[] = array(
3302 3781 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ),
3303 3782 'selector' => $element_selector,
3304 - 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
3783 + 'media_query' => $responsive_media_queries[ $breakpoint ],
3305 3784 );
3306 3785 }
3307 3786 }
3308 3787
@@ -3308,45 +3787,29 @@
3308 3787
3309 3788 // Handle any pseudo selectors for the element.
3310 3789 if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] ) ) {
3311 3790 foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] as $pseudo_selector ) {
3312 - // Create element pseudo node if default or any responsive breakpoint has the pseudo.
3313 - $has_element_pseudo = isset( $theme_json['styles']['blocks'][ $name ]['elements'][ $element ][ $pseudo_selector ] );
3314 - if ( ! $has_element_pseudo ) {
3315 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $bp ) {
3316 - if ( isset( $theme_json['styles']['blocks'][ $name ][ $bp ]['elements'][ $element ][ $pseudo_selector ] ) ) {
3317 - $has_element_pseudo = true;
3318 - break;
3319 - }
3320 - }
3791 + // Emit the default pseudo node only when the default state styles
3792 + // the pseudo. Otherwise get_styles_for_block() falls back to the
3793 + // element's base styles, outputting a rule the theme never defined.
3794 + if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'][ $element ][ $pseudo_selector ] ) ) {
3795 + $nodes[] = array(
3796 + 'path' => array( 'styles', 'blocks', $name, 'elements', $element ),
3797 + 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ),
3798 + );
3321 3799 }
3322 3800
3323 - if ( $has_element_pseudo ) {
3324 - $element_pseudo_path = array( 'styles', 'blocks', $name, 'elements', $element );
3325 - if ( $include_node_paths_only ) {
3801 + // Responsive element pseudo nodes: one node per breakpoint
3802 + // that has this pseudo state for this element.
3803 + // Cascade: a:hover{} → @media{a:hover{}}
3804 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
3805 + if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ][ $pseudo_selector ] ) ) {
3326 3806 $nodes[] = array(
3327 - 'path' => $element_pseudo_path,
3807 + 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ),
3808 + 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ),
3809 + 'media_query' => $responsive_media_queries[ $breakpoint ],
3328 3810 );
3329 - continue;
3330 3811 }
3331 -
3332 - $nodes[] = array(
3333 - 'path' => $element_pseudo_path,
3334 - 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ),
3335 - );
3336 -
3337 - // Responsive element pseudo nodes: one node per breakpoint
3338 - // that has this pseudo state for this element.
3339 - // Cascade: a:hover{} → @media{a:hover{}}
3340 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3341 - if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ][ $pseudo_selector ] ) ) {
3342 - $nodes[] = array(
3343 - 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ),
3344 - 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ),
3345 - 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
3346 - );
3347 - }
3348 - }
3349 3812 }
3350 3813 }
3351 3814 }
3352 3815 }
@@ -3366,14 +3829,15 @@
3366 3829 *
3367 3830 * @return string Styles for the block.
3368 3831 */
3369 3832 public function get_styles_for_block( $block_metadata ) {
3370 - $node = _wp_array_get( $this->theme_json, $block_metadata['path'], array() );
3371 - $use_root_padding = isset( $this->theme_json['settings']['useRootPaddingAwareAlignments'] ) && true === $this->theme_json['settings']['useRootPaddingAwareAlignments'];
3372 - $selector = $block_metadata['selector'];
3373 - $settings = $this->theme_json['settings'] ?? null;
3374 - $is_root_selector = static::ROOT_BLOCK_SELECTOR === $selector;
3375 - $media_query = $block_metadata['media_query'] ?? null;
3833 + $node = _wp_array_get( $this->theme_json, $block_metadata['path'], array() );
3834 + $use_root_padding = isset( $this->theme_json['settings']['useRootPaddingAwareAlignments'] ) && true === $this->theme_json['settings']['useRootPaddingAwareAlignments'];
3835 + $selector = $block_metadata['selector'];
3836 + $settings = $this->theme_json['settings'] ?? null;
3837 + $is_root_selector = static::ROOT_BLOCK_SELECTOR === $selector;
3838 + $media_query = $block_metadata['media_query'] ?? null;
3839 + $responsive_media_queries = static::get_viewport_media_queries( $settings['viewport'] ?? null );
3376 3840
3377 3841 $feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $node );
3378 3842
3379 3843 // Update text indent selector for paragraph blocks based on the textIndent setting.
@@ -3386,16 +3850,16 @@
3386 3850
3387 3851 // If there are style variations, generate the declarations for them, including any feature selectors the block may have.
3388 3852 // Responsive nodes (those with a media_query) do not process variations — variation responsive
3389 3853 // CSS is handled by the variation's own responsive nodes or the existing variation loop.
3390 - $style_variation_declarations = array();
3391 - $style_variation_custom_css = array();
3392 - $style_variation_responsive_css = array();
3393 - $style_variation_layout_metadata = array();
3854 + $style_variation_declarations = array();
3855 + $style_variation_custom_css = array();
3856 + $style_variation_responsive_css = array();
3857 + $style_variation_responsive_pseudo_css = array();
3858 + $style_variation_layout_metadata = array();
3394 3859 if ( ! $media_query && ! empty( $block_metadata['variations'] ) ) {
3395 3860 foreach ( $block_metadata['variations'] as $style_variation ) {
3396 - $style_variation_node = _wp_array_get( $this->theme_json, $style_variation['path'], array() );
3397 - $clean_style_variation_selector = trim( $style_variation['selector'] );
3861 + $style_variation_node = _wp_array_get( $this->theme_json, $style_variation['path'], array() );
3398 3862
3399 3863 // Generate any feature/subfeature style declarations for the current style variation.
3400 3864 $variation_declarations = static::get_feature_declarations_for_node( $block_metadata, $style_variation_node );
3401 3865
@@ -3406,27 +3870,10 @@
3406 3870 $variation_declarations = static::update_button_width_declarations( $variation_declarations, $settings );
3407 3871
3408 3872 // Combine selectors with style variation's selector and add to overall style variation declarations.
3409 3873 foreach ( $variation_declarations as $current_selector => $new_declarations ) {
3410 - /*
3411 - * Clean up any whitespace between comma separated selectors.
3412 - * This prevents these spaces breaking compound selectors such as:
3413 - * - `.wp-block-list:not(.wp-block-list .wp-block-list)`
3414 - * - `.wp-block-image img, .wp-block-image.my-class img`
3415 - */
3416 - $clean_current_selector = preg_replace( '/,\s+/', ',', $current_selector );
3417 - $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_current_selector );
3874 + $combined_selectors = static::get_block_style_variation_feature_selector( $style_variation, $current_selector );
3418 3875
3419 - // Prepend the variation selector to the current selector.
3420 - $split_selectors = explode( ',', $shortened_selector );
3421 - $updated_selectors = array_map(
3422 - static function ( $split_selector ) use ( $clean_style_variation_selector ) {
3423 - return $clean_style_variation_selector . $split_selector;
3424 - },
3425 - $split_selectors
3426 - );
3427 - $combined_selectors = implode( ',', $updated_selectors );
3428 -
3429 3876 // Add the new declarations to the overall results under the modified selector.
3430 3877 $style_variation_declarations[ $combined_selectors ] = $new_declarations;
3431 3878 }
3432 3879 // Compute declarations for remaining styles not covered by feature level selectors.
@@ -3433,9 +3880,9 @@
3433 3880 $style_variation_declarations[ $style_variation['selector'] ] = static::compute_style_properties( $style_variation_node, $settings, null, $this->theme_json );
3434 3881
3435 3882 // Process pseudo-selectors for this variation (e.g., :hover, :focus).
3436 3883 $block_name = $block_metadata['name'] ?? ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 3 ? static::get_block_name_from_metadata_path( $block_metadata ) : null );
3437 - $variation_pseudo_declarations = static::process_pseudo_selectors( $style_variation_node, $style_variation['selector'], $settings, $block_name );
3884 + $variation_pseudo_declarations = $this->process_pseudo_selectors( $style_variation_node, $style_variation['selector'], $settings, $block_name, $block_metadata, $style_variation );
3438 3885 $style_variation_declarations = array_merge( $style_variation_declarations, $variation_pseudo_declarations );
3439 3886
3440 3887 // Store custom CSS for the style variation.
3441 3888 if ( isset( $style_variation_node['css'] ) ) {
@@ -3445,10 +3892,20 @@
3445 3892 // Store variation metadata and node for layout styles generation.
3446 3893 // Only store if the variation has blockGap defined.
3447 3894 if ( isset( $style_variation_node['spacing']['blockGap'] ) ) {
3448 3895 // Append block selector to the variation selector for proper targeting.
3449 - $variation_metadata_with_selector = $style_variation;
3450 - $variation_metadata_with_selector['selector'] = $style_variation['selector'] . $block_metadata['css'];
3896 + $variation_metadata_with_selector = $style_variation;
3897 + $variation_metadata_with_selector['selector'] = $style_variation['selector'] . $block_metadata['css'];
3898 +
3899 + /*
3900 + * `get_layout_styles()` reads `name` as a block name, to check that the block
3901 + * supports layout at all. A variation node's `name` is the variation slug,
3902 + * which is never a registered block, so the check fails and every variation
3903 + * gap rule is discarded. Pass the block the variation belongs to, so the
3904 + * support check answers the question it is actually asking.
3905 + */
3906 + $variation_metadata_with_selector['name'] = $block_name;
3907 +
3451 3908 $style_variation_layout_metadata[ $style_variation['selector'] ] = array(
3452 3909 'metadata' => $variation_metadata_with_selector,
3453 3910 'node' => $style_variation_node,
3454 3911 );
@@ -3455,44 +3912,25 @@
3455 3912 }
3456 3913
3457 3914 // Store responsive breakpoint CSS for the style variation.
3458 3915 // This includes both base properties and feature-level selectors.
3459 - $variation_responsive_css = '';
3916 + $variation_responsive_css = '';
3917 + $variation_responsive_pseudo_css = '';
3460 3918
3461 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3919 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
3462 3920 if ( ! isset( $style_variation_node[ $breakpoint ] ) ) {
3463 3921 continue;
3464 3922 }
3465 3923
3466 3924 $breakpoint_node = $style_variation_node[ $breakpoint ];
3467 - $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ];
3925 + $breakpoint_media = $responsive_media_queries[ $breakpoint ];
3468 3926 // Process feature-level declarations for this breakpoint.
3469 3927 $breakpoint_feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $breakpoint_node );
3470 3928 $breakpoint_feature_declarations = static::update_paragraph_text_indent_selector( $breakpoint_feature_declarations, $settings, $block_name );
3471 3929 $breakpoint_feature_declarations = static::update_button_width_declarations( $breakpoint_feature_declarations, $settings );
3472 3930 foreach ( $breakpoint_feature_declarations as $feature_selector => $feature_decl ) {
3473 - $clean_feature_selector = preg_replace( '/,\s+/', ',', $feature_selector );
3474 - $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_feature_selector );
3931 + $combined_selectors = static::get_block_style_variation_feature_selector( $style_variation, $feature_selector );
3475 3932
3476 - if ( $block_metadata['selector'] && ! str_contains( $clean_feature_selector, $block_metadata['selector'] ) ) {
3477 - /*
3478 - * Feature selector is block-level (e.g. `.wp-block-button` for
3479 - * dimensions/width) — apply the variation class directly to it.
3480 - */
3481 - $feature_element_selector = str_replace( $shortened_selector, '', $clean_style_variation_selector );
3482 - $combined_selectors = str_replace( $feature_element_selector, '', $clean_style_variation_selector );
3483 - } else {
3484 - // Prepend the variation selector to the current selector.
3485 - $split_selectors = explode( ',', $shortened_selector );
3486 - $updated_selectors = array_map(
3487 - static function ( $split_selector ) use ( $clean_style_variation_selector ) {
3488 - return $clean_style_variation_selector . $split_selector;
3489 - },
3490 - $split_selectors
3491 - );
3492 - $combined_selectors = implode( ',', $updated_selectors );
3493 - }
3494 -
3495 3933 $feature_ruleset = static::to_ruleset( ':root :where(' . $combined_selectors . ')', $feature_decl );
3496 3934 $variation_responsive_css .= $breakpoint_media . '{' . $feature_ruleset . '}';
3497 3935 }
3498 3936
@@ -3502,15 +3940,15 @@
3502 3940 $base_ruleset = static::to_ruleset( ':root :where(' . $style_variation['selector'] . ')', $breakpoint_declarations );
3503 3941 $variation_responsive_css .= $breakpoint_media . '{' . $base_ruleset . '}';
3504 3942 }
3505 3943
3506 - $breakpoint_pseudo_declarations = static::process_pseudo_selectors( $breakpoint_node, $style_variation['selector'], $settings, $block_name );
3944 + $breakpoint_pseudo_declarations = $this->process_pseudo_selectors( $breakpoint_node, $style_variation['selector'], $settings, $block_name, $block_metadata, $style_variation );
3507 3945 foreach ( $breakpoint_pseudo_declarations as $pseudo_selector => $pseudo_declarations ) {
3508 3946 if ( empty( $pseudo_declarations ) ) {
3509 3947 continue;
3510 3948 }
3511 - $pseudo_ruleset = static::to_ruleset( ':root :where(' . $pseudo_selector . ')', $pseudo_declarations );
3512 - $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_ruleset . '}';
3949 + $pseudo_ruleset = static::to_ruleset( ':root :where(' . $pseudo_selector . ')', $pseudo_declarations );
3950 + $variation_responsive_pseudo_css .= $breakpoint_media . '{' . $pseudo_ruleset . '}';
3513 3951 }
3514 3952
3515 3953 // Process custom CSS for this breakpoint.
3516 3954 if ( isset( $breakpoint_node['css'] ) ) {
@@ -3521,9 +3959,13 @@
3521 3959 // Process blockGap responsive layout styles for this variation.
3522 3960 if ( isset( $breakpoint_node['spacing']['blockGap'] ) ) {
3523 3961 $variation_layout_metadata = $style_variation;
3524 3962 $variation_layout_metadata['selector'] = $style_variation['selector'] . $block_metadata['css'];
3525 - $variation_responsive_css .= $this->get_layout_styles(
3963 +
3964 + // The variation slug is not a block name here either. See above.
3965 + $variation_layout_metadata['name'] = $block_name;
3966 +
3967 + $variation_responsive_css .= $this->get_layout_styles(
3526 3968 $variation_layout_metadata,
3527 3969 array(
3528 3970 'node' => $breakpoint_node,
3529 3971 'media_query' => $breakpoint_media,
@@ -3537,18 +3979,9 @@
3537 3979 if ( ! isset( $block_elements[ $element_name ] ) ) {
3538 3980 continue;
3539 3981 }
3540 3982
3541 - $clean_element_selector = preg_replace( '/,\s+/', ',', $block_elements[ $element_name ] );
3542 - $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_element_selector );
3543 - $split_selectors = explode( ',', $shortened_selector );
3544 - $updated_selectors = array_map(
3545 - static function ( $split_selector ) use ( $clean_style_variation_selector ) {
3546 - return $clean_style_variation_selector . $split_selector;
3547 - },
3548 - $split_selectors
3549 - );
3550 - $variation_element_selector = implode( ',', $updated_selectors );
3983 + $variation_element_selector = static::get_block_style_variation_feature_selector( $style_variation, $block_elements[ $element_name ] );
3551 3984
3552 3985 $element_declarations = static::compute_style_properties( $element_node, $settings, null, $this->theme_json );
3553 3986 if ( ! empty( $element_declarations ) ) {
3554 3987 $element_ruleset = static::to_ruleset( ':root :where(' . $variation_element_selector . ')', $element_declarations );
@@ -3570,10 +4003,10 @@
3570 4003 if ( empty( $pseudo_declarations ) ) {
3571 4004 continue;
3572 4005 }
3573 4006
3574 - $pseudo_selector_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $variation_element_selector, $pseudo_selector ) . ')', $pseudo_declarations );
3575 - $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_selector_ruleset . '}';
4007 + $pseudo_selector_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $variation_element_selector, $pseudo_selector ) . ')', $pseudo_declarations );
4008 + $variation_responsive_pseudo_css .= $breakpoint_media . '{' . $pseudo_selector_ruleset . '}';
3576 4009 }
3577 4010 }
3578 4011 }
3579 4012 }
@@ -3581,8 +4014,11 @@
3581 4014
3582 4015 if ( ! empty( $variation_responsive_css ) ) {
3583 4016 $style_variation_responsive_css[ $style_variation['selector'] ] = $variation_responsive_css;
3584 4017 }
4018 + if ( ! empty( $variation_responsive_pseudo_css ) ) {
4019 + $style_variation_responsive_pseudo_css[ $style_variation['selector'] ] = $variation_responsive_pseudo_css;
4020 + }
3585 4021 }
3586 4022 }
3587 4023 /*
3588 4024 * Get a reference to element name from path.
@@ -3750,8 +4186,15 @@
3750 4186 if ( isset( $style_variation_responsive_css[ $style_variation_selector ] ) ) {
3751 4187 $block_rules .= $style_variation_responsive_css[ $style_variation_selector ];
3752 4188 }
3753 4189 }
4190 + /*
4191 + * Responsive pseudo styles must be output after default pseudo styles
4192 + * so viewport state styles win in the cascade.
4193 + */
4194 + foreach ( $style_variation_responsive_pseudo_css as $responsive_pseudo_css ) {
4195 + $block_rules .= $responsive_pseudo_css;
4196 + }
3754 4197
3755 4198 // Compute selector for block custom CSS.
3756 4199 $css_feature_selector = $block_metadata['selectors']['css'] ?? null;
3757 4200 if ( is_array( $css_feature_selector ) ) {
@@ -4185,9 +4628,9 @@
4185 4628 * Gets a `default`'s preset name by a provided slug.
4186 4629 *
4187 4630 * @since 5.9.0
4188 4631 *
4189 - * @param string $slug The slug we want to find a match from default presets.
4632 + * @param string $slug The slug we want to find a match from default presets.
4190 4633 * @param array $base_path The path to inspect. It's 'settings' by default.
4191 4634 * @return string|null
4192 4635 */
4193 4636 protected function get_name_from_defaults( $slug, $base_path ) {
@@ -4235,10 +4678,10 @@
4235 4678 * @since 5.9.0
4236 4679 * @since 6.6.0 Added support for block style variation element styles and $origin parameter.
4237 4680 *
4238 4681 * @param array $theme_json Structure to sanitize.
4239 - * @param string $origin Optional. What source of data this object represents.
4240 - * One of 'blocks', 'default', 'theme', or 'custom'. Default 'theme'.
4682 + * @param string $origin Optional. What source of data this object represents.
4683 + * One of 'blocks', 'default', 'theme', or 'custom'. Default 'theme'.
4241 4684 * @return array Sanitized structure.
4242 4685 */
4243 4686 public static function remove_insecure_properties( $theme_json, $origin = 'theme' ) {
4244 4687 if ( ! in_array( $origin, static::VALID_ORIGINS, true ) ) {
@@ -4247,8 +4690,11 @@
4247 4690
4248 4691 $sanitized = array();
4249 4692
4250 4693 $theme_json = WP_Theme_JSON_Schema_Gutenberg::migrate( $theme_json, $origin );
4694 + if ( isset( $theme_json['styles'] ) ) {
4695 + $theme_json['styles'] = gutenberg_resolve_style_state_aliases( $theme_json['styles'] );
4696 + }
4251 4697
4252 4698 $blocks_metadata = static::get_blocks_metadata();
4253 4699 $valid_block_names = array_keys( $blocks_metadata );
4254 4700 $valid_element_names = array_keys( static::ELEMENTS );
@@ -4255,11 +4701,12 @@
4255 4701 $valid_variations = static::get_valid_block_style_variations( $blocks_metadata );
4256 4702
4257 4703 $theme_json = static::sanitize( $theme_json, $valid_block_names, $valid_element_names, $valid_variations );
4258 4704
4259 - $blocks_metadata = static::get_blocks_metadata();
4260 - $style_options = array( 'include_block_style_variations' => true ); // Allow variations data.
4261 - $style_nodes = static::get_style_nodes( $theme_json, $blocks_metadata, $style_options );
4705 + $blocks_metadata = static::get_blocks_metadata();
4706 + $style_options = array( 'include_block_style_variations' => true ); // Allow variations data.
4707 + $style_nodes = static::get_style_nodes( $theme_json, $blocks_metadata, $style_options );
4708 + $responsive_media_queries = static::get_viewport_media_queries( $theme_json['settings']['viewport'] ?? null );
4262 4709
4263 4710 foreach ( $style_nodes as $metadata ) {
4264 4711 $input = _wp_array_get( $theme_json, $metadata['path'], array() );
4265 4712 if ( empty( $input ) ) {
@@ -4295,18 +4742,18 @@
4295 4742 }
4296 4743 }
4297 4744
4298 4745 // Re-add and process responsive breakpoint styles.
4299 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
4746 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
4300 4747 if ( isset( $input[ $breakpoint ] ) ) {
4301 4748 $output[ $breakpoint ] = static::remove_insecure_styles( $input[ $breakpoint ] );
4302 4749
4303 4750 if ( isset( $input[ $breakpoint ]['elements'] ) ) {
4304 - $output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $input[ $breakpoint ]['elements'] );
4751 + $output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $input[ $breakpoint ]['elements'], $responsive_media_queries );
4305 4752 }
4306 4753
4307 4754 if ( isset( $input[ $breakpoint ]['blocks'] ) ) {
4308 - $output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $input[ $breakpoint ]['blocks'] );
4755 + $output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $input[ $breakpoint ]['blocks'], $responsive_media_queries );
4309 4756 }
4310 4757
4311 4758 if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) {
4312 4759 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) {
@@ -4336,26 +4783,26 @@
4336 4783
4337 4784 $variation_output = static::remove_insecure_styles( $variation_input );
4338 4785
4339 4786 if ( isset( $variation_input['blocks'] ) ) {
4340 - $variation_output['blocks'] = static::remove_insecure_inner_block_styles( $variation_input['blocks'] );
4787 + $variation_output['blocks'] = static::remove_insecure_inner_block_styles( $variation_input['blocks'], $responsive_media_queries );
4341 4788 }
4342 4789
4343 4790 if ( isset( $variation_input['elements'] ) ) {
4344 - $variation_output['elements'] = static::remove_insecure_element_styles( $variation_input['elements'] );
4791 + $variation_output['elements'] = static::remove_insecure_element_styles( $variation_input['elements'], $responsive_media_queries );
4345 4792 }
4346 4793
4347 4794 // Re-add and process responsive breakpoint styles for variations.
4348 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
4795 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
4349 4796 if ( isset( $variation_input[ $breakpoint ] ) ) {
4350 4797 $variation_output[ $breakpoint ] = static::remove_insecure_styles( $variation_input[ $breakpoint ] );
4351 4798
4352 4799 if ( isset( $variation_input[ $breakpoint ]['elements'] ) ) {
4353 - $variation_output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $variation_input[ $breakpoint ]['elements'] );
4800 + $variation_output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $variation_input[ $breakpoint ]['elements'], $responsive_media_queries );
4354 4801 }
4355 4802
4356 4803 if ( isset( $variation_input[ $breakpoint ]['blocks'] ) ) {
4357 - $variation_output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $variation_input[ $breakpoint ]['blocks'] );
4804 + $variation_output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $variation_input[ $breakpoint ]['blocks'], $responsive_media_queries );
4358 4805 }
4359 4806
4360 4807 if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) {
4361 4808 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) {
@@ -4385,9 +4832,9 @@
4385 4832 if ( empty( $input ) ) {
4386 4833 continue;
4387 4834 }
4388 4835
4389 - $output = static::remove_insecure_settings( $input );
4836 + $output = static::remove_insecure_settings( $input, array( 'settings' ) === $metadata['path'] );
4390 4837 if ( ! empty( $output ) ) {
4391 4838 _wp_array_set( $sanitized, $metadata['path'], $output );
4392 4839 }
4393 4840 }
@@ -4409,14 +4856,19 @@
4409 4856
4410 4857 /**
4411 4858 * Remove insecure element styles within a variation or block.
4412 4859 *
4860 + * When responsive media queries are provided, nested responsive state styles
4861 + * for those media-query keys are re-added after the base sanitization pass.
4862 + *
4413 4863 * @since 6.8.0
4414 4864 *
4415 - * @param array $elements The elements to process.
4865 + * @param array $elements The elements to process.
4866 + * @param array|null $responsive_media_queries Optional. Media queries whose keys define allowed
4867 + * viewport states. Default null.
4416 4868 * @return array The sanitized elements styles.
4417 4869 */
4418 - protected static function remove_insecure_element_styles( $elements ) {
4870 + protected static function remove_insecure_element_styles( $elements, $responsive_media_queries = null ) {
4419 4871 $sanitized = array();
4420 4872 $valid_element_names = array_keys( static::ELEMENTS );
4421 4873
4422 4874 foreach ( $valid_element_names as $element_name ) {
@@ -4431,17 +4883,19 @@
4431 4883 }
4432 4884 }
4433 4885 }
4434 4886
4435 - // Re-add and process responsive breakpoint styles for elements.
4436 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
4437 - if ( isset( $element_input[ $breakpoint ] ) ) {
4438 - $element_output[ $breakpoint ] = static::remove_insecure_styles( $element_input[ $breakpoint ] );
4887 + if ( null !== $responsive_media_queries ) {
4888 + // Re-add and process responsive breakpoint styles for elements.
4889 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
4890 + if ( isset( $element_input[ $breakpoint ] ) ) {
4891 + $element_output[ $breakpoint ] = static::remove_insecure_styles( $element_input[ $breakpoint ] );
4439 4892
4440 - if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) {
4441 - foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) {
4442 - if ( isset( $element_input[ $breakpoint ][ $pseudo_selector ] ) ) {
4443 - $element_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $element_input[ $breakpoint ][ $pseudo_selector ] );
4893 + if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) {
4894 + foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) {
4895 + if ( isset( $element_input[ $breakpoint ][ $pseudo_selector ] ) ) {
4896 + $element_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $element_input[ $breakpoint ][ $pseudo_selector ] );
4897 + }
4444 4898 }
4445 4899 }
4446 4900 }
4447 4901 }
@@ -4455,31 +4909,38 @@
4455 4909
4456 4910 /**
4457 4911 * Remove insecure styles from inner blocks and their elements.
4458 4912 *
4913 + * When responsive media queries are provided, nested responsive state styles
4914 + * for those media-query keys are re-added after the base sanitization pass.
4915 + *
4459 4916 * @since 6.8.0
4460 4917 *
4461 - * @param array $blocks The block styles to process.
4918 + * @param array $blocks The block styles to process.
4919 + * @param array|null $responsive_media_queries Optional. Media queries whose keys define allowed
4920 + * viewport states. Default null.
4462 4921 * @return array Sanitized block type styles.
4463 4922 */
4464 - protected static function remove_insecure_inner_block_styles( $blocks ) {
4923 + protected static function remove_insecure_inner_block_styles( $blocks, $responsive_media_queries = null ) {
4465 4924 $sanitized = array();
4466 4925 foreach ( $blocks as $block_type => $block_input ) {
4467 4926 $block_output = static::remove_insecure_styles( $block_input );
4468 4927
4469 4928 if ( isset( $block_input['elements'] ) ) {
4470 - $block_output['elements'] = static::remove_insecure_element_styles( $block_input['elements'] );
4929 + $block_output['elements'] = static::remove_insecure_element_styles( $block_input['elements'], $responsive_media_queries );
4471 4930 }
4472 4931
4473 - // Re-add and process responsive breakpoint styles for inner blocks.
4474 - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
4475 - if ( isset( $block_input[ $breakpoint ] ) ) {
4476 - $block_output[ $breakpoint ] = static::remove_insecure_styles( $block_input[ $breakpoint ] );
4932 + if ( null !== $responsive_media_queries ) {
4933 + // Re-add and process responsive breakpoint styles for inner blocks.
4934 + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) {
4935 + if ( isset( $block_input[ $breakpoint ] ) ) {
4936 + $block_output[ $breakpoint ] = static::remove_insecure_styles( $block_input[ $breakpoint ] );
4477 4937
4478 - if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] ) ) {
4479 - foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] as $pseudo_selector ) {
4480 - if ( isset( $block_input[ $breakpoint ][ $pseudo_selector ] ) ) {
4481 - $block_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $block_input[ $breakpoint ][ $pseudo_selector ] );
4938 + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] ) ) {
4939 + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] as $pseudo_selector ) {
4940 + if ( isset( $block_input[ $breakpoint ][ $pseudo_selector ] ) ) {
4941 + $block_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $block_input[ $breakpoint ][ $pseudo_selector ] );
4942 + }
4482 4943 }
4483 4944 }
4484 4945 }
4485 4946 }
@@ -4524,12 +4985,14 @@
4524 4985 * without the insecure settings.
4525 4986 *
4526 4987 * @since 5.9.0
4527 4988 *
4528 - * @param array $input Node to process.
4989 + * @param array $input Node to process.
4990 + * @param bool $allow_viewport Whether to preserve and sanitize top-level
4991 + * viewport settings.
4529 4992 * @return array
4530 4993 */
4531 - protected static function remove_insecure_settings( $input ) {
4994 + protected static function remove_insecure_settings( $input, $allow_viewport = true ) {
4532 4995 $output = array();
4533 4996 foreach ( static::PRESETS_METADATA as $preset_metadata ) {
4534 4997 foreach ( static::VALID_ORIGINS as $origin ) {
4535 4998 $path_with_origin = $preset_metadata['path'];
@@ -4580,8 +5043,12 @@
4580 5043
4581 5044 // Preserve all valid settings that have type markers in VALID_SETTINGS.
4582 5045 self::preserve_valid_typed_settings( $input, $output, static::VALID_SETTINGS );
4583 5046
5047 + if ( $allow_viewport && array_key_exists( 'viewport', $input ) ) {
5048 + $output['viewport'] = static::sanitize_viewport_settings( $input['viewport'] );
5049 + }
5050 +
4584 5051 return $output;
4585 5052 }
4586 5053
4587 5054 /**
@@ -5258,8 +5725,9 @@
5258 5725 * This is used to convert the internal representation of variables to the CSS representation.
5259 5726 * For example, `var:preset|color|vivid-green-cyan` becomes `var(--wp--preset--color--vivid-green-cyan)`.
5260 5727 *
5261 5728 * @since 6.3.0
5729 + * @since 7.2.0 Preset reference slugs are kebab-cased to match the generated custom properties.
5262 5730 * @param string $value The variable such as var:preset|color|vivid-green-cyan to convert.
5263 5731 * @return string The converted variable.
5264 5732 */
5265 5733 private static function convert_custom_properties( $value ) {
@@ -5266,15 +5734,32 @@
5266 5734 $prefix = 'var:';
5267 5735 $prefix_len = strlen( $prefix );
5268 5736 $token_in = '|';
5269 5737 $token_out = '--';
5270 - if ( 0 === strpos( $value, $prefix ) ) {
5271 - $unwrapped_name = str_replace(
5272 - $token_in,
5273 - $token_out,
5274 - substr( $value, $prefix_len )
5275 - );
5276 - $value = "var(--wp--$unwrapped_name)";
5738 + if ( str_starts_with( $value, $prefix ) ) {
5739 + $parts = explode( $token_in, substr( $value, $prefix_len ) );
5740 +
5741 + /*
5742 + * The slug of a preset reference is kebab-cased so the resulting
5743 + * custom property matches the one generated from the preset,
5744 + * whose slug is also kebab-cased (see `get_settings_values_by_slug()`).
5745 + * For slugs that are not already kebab-cased (e.g. `n27`), a verbatim
5746 + * conversion produces a reference to a custom property that does
5747 + * not exist (`--wp--preset--font-family--n27` instead of the
5748 + * generated `--wp--preset--font-family--n-27`).
5749 + *
5750 + * Duotone is the exception: its custom properties are generated by
5751 + * `WP_Duotone_Gutenberg` from the presets it registers in
5752 + * `get_all_global_styles_presets()`. Duotone references are
5753 + * kebab-cased all the same: the editor and the JS style engine
5754 + * kebab-case the references of every preset type, and
5755 + * `WP_Duotone_Gutenberg` looks up presets by kebab-cased filter ID.
5756 + */
5757 + if ( 3 === count( $parts ) && 'preset' === $parts[0] ) {
5758 + $parts[2] = _wp_to_kebab_case( $parts[2] );
5759 + }
5760 +
5761 + $value = 'var(--wp--' . implode( $token_out, $parts ) . ')';
5277 5762 }
5278 5763
5279 5764 return $value;
5280 5765 }
@@ -5283,9 +5768,9 @@
5283 5768 * Given a tree, converts the internal representation of variables to the CSS representation.
5284 5769 * It is recursive and modifies the input in-place.
5285 5770 *
5286 5771 * @since 6.3.0
5287 - * @param array $tree Input to process.
5772 + * @param array $tree Input to process.
5288 5773 * @return array The modified $tree.
5289 5774 */
5290 5775 private static function resolve_custom_css_format( $tree ) {
5291 5776 $prefix = 'var:';
@@ -5290,9 +5775,9 @@
5290 5775 private static function resolve_custom_css_format( $tree ) {
5291 5776 $prefix = 'var:';
5292 5777
5293 5778 foreach ( $tree as $key => $data ) {
5294 - if ( is_string( $data ) && 0 === strpos( $data, $prefix ) ) {
5779 + if ( is_string( $data ) && str_starts_with( $data, $prefix ) ) {
5295 5780 $tree[ $key ] = self::convert_custom_properties( $data );
5296 5781 } elseif ( is_array( $data ) ) {
5297 5782 $tree[ $key ] = self::resolve_custom_css_format( $data );
5298 5783 }
@@ -5395,16 +5880,27 @@
5395 5880 return $variation_class;
5396 5881 }
5397 5882
5398 5883 $limit = 1;
5399 - $selector_parts = explode( ',', $block_selector );
5884 + $selector_parts = static::split_selector_list( $block_selector );
5400 5885 $result = array();
5401 5886
5887 + /*
5888 + * Append the variation class to each selector's ancestor: the first
5889 + * run of characters before any combinator (whitespace) or pseudo-class
5890 + * (`:`). Only the first match is replaced.
5891 + *
5892 + * Examples ("custom" variation):
5893 + * - `.wp-block` => `.wp-block.is-style-custom`
5894 + * - `.wp-block .inner` => `.wp-block.is-style-custom .inner`
5895 + * - `.wp-block:where(.a .b)` => `.wp-block.is-style-custom:where(.a .b)`
5896 + * - `:where(.outer .inner)` => `:where(.outer.is-style-custom .inner)`
5897 + */
5402 5898 foreach ( $selector_parts as $part ) {
5403 5899 $result[] = preg_replace_callback(
5404 - '/((?::\([^)]+\))?\s*)([^\s:]+)/',
5900 + '/[^\s:]+/',
5405 5901 function ( $matches ) use ( $variation_class ) {
5406 - return $matches[1] . $matches[2] . $variation_class;
5902 + return $matches[0] . $variation_class;
5407 5903 },
5408 5904 $part,
5409 5905 $limit
5410 5906 );
@@ -5409,9 +5905,52 @@
5409 5905 $limit
5410 5906 );
5411 5907 }
5412 5908
5413 - return implode( ',', $result );
5909 + return implode( ', ', $result );
5910 + }
5911 +
5912 + /**
5913 + * Applies a block style variation class to a feature selector.
5914 + *
5915 + * Feature selectors can target a different element than the block's root
5916 + * selector. For example, the Button block's root selector targets the inner
5917 + * link, while its dimensions width selector targets the outer wrapper. Apply
5918 + * the variation class directly to the selector that will receive the
5919 + * declarations instead of deriving it by subtracting the root selector from
5920 + * the feature selector.
5921 + *
5922 + * @param array $style_variation Style variation metadata.
5923 + * @param string $feature_selector CSS selector for the feature.
5924 + * @return string Feature selector with block style variation selector added.
5925 + */
5926 + protected static function get_block_style_variation_feature_selector( $style_variation, $feature_selector ) {
5927 + $variation_path = $style_variation['path'] ?? array();
5928 + $variation_name = $style_variation['name'] ?? ( is_array( $variation_path ) ? end( $variation_path ) : null );
5929 +
5930 + if ( ! $variation_name ) {
5931 + return $style_variation['selector'] ?? $feature_selector;
5932 + }
5933 +
5934 + $variation_class = ".is-style-$variation_name";
5935 + $selector_parts = static::split_selector_list( $feature_selector );
5936 + $selector_parts = array_map(
5937 + static function ( $selector ) use ( $variation_class ) {
5938 + $prefix = $variation_class . ' ';
5939 +
5940 + if ( str_starts_with( $selector, $prefix ) ) {
5941 + return substr( $selector, strlen( $prefix ) );
5942 + }
5943 +
5944 + return $selector;
5945 + },
5946 + $selector_parts
5947 + );
5948 +
5949 + return static::get_block_style_variation_selector(
5950 + $variation_name,
5951 + implode( ', ', $selector_parts )
5952 + );
5414 5953 }
5415 5954
5416 5955 /**
5417 5956 * Collects valid block style variations keyed by block type.