diff --git a/wp-includes/block-editor.php b/wp-includes/block-editor.php index 4d9707ed05..96339d5e62 100644 --- a/wp-includes/block-editor.php +++ b/wp-includes/block-editor.php @@ -303,15 +303,15 @@ function get_block_editor_settings( array $custom_settings, $block_editor_contex $custom_settings ); - $theme_json = WP_Theme_JSON_Resolver::get_merged_data( $editor_settings ); + $theme_json = WP_Theme_JSON_Resolver::get_merged_data(); if ( WP_Theme_JSON_Resolver::theme_has_support() ) { $editor_settings['styles'][] = array( - 'css' => $theme_json->get_stylesheet( 'block_styles' ), + 'css' => $theme_json->get_stylesheet( array( 'styles', 'presets' ) ), '__unstableType' => 'globalStyles', ); $editor_settings['styles'][] = array( - 'css' => $theme_json->get_stylesheet( 'css_variables' ), + 'css' => $theme_json->get_stylesheet( array( 'variables' ) ), '__experimentalNoWrapper' => true, '__unstableType' => 'globalStyles', ); @@ -358,17 +358,17 @@ function get_block_editor_settings( array $custom_settings, $block_editor_contex $editor_settings['disableCustomFontSizes'] = ! $editor_settings['__experimentalFeatures']['typography']['customFontSize']; unset( $editor_settings['__experimentalFeatures']['typography']['customFontSize'] ); } - if ( isset( $editor_settings['__experimentalFeatures']['typography']['customLineHeight'] ) ) { - $editor_settings['enableCustomLineHeight'] = $editor_settings['__experimentalFeatures']['typography']['customLineHeight']; - unset( $editor_settings['__experimentalFeatures']['typography']['customLineHeight'] ); + if ( isset( $editor_settings['__experimentalFeatures']['typography']['lineHeight'] ) ) { + $editor_settings['enableCustomLineHeight'] = $editor_settings['__experimentalFeatures']['typography']['lineHeight']; + unset( $editor_settings['__experimentalFeatures']['typography']['lineHeight'] ); } if ( isset( $editor_settings['__experimentalFeatures']['spacing']['units'] ) ) { $editor_settings['enableCustomUnits'] = $editor_settings['__experimentalFeatures']['spacing']['units']; unset( $editor_settings['__experimentalFeatures']['spacing']['units'] ); } - if ( isset( $editor_settings['__experimentalFeatures']['spacing']['customPadding'] ) ) { - $editor_settings['enableCustomSpacing'] = $editor_settings['__experimentalFeatures']['spacing']['customPadding']; - unset( $editor_settings['__experimentalFeatures']['spacing']['customPadding'] ); + if ( isset( $editor_settings['__experimentalFeatures']['spacing']['padding'] ) ) { + $editor_settings['enableCustomSpacing'] = $editor_settings['__experimentalFeatures']['spacing']['padding']; + unset( $editor_settings['__experimentalFeatures']['spacing']['padding'] ); } /** diff --git a/wp-includes/block-supports/duotone.php b/wp-includes/block-supports/duotone.php index 04aac0ef1e..c2f2d5256d 100644 --- a/wp-includes/block-supports/duotone.php +++ b/wp-includes/block-supports/duotone.php @@ -69,6 +69,28 @@ function wp_tinycolor_bound01( $n, $max ) { return ( $n % $max ) / (float) $max; } +/** + * Direct port of tinycolor's boundAlpha function to maintain consistency with + * how tinycolor works. + * + * @see https://github.com/bgrins/TinyColor + * + * @since 5.9.0 + * @access private + * + * @param mixed $n Number of unknown type. + * @return float Value in the range [0,1]. + */ +function _wp_tinycolor_bound_alpha( $n ) { + if ( is_numeric( $n ) ) { + $n = (float) $n; + if ( $n >= 0 && $n <= 1 ) { + return $n; + } + } + return 1; +} + /** * Round and convert values of an RGB object. * @@ -170,8 +192,7 @@ function wp_tinycolor_hsl_to_rgb( $hsl_color ) { /** * Parses hex, hsl, and rgb CSS strings using the same regex as TinyColor v1.4.2 - * used in the JavaScript. Only colors output from react-color are implemented - * and the alpha value is ignored as it is not used in duotone. + * used in the JavaScript. Only colors output from react-color are implemented. * * Direct port of TinyColor's function, lightly simplified to maintain * consistency with TinyColor. @@ -180,6 +201,7 @@ function wp_tinycolor_hsl_to_rgb( $hsl_color ) { * @see https://github.com/casesandberg/react-color/ * * @since 5.8.0 + * @since 5.9.0 Added alpha processing. * @access private * * @param string $color_str CSS color string. @@ -199,35 +221,47 @@ function wp_tinycolor_string_to_rgb( $color_str ) { $rgb_regexp = '/^rgb' . $permissive_match3 . '$/'; if ( preg_match( $rgb_regexp, $color_str, $match ) ) { - return wp_tinycolor_rgb_to_rgb( + $rgb = wp_tinycolor_rgb_to_rgb( array( 'r' => $match[1], 'g' => $match[2], 'b' => $match[3], ) ); + + $rgb['a'] = 1; + + return $rgb; } $rgba_regexp = '/^rgba' . $permissive_match4 . '$/'; if ( preg_match( $rgba_regexp, $color_str, $match ) ) { - return wp_tinycolor_rgb_to_rgb( + $rgb = wp_tinycolor_rgb_to_rgb( array( 'r' => $match[1], 'g' => $match[2], 'b' => $match[3], ) ); + + $rgb['a'] = _wp_tinycolor_bound_alpha( $match[4] ); + + return $rgb; } $hsl_regexp = '/^hsl' . $permissive_match3 . '$/'; if ( preg_match( $hsl_regexp, $color_str, $match ) ) { - return wp_tinycolor_hsl_to_rgb( + $rgb = wp_tinycolor_hsl_to_rgb( array( 'h' => $match[1], 's' => $match[2], 'l' => $match[3], ) ); + + $rgb['a'] = 1; + + return $rgb; } $hsla_regexp = '/^hsla' . $permissive_match4 . '$/'; @@ -239,50 +273,87 @@ function wp_tinycolor_string_to_rgb( $color_str ) { 'l' => $match[3], ) ); + + $rgb['a'] = _wp_tinycolor_bound_alpha( $match[4] ); + + return $rgb; } $hex8_regexp = '/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/'; if ( preg_match( $hex8_regexp, $color_str, $match ) ) { - return wp_tinycolor_rgb_to_rgb( + $rgb = wp_tinycolor_rgb_to_rgb( array( 'r' => base_convert( $match[1], 16, 10 ), 'g' => base_convert( $match[2], 16, 10 ), 'b' => base_convert( $match[3], 16, 10 ), ) ); + + $rgb['a'] = _wp_tinycolor_bound_alpha( + base_convert( $match[4], 16, 10 ) / 255 + ); + + return $rgb; } $hex6_regexp = '/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/'; if ( preg_match( $hex6_regexp, $color_str, $match ) ) { - return wp_tinycolor_rgb_to_rgb( + $rgb = wp_tinycolor_rgb_to_rgb( array( 'r' => base_convert( $match[1], 16, 10 ), 'g' => base_convert( $match[2], 16, 10 ), 'b' => base_convert( $match[3], 16, 10 ), ) ); + + $rgb['a'] = 1; + + return $rgb; } $hex4_regexp = '/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/'; if ( preg_match( $hex4_regexp, $color_str, $match ) ) { - return wp_tinycolor_rgb_to_rgb( + $rgb = wp_tinycolor_rgb_to_rgb( array( 'r' => base_convert( $match[1] . $match[1], 16, 10 ), 'g' => base_convert( $match[2] . $match[2], 16, 10 ), 'b' => base_convert( $match[3] . $match[3], 16, 10 ), ) ); + + $rgb['a'] = _wp_tinycolor_bound_alpha( + base_convert( $match[4] . $match[4], 16, 10 ) / 255 + ); + + return $rgb; } $hex3_regexp = '/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/'; if ( preg_match( $hex3_regexp, $color_str, $match ) ) { - return wp_tinycolor_rgb_to_rgb( + $rgb = wp_tinycolor_rgb_to_rgb( array( 'r' => base_convert( $match[1] . $match[1], 16, 10 ), 'g' => base_convert( $match[2] . $match[2], 16, 10 ), 'b' => base_convert( $match[3] . $match[3], 16, 10 ), ) ); + + $rgb['a'] = 1; + + return $rgb; + } + + /* + * The JS color picker considers the string "transparent" to be a hex value, + * so we need to handle it here as a special case. + */ + if ( 'transparent' === $color_str ) { + return array( + 'r' => 0, + 'g' => 0, + 'b' => 0, + 'a' => 0, + ); } } @@ -313,6 +384,95 @@ function wp_register_duotone_support( $block_type ) { } } } +/** + * Renders the duotone filter SVG and returns the CSS filter property to + * reference the rendered SVG. + * + * @since 5.9.0 + * + * @param array $preset Duotone preset value as seen in theme.json. + * @return string Duotone CSS filter property. + */ +function wp_render_duotone_filter_preset( $preset ) { + $duotone_id = $preset['slug']; + $duotone_colors = $preset['colors']; + $filter_id = 'wp-duotone-' . $duotone_id; + $duotone_values = array( + 'r' => array(), + 'g' => array(), + 'b' => array(), + 'a' => array(), + ); + foreach ( $duotone_colors as $color_str ) { + $color = wp_tinycolor_string_to_rgb( $color_str ); + + $duotone_values['r'][] = $color['r'] / 255; + $duotone_values['g'][] = $color['g'] / 255; + $duotone_values['b'][] = $color['b'] / 255; + $duotone_values['a'][] = $color['a']; + } + + ob_start(); + + ?> + + + + + + + + + + + + + + + + + <', $svg ); + $svg = trim( $svg ); + } + + add_action( + /* + * Safari doesn't render SVG filters defined in data URIs, + * and SVG filters won't render in the head of a document, + * so the next best place to put the SVG is in the footer. + */ + is_admin() ? 'admin_footer' : 'wp_footer', + static function () use ( $svg ) { + echo $svg; + } + ); + + return "url('#" . $filter_id . "')"; +} /** * Render out the duotone stylesheet and SVG. diff --git a/wp-includes/class-wp-theme-json-resolver.php b/wp-includes/class-wp-theme-json-resolver.php index 47427c27e3..34d3ab9fcf 100644 --- a/wp-includes/class-wp-theme-json-resolver.php +++ b/wp-includes/class-wp-theme-json-resolver.php @@ -11,6 +11,10 @@ * Class that abstracts the processing of the different data sources * for site-level config and offers an API to work with them. * + * This class is for internal core usage and is not supposed to be used by extenders (plugins and/or themes). + * This is a low-level API that may need to do breaking changes. Please, + * use get_global_settings, get_global_styles, and get_global_stylesheet instead. + * * @access private */ class WP_Theme_JSON_Resolver { @@ -40,9 +44,27 @@ class WP_Theme_JSON_Resolver { private static $theme_has_support = null; /** - * Container to keep loaded i18n schema for `theme.json`. + * Container for data coming from the user. * * @since 5.9.0 + * @var WP_Theme_JSON + */ + private static $user = null; + + /** + * Stores the ID of the custom post type + * that holds the user data. + * + * @since 5.9.0 + * @var integer + */ + private static $user_custom_post_type_id = null; + + /** + * Container to keep loaded i18n schema for `theme.json`. + * + * @since 5.8.0 + * @since 5.9.0 Renamed from $theme_json_i18n * @var array */ private static $i18n_schema = null; @@ -122,34 +144,45 @@ class WP_Theme_JSON_Resolver { /** * Returns the theme's data. * - * Data from theme.json can be augmented via the $theme_support_data variable. - * This is useful, for example, to backfill the gaps in theme.json that a theme - * has declared via add_theme_supports. - * - * Note that if the same data is present in theme.json and in $theme_support_data, - * the theme.json's is not overwritten. + * Data from theme.json will be backfilled from existing + * theme supports, if any. Note that if the same data + * is present in theme.json and in theme supports, + * the theme.json takes precendence. * * @since 5.8.0 + * @since 5.9.0 Theme supports have been inlined and the argument removed. * - * @param array $theme_support_data Optional. Theme support data in theme.json format. - * Default empty array. * @return WP_Theme_JSON Entity that holds theme data. */ - public static function get_theme_data( $theme_support_data = array() ) { + public static function get_theme_data( $deprecated = array() ) { + if ( ! empty( $deprecated ) ) { + _deprecated_argument( __METHOD__, '5.9' ); + } if ( null === self::$theme ) { $theme_json_data = self::read_json_file( self::get_file_path_from_theme( 'theme.json' ) ); $theme_json_data = self::translate( $theme_json_data, wp_get_theme()->get( 'TextDomain' ) ); self::$theme = new WP_Theme_JSON( $theme_json_data ); - } - if ( empty( $theme_support_data ) ) { - return self::$theme; + if ( wp_get_theme()->parent() ) { + // Get parent theme.json. + $parent_theme_json_data = self::read_json_file( self::get_file_path_from_theme( 'theme.json', true ) ); + $parent_theme_json_data = self::translate( $parent_theme_json_data, wp_get_theme()->parent()->get( 'TextDomain' ) ); + $parent_theme = new WP_Theme_JSON( $parent_theme_json_data ); + + // Merge the child theme.json into the parent theme.json. + // The child theme takes precedence over the parent. + $parent_theme->merge( self::$theme ); + self::$theme = $parent_theme; + } } /* - * We want the presets and settings declared in theme.json - * to override the ones declared via add_theme_support. - */ + * We want the presets and settings declared in theme.json + * to override the ones declared via theme supports. + * So we take theme supports, transform it to theme.json shape + * and merge the self::$theme upon that. + */ + $theme_support_data = WP_Theme_JSON::get_from_editor_settings( get_default_block_editor_settings() ); $with_theme_supports = new WP_Theme_JSON( $theme_support_data ); $with_theme_supports->merge( self::$theme ); @@ -157,40 +190,180 @@ class WP_Theme_JSON_Resolver { } /** - * There are different sources of data for a site: core and theme. + * Returns the CPT that contains the user's origin config + * for the current theme or a void array if none found. * - * While the getters {@link get_core_data}, {@link get_theme_data} return the raw data - * from the respective origins, this method merges them all together. + * It can also create and return a new draft CPT. * - * If the same piece of data is declared in different origins (core and theme), - * the last origin overrides the previous. For example, if core disables custom colors - * but a theme enables them, the theme config wins. + * @since 5.9.0 + * + * @param bool $should_create_cpt Optional. Whether a new CPT should be created if no one was found. + * False by default. + * @param array $post_status_filter Filter Optional. CPT by post status. + * ['publish'] by default, so it only fetches published posts. + * + * @return array Custom Post Type for the user's origin config. + */ + private static function get_user_data_from_custom_post_type( $should_create_cpt = false, $post_status_filter = array( 'publish' ) ) { + $user_cpt = array(); + $post_type_filter = 'wp_global_styles'; + $query = new WP_Query( + array( + 'posts_per_page' => 1, + 'orderby' => 'date', + 'order' => 'desc', + 'post_type' => $post_type_filter, + 'post_status' => $post_status_filter, + 'tax_query' => array( + array( + 'taxonomy' => 'wp_theme', + 'field' => 'name', + 'terms' => wp_get_theme()->get_stylesheet(), + ), + ), + ) + ); + + if ( is_array( $query->posts ) && ( 1 === $query->post_count ) ) { + $user_cpt = $query->posts[0]->to_array(); + } elseif ( $should_create_cpt ) { + $cpt_post_id = wp_insert_post( + array( + 'post_content' => '{"version": ' . WP_Theme_JSON::LATEST_SCHEMA . ', "isGlobalStylesUserThemeJSON": true }', + 'post_status' => 'publish', + 'post_title' => __( 'Custom Styles', 'default' ), + 'post_type' => $post_type_filter, + 'post_name' => 'wp-global-styles-' . urlencode( wp_get_theme()->get_stylesheet() ), + 'tax_input' => array( + 'wp_theme' => array( wp_get_theme()->get_stylesheet() ), + ), + ), + true + ); + + if ( is_wp_error( $cpt_post_id ) ) { + $user_cpt = array(); + } else { + $user_cpt = get_post( $cpt_post_id, ARRAY_A ); + } + } + + return $user_cpt; + } + + /** + * Returns the user's origin config. + * + * @since 5.9.0 + * + * @return WP_Theme_JSON Entity that holds user data. + */ + public static function get_user_data() { + if ( null !== self::$user ) { + return self::$user; + } + + $config = array(); + $user_cpt = self::get_user_data_from_custom_post_type(); + + if ( array_key_exists( 'post_content', $user_cpt ) ) { + $decoded_data = json_decode( $user_cpt['post_content'], true ); + + $json_decoding_error = json_last_error(); + if ( JSON_ERROR_NONE !== $json_decoding_error ) { + trigger_error( 'Error when decoding a theme.json schema for user data. ' . json_last_error_msg() ); + return new WP_Theme_JSON( $config, 'user' ); + } + + // Very important to verify if the flag isGlobalStylesUserThemeJSON is true. + // If is not true the content was not escaped and is not safe. + if ( + is_array( $decoded_data ) && + isset( $decoded_data['isGlobalStylesUserThemeJSON'] ) && + $decoded_data['isGlobalStylesUserThemeJSON'] + ) { + unset( $decoded_data['isGlobalStylesUserThemeJSON'] ); + $config = $decoded_data; + } + } + self::$user = new WP_Theme_JSON( $config, 'user' ); + + return self::$user; + } + + /** + * There are three sources of data (origins) for a site: + * core, theme, and user. The user's has higher priority + * than the theme's, and the theme's higher than core's. + * + * Unlike the getters {@link get_core_data}, + * {@link get_theme_data}, and {@link get_user_data}, + * this method returns data after it has been merged + * with the previous origins. This means that if the same piece of data + * is declared in different origins (user, theme, and core), + * the last origin overrides the previous. + * + * For example, if the user has set a background color + * for the paragraph block, and the theme has done it as well, + * the user preference wins. * * @since 5.8.0 + * @since 5.9.0 Add user data and change the arguments. * - * @param array $settings Optional. Existing block editor settings. Default empty array. + * @param string $origin Optional. To what level should we merge data. + * Valid values are 'theme' or 'user'. + * Default is 'user'. * @return WP_Theme_JSON */ - public static function get_merged_data( $settings = array() ) { - $theme_support_data = WP_Theme_JSON::get_from_editor_settings( $settings ); + public static function get_merged_data( $origin = 'user' ) { + if ( is_array( $origin ) ) { + _deprecated_argument( __FUNCTION__, '5.9' ); + } $result = new WP_Theme_JSON(); $result->merge( self::get_core_data() ); - $result->merge( self::get_theme_data( $theme_support_data ) ); + $result->merge( self::get_theme_data() ); + + if ( 'user' === $origin ) { + $result->merge( self::get_user_data() ); + } return $result; } + /** + * Returns the ID of the custom post type + * that stores user data. + * + * @since 5.9.0 + * + * @return integer|null + */ + public static function get_user_custom_post_type_id() { + if ( null !== self::$user_custom_post_type_id ) { + return self::$user_custom_post_type_id; + } + + $user_cpt = self::get_user_data_from_custom_post_type( true ); + + if ( array_key_exists( 'ID', $user_cpt ) ) { + self::$user_custom_post_type_id = $user_cpt['ID']; + } + + return self::$user_custom_post_type_id; + } + /** * Whether the current theme has a theme.json file. * * @since 5.8.0 + * @since 5.9.0 Also check in the parent theme. * * @return bool */ public static function theme_has_support() { if ( ! isset( self::$theme_has_support ) ) { - self::$theme_has_support = (bool) self::get_file_path_from_theme( 'theme.json' ); + self::$theme_has_support = is_readable( get_theme_file_path( 'theme.json' ) ); } return self::$theme_has_support; @@ -202,35 +375,32 @@ class WP_Theme_JSON_Resolver { * If it isn't, returns an empty string, otherwise returns the whole file path. * * @since 5.8.0 + * @since 5.9.0 Adapt to work with child themes. * * @param string $file_name Name of the file. + * @param bool $template Optional. Use template theme directory. Default false. * @return string The whole file path or empty if the file doesn't exist. */ - private static function get_file_path_from_theme( $file_name ) { - /* - * This used to be a locate_template call. However, that method proved problematic - * due to its use of constants (STYLESHEETPATH) that threw errors in some scenarios. - * - * When the theme.json merge algorithm properly supports child themes, - * this should also fall back to the template path, as locate_template did. - */ - $located = ''; - $candidate = get_stylesheet_directory() . '/' . $file_name; - if ( is_readable( $candidate ) ) { - $located = $candidate; - } - return $located; + private static function get_file_path_from_theme( $file_name, $template = false ) { + $path = $template ? get_template_directory() : get_stylesheet_directory(); + $candidate = $path . '/' . $file_name; + + return is_readable( $candidate ) ? $candidate : ''; } /** * Cleans the cached data so it can be recalculated. * * @since 5.8.0 + * @since 5.9.0 Added new variables to reset. */ public static function clean_cached_data() { - self::$core = null; - self::$theme = null; - self::$theme_has_support = null; + self::$core = null; + self::$theme = null; + self::$user = null; + self::$user_custom_post_type_id = null; + self::$theme_has_support = null; + self::$i18n_schema = null; } } diff --git a/wp-includes/class-wp-theme-json-schema.php b/wp-includes/class-wp-theme-json-schema.php new file mode 100644 index 0000000000..6374a911bb --- /dev/null +++ b/wp-includes/class-wp-theme-json-schema.php @@ -0,0 +1,141 @@ + 'border.radius', + 'spacing.customMargin' => 'spacing.margin', + 'spacing.customPadding' => 'spacing.padding', + 'typography.customLineHeight' => 'typography.lineHeight', + ); + + /** + * Function that migrates a given theme.json structure to the last version. + * + * @param array $theme_json The structure to migrate. + * + * @return array The structure in the last version. + */ + public static function migrate( $theme_json ) { + if ( ! isset( $theme_json['version'] ) ) { + $theme_json = array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + ); + } + + if ( 1 === $theme_json['version'] ) { + $theme_json = self::migrate_v1_to_v2( $theme_json ); + } + + return $theme_json; + } + + /** + * Removes the custom prefixes for a few properties + * that were part of v1: + * + * 'border.customRadius' => 'border.radius', + * 'spacing.customMargin' => 'spacing.margin', + * 'spacing.customPadding' => 'spacing.padding', + * 'typography.customLineHeight' => 'typography.lineHeight', + * + * @param array $old Data to migrate. + * + * @return array Data without the custom prefixes. + */ + private static function migrate_v1_to_v2( $old ) { + // Copy everything. + $new = $old; + + // Overwrite the things that changed. + if ( isset( $old['settings'] ) ) { + $new['settings'] = self::rename_paths( $old['settings'], self::V1_TO_V2_RENAMED_PATHS ); + } + + // Set the new version. + $new['version'] = 2; + + return $new; + } + + /** + * Processes the settings subtree. + * + * @param array $settings Array to process. + * @param array $paths_to_rename Paths to rename. + * + * @return array The settings in the new format. + */ + private static function rename_paths( $settings, $paths_to_rename ) { + $new_settings = $settings; + + // Process any renamed/moved paths within default settings. + self::rename_settings( $new_settings, $paths_to_rename ); + + // Process individual block settings. + if ( isset( $new_settings['blocks'] ) && is_array( $new_settings['blocks'] ) ) { + foreach ( $new_settings['blocks'] as &$block_settings ) { + self::rename_settings( $block_settings, $paths_to_rename ); + } + } + + return $new_settings; + } + + /** + * Processes a settings array, renaming or moving properties. + * + * @param array $settings Reference to settings either defaults or an individual block's. + * @param arary $paths_to_rename Paths to rename. + */ + private static function rename_settings( &$settings, $paths_to_rename ) { + foreach ( $paths_to_rename as $original => $renamed ) { + $original_path = explode( '.', $original ); + $renamed_path = explode( '.', $renamed ); + $current_value = _wp_array_get( $settings, $original_path, null ); + + if ( null !== $current_value ) { + _wp_array_set( $settings, $renamed_path, $current_value ); + self::unset_setting_by_path( $settings, $original_path ); + } + } + } + + /** + * Removes a property from within the provided settings by its path. + * + * @param array $settings Reference to the current settings array. + * @param array $path Path to the property to be removed. + * + * @return void + */ + private static function unset_setting_by_path( &$settings, $path ) { + $tmp_settings = &$settings; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $last_key = array_pop( $path ); + foreach ( $path as $key ) { + $tmp_settings = &$tmp_settings[ $key ]; + } + + unset( $tmp_settings[ $last_key ] ); + } +} diff --git a/wp-includes/class-wp-theme-json.php b/wp-includes/class-wp-theme-json.php index d394bd3301..cd80d24d3c 100644 --- a/wp-includes/class-wp-theme-json.php +++ b/wp-includes/class-wp-theme-json.php @@ -10,6 +10,10 @@ /** * Class that encapsulates the processing of structures that adhere to the theme.json spec. * + * This class is for internal core usage and is not supposed to be used by extenders (plugins and/or themes). + * This is a low-level API that may need to do breaking changes. Please, + * use get_global_settings, get_global_styles, and get_global_stylesheet instead. + * * @access private */ class WP_Theme_JSON { @@ -70,119 +74,153 @@ class WP_Theme_JSON { * * This contains the necessary metadata to process them: * - * - path => where to find the preset within the settings section - * - * - value_key => the key that represents the value - * - * - css_var_infix => infix to use in generating the CSS Custom Property. Example: - * --wp--preset----: - * - * - classes => array containing a structure with the classes to - * generate for the presets. Each class should have - * the class suffix and the property name. Example: - * - * .has-- { - * : - * } + * - path => where to find the preset within the settings section + * - value_key => the key that represents the value + * - value_func => the callback to render the value (either value_key or value_func should be present) + * - css_vars => template string to use in generating the CSS Custom Property. + * Example output: "--wp--preset--duotone--blue: " will generate as many CSS Custom Properties as presets defined + * substituting the $slug for the slug's value for each preset value. + * - classes => array containing a structure with the classes to generate for the presets. + * Each key is a template string to resolve similarly to the css_vars and each value is the CSS property to use for that class. + * Example output: ".has-blue-color { color: }" + * - properties => a list of CSS properties to be used by kses to check the preset value is safe. * * @since 5.8.0 + * @since 5.9.0 Added new presets and simplified the metadata structure. * @var array */ const PRESETS_METADATA = array( array( - 'path' => array( 'color', 'palette' ), - 'value_key' => 'color', - 'css_var_infix' => 'color', - 'classes' => array( - array( - 'class_suffix' => 'color', - 'property_name' => 'color', - ), - array( - 'class_suffix' => 'background-color', - 'property_name' => 'background-color', - ), + 'path' => array( 'color', 'palette' ), + 'value_key' => 'color', + 'css_vars' => '--wp--preset--color--$slug', + 'classes' => array( + '.has-$slug-color' => 'color', + '.has-$slug-background-color' => 'background-color', + '.has-$slug-border-color' => 'border-color', ), + 'properties' => array( 'color', 'background-color', 'border-color' ), ), array( - 'path' => array( 'color', 'gradients' ), - 'value_key' => 'gradient', - 'css_var_infix' => 'gradient', - 'classes' => array( - array( - 'class_suffix' => 'gradient-background', - 'property_name' => 'background', - ), - ), + 'path' => array( 'color', 'gradients' ), + 'value_key' => 'gradient', + 'css_vars' => '--wp--preset--gradient--$slug', + 'classes' => array( '.has-$slug-gradient-background' => 'background' ), + 'properties' => array( 'background' ), ), array( - 'path' => array( 'typography', 'fontSizes' ), - 'value_key' => 'size', - 'css_var_infix' => 'font-size', - 'classes' => array( - array( - 'class_suffix' => 'font-size', - 'property_name' => 'font-size', - ), - ), + 'path' => array( 'color', 'duotone' ), + 'value_func' => 'wp_render_duotone_filter_preset', + 'css_vars' => '--wp--preset--duotone--$slug', + 'classes' => array(), + 'properties' => array( 'filter' ), + ), + array( + 'path' => array( 'typography', 'fontSizes' ), + 'value_key' => 'size', + 'css_vars' => '--wp--preset--font-size--$slug', + 'classes' => array( '.has-$slug-font-size' => 'font-size' ), + 'properties' => array( 'font-size' ), + ), + array( + 'path' => array( 'typography', 'fontFamilies' ), + 'value_key' => 'fontFamily', + 'css_vars' => '--wp--preset--font-family--$slug', + 'classes' => array( '.has-$slug-font-family' => 'font-family' ), + 'properties' => array( 'font-family' ), ), ); /** * Metadata for style properties. * - * Each property declares: - * - * - 'value': path to the value in theme.json and block attributes. + * Each element is a direct mapping from the CSS property name to the + * path to the value in theme.json & block attributes. * * @since 5.8.0 + * @since 5.9.0 Added new properties and simplified the metadata structure. * @var array */ const PROPERTIES_METADATA = array( - 'background' => array( - 'value' => array( 'color', 'gradient' ), - ), - 'background-color' => array( - 'value' => array( 'color', 'background' ), - ), - 'color' => array( - 'value' => array( 'color', 'text' ), - ), - 'font-size' => array( - 'value' => array( 'typography', 'fontSize' ), - ), - 'line-height' => array( - 'value' => array( 'typography', 'lineHeight' ), - ), - 'margin' => array( - 'value' => array( 'spacing', 'margin' ), - 'properties' => array( 'top', 'right', 'bottom', 'left' ), - ), - 'padding' => array( - 'value' => array( 'spacing', 'padding' ), - 'properties' => array( 'top', 'right', 'bottom', 'left' ), - ), + 'background' => array( 'color', 'gradient' ), + 'background-color' => array( 'color', 'background' ), + 'border-radius' => array( 'border', 'radius' ), + 'border-top-left-radius' => array( 'border', 'radius', 'topLeft' ), + 'border-top-right-radius' => array( 'border', 'radius', 'topRight' ), + 'border-bottom-left-radius' => array( 'border', 'radius', 'bottomLeft' ), + 'border-bottom-right-radius' => array( 'border', 'radius', 'bottomRight' ), + 'border-color' => array( 'border', 'color' ), + 'border-width' => array( 'border', 'width' ), + 'border-style' => array( 'border', 'style' ), + 'color' => array( 'color', 'text' ), + 'font-family' => array( 'typography', 'fontFamily' ), + 'font-size' => array( 'typography', 'fontSize' ), + 'font-style' => array( 'typography', 'fontStyle' ), + 'font-weight' => array( 'typography', 'fontWeight' ), + 'letter-spacing' => array( 'typography', 'letterSpacing' ), + 'line-height' => array( 'typography', 'lineHeight' ), + 'margin' => array( 'spacing', 'margin' ), + 'margin-top' => array( 'spacing', 'margin', 'top' ), + 'margin-right' => array( 'spacing', 'margin', 'right' ), + 'margin-bottom' => array( 'spacing', 'margin', 'bottom' ), + 'margin-left' => array( 'spacing', 'margin', 'left' ), + 'padding' => array( 'spacing', 'padding' ), + 'padding-top' => array( 'spacing', 'padding', 'top' ), + 'padding-right' => array( 'spacing', 'padding', 'right' ), + 'padding-bottom' => array( 'spacing', 'padding', 'bottom' ), + 'padding-left' => array( 'spacing', 'padding', 'left' ), + '--wp--style--block-gap' => array( 'spacing', 'blockGap' ), + 'text-decoration' => array( 'typography', 'textDecoration' ), + 'text-transform' => array( 'typography', 'textTransform' ), + 'filter' => array( 'filter', 'duotone' ), ); /** + * Protected style properties. + * + * These style properties are only rendered if a setting enables it + * via a value other than `null`. + * + * Each element maps the style property to the corresponding theme.json + * setting key. + * + * @since 5.9.0 + */ + const PROTECTED_PROPERTIES = array( + 'spacing.blockGap' => array( 'spacing', 'blockGap' ), + ); + + /** + * The top-level keys a theme.json can have. + * * @since 5.8.0 + * @since 5.9.0 Renamed from ALLOWED_TOP_LEVEL_KEYS and added new values. * @var string[] */ - const ALLOWED_TOP_LEVEL_KEYS = array( + const VALID_TOP_LEVEL_KEYS = array( + 'customTemplates', 'settings', 'styles', + 'templateParts', 'version', ); /** + * The valid properties under the settings key. + * * @since 5.8.0 + * @since 5.9.0 Renamed from ALLOWED_SETTINGS, gained new properties, and renamed others according to the new schema. * @var array */ - const ALLOWED_SETTINGS = array( + const VALID_SETTINGS = array( 'border' => array( - 'customRadius' => null, + 'color' => null, + 'radius' => null, + 'style' => null, + 'width' => null, ), 'color' => array( + 'background' => null, 'custom' => null, 'customDuotone' => null, 'customGradient' => null, @@ -190,6 +228,7 @@ class WP_Theme_JSON { 'gradients' => null, 'link' => null, 'palette' => null, + 'text' => null, ), 'custom' => null, 'layout' => array( @@ -197,48 +236,61 @@ class WP_Theme_JSON { 'wideSize' => null, ), 'spacing' => array( - 'customMargin' => null, - 'customPadding' => null, - 'units' => null, + 'blockGap' => null, + 'margin' => null, + 'padding' => null, + 'units' => null, ), 'typography' => array( - 'customFontSize' => null, - 'customLineHeight' => null, - 'dropCap' => null, - 'fontSizes' => null, + 'customFontSize' => null, + 'dropCap' => null, + 'fontFamilies' => null, + 'fontSizes' => null, + 'fontStyle' => null, + 'fontWeight' => null, + 'letterSpacing' => null, + 'lineHeight' => null, + 'textDecoration' => null, + 'textTransform' => null, ), ); /** + * The valid properties under the styles key. + * * @since 5.8.0 + * @since 5.9.0 Renamed from ALLOWED_SETTINGS, gained new properties. * @var array */ - const ALLOWED_STYLES = array( + const VALID_STYLES = array( 'border' => array( + 'color' => null, 'radius' => null, + 'style' => null, + 'width' => null, ), 'color' => array( 'background' => null, 'gradient' => null, 'text' => null, ), + 'filter' => array( + 'duotone' => null, + ), 'spacing' => array( - 'margin' => array( - 'top' => null, - 'right' => null, - 'bottom' => null, - 'left' => null, - ), - 'padding' => array( - 'bottom' => null, - 'left' => null, - 'right' => null, - 'top' => null, - ), + 'margin' => null, + 'padding' => null, + 'blockGap' => null, ), 'typography' => array( - 'fontSize' => null, - 'lineHeight' => null, + 'fontFamily' => null, + 'fontSize' => null, + 'fontStyle' => null, + 'fontWeight' => null, + 'letterSpacing' => null, + 'lineHeight' => null, + 'textDecoration' => null, + 'textTransform' => null, ), ); @@ -257,10 +309,13 @@ class WP_Theme_JSON { ); /** + * The latest version of the schema in use. + * * @since 5.8.0 + * @since 5.9.0 Changed value. * @var int */ - const LATEST_SCHEMA = 1; + const LATEST_SCHEMA = 2; /** * Constructor. @@ -276,18 +331,16 @@ class WP_Theme_JSON { $origin = 'theme'; } - if ( ! isset( $theme_json['version'] ) || self::LATEST_SCHEMA !== $theme_json['version'] ) { - $this->theme_json = array(); - return; - } - - $this->theme_json = self::sanitize( $theme_json ); + $this->theme_json = WP_Theme_JSON_Schema::migrate( $theme_json ); + $valid_block_names = array_keys( self::get_blocks_metadata() ); + $valid_element_names = array_keys( self::ELEMENTS ); + $this->theme_json = self::sanitize( $this->theme_json, $valid_block_names, $valid_element_names ); // Internally, presets are keyed by origin. $nodes = self::get_setting_nodes( $this->theme_json ); foreach ( $nodes as $node ) { - foreach ( self::PRESETS_METADATA as $preset ) { - $path = array_merge( $node['path'], $preset['path'] ); + foreach ( self::PRESETS_METADATA as $preset_metadata ) { + $path = array_merge( $node['path'], $preset_metadata['path'] ); $preset = _wp_array_get( $this->theme_json, $path, null ); if ( null !== $preset ) { _wp_array_set( $this->theme_json, $path, array( $origin => $preset ) ); @@ -300,42 +353,39 @@ class WP_Theme_JSON { * Sanitizes the input according to the schemas. * * @since 5.8.0 + * @since 5.9.0 Has new parameters. * * @param array $input Structure to sanitize. + * @param array $valid_block_names List of valid block names. + * @param array $valid_element_names List of valid element names. * @return array The sanitized output. */ - private static function sanitize( $input ) { + private static function sanitize( $input, $valid_block_names, $valid_element_names ) { $output = array(); if ( ! is_array( $input ) ) { return $output; } - $allowed_top_level_keys = self::ALLOWED_TOP_LEVEL_KEYS; - $allowed_settings = self::ALLOWED_SETTINGS; - $allowed_styles = self::ALLOWED_STYLES; - $allowed_blocks = array_keys( self::get_blocks_metadata() ); - $allowed_elements = array_keys( self::ELEMENTS ); + $output = array_intersect_key( $input, array_flip( self::VALID_TOP_LEVEL_KEYS ) ); - $output = array_intersect_key( $input, array_flip( $allowed_top_level_keys ) ); - - // Build the schema. + // Build the schema based on valid block & element names. $schema = array(); $schema_styles_elements = array(); - foreach ( $allowed_elements as $element ) { - $schema_styles_elements[ $element ] = $allowed_styles; + foreach ( $valid_element_names as $element ) { + $schema_styles_elements[ $element ] = self::VALID_STYLES; } $schema_styles_blocks = array(); $schema_settings_blocks = array(); - foreach ( $allowed_blocks as $block ) { - $schema_settings_blocks[ $block ] = $allowed_settings; - $schema_styles_blocks[ $block ] = $allowed_styles; + foreach ( $valid_block_names as $block ) { + $schema_settings_blocks[ $block ] = self::VALID_SETTINGS; + $schema_styles_blocks[ $block ] = self::VALID_STYLES; $schema_styles_blocks[ $block ]['elements'] = $schema_styles_elements; } - $schema['styles'] = $allowed_styles; + $schema['styles'] = self::VALID_STYLES; $schema['styles']['blocks'] = $schema_styles_blocks; $schema['styles']['elements'] = $schema_styles_elements; - $schema['settings'] = $allowed_settings; + $schema['settings'] = self::VALID_SETTINGS; $schema['settings']['blocks'] = $schema_settings_blocks; // Remove anything that's not present in the schema. @@ -360,7 +410,6 @@ class WP_Theme_JSON { return $output; } - /** * Returns the metadata for each block. * @@ -377,14 +426,16 @@ class WP_Theme_JSON { * 'core/heading': { * 'selector': 'h1', * 'elements': {} - * } - * 'core/group': { - * 'selector': '.wp-block-group', + * }, + * 'core/image': { + * 'selector': '.wp-block-image', + * 'duotone': 'img', * 'elements': {} * } * } * * @since 5.8.0 + * @since 5.9.0 Added duotone key with CSS selector. * * @return array Block metadata. */ @@ -407,11 +458,16 @@ class WP_Theme_JSON { self::$blocks_metadata[ $block_name ]['selector'] = '.wp-block-' . str_replace( '/', '-', str_replace( 'core/', '', $block_name ) ); } - /* - * Assign defaults, then overwrite those that the block sets by itself. - * If the block selector is compounded, will append the element to each - * individual block selector. - */ + if ( + isset( $block_type->supports['color']['__experimentalDuotone'] ) && + is_string( $block_type->supports['color']['__experimentalDuotone'] ) + ) { + self::$blocks_metadata[ $block_name ]['duotone'] = $block_type->supports['color']['__experimentalDuotone']; + } + + // Assign defaults, then overwrite those that the block sets by itself. + // If the block selector is compounded, will append the element to each + // individual block selector. $block_selectors = explode( ',', self::$blocks_metadata[ $block_name ]['selector'] ); foreach ( self::ELEMENTS as $el_name => $el_selector ) { $element_selector = array(); @@ -493,25 +549,95 @@ class WP_Theme_JSON { * the theme.json structure this object represents. * * @since 5.8.0 + * @since 5.9.0 Changed the arguments passed to the function. * - * @param string $type Optional. Type of stylesheet we want. Accepts 'all', - * 'block_styles', and 'css_variables'. Default 'all'. + * @param array $types Types of styles to load. Will load all by default. It accepts: + * 'variables': only the CSS Custom Properties for presets & custom ones. + * 'styles': only the styles section in theme.json. + * 'presets': only the classes for the presets. + * @param array $origins A list of origins to include. By default it includes self::VALID_ORIGINS. * @return string Stylesheet. */ - public function get_stylesheet( $type = 'all' ) { + public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' ), $origins = self::VALID_ORIGINS ) { + if ( is_string( $types ) ) { + // Dispatch error and map old arguments to new ones. + _deprecated_argument( __FUNCTION__, '5.9' ); + if ( 'block_styles' === $types ) { + $types = array( 'styles', 'presets' ); + } elseif ( 'css_variables' === $types ) { + $types = array( 'variables' ); + } else { + $types = array( 'variables', 'styles', 'presets' ); + } + } + $blocks_metadata = self::get_blocks_metadata(); $style_nodes = self::get_style_nodes( $this->theme_json, $blocks_metadata ); $setting_nodes = self::get_setting_nodes( $this->theme_json, $blocks_metadata ); - switch ( $type ) { - case 'block_styles': - return $this->get_block_styles( $style_nodes, $setting_nodes ); - case 'css_variables': - return $this->get_css_variables( $setting_nodes ); - default: - return $this->get_css_variables( $setting_nodes ) . $this->get_block_styles( $style_nodes, $setting_nodes ); + $stylesheet = ''; + + if ( in_array( 'variables', $types, true ) ) { + $stylesheet .= $this->get_css_variables( $setting_nodes, $origins ); } + if ( in_array( 'styles', $types, true ) ) { + $stylesheet .= $this->get_block_classes( $style_nodes ); + } + + if ( in_array( 'presets', $types, true ) ) { + $stylesheet .= $this->get_preset_classes( $setting_nodes, $origins ); + } + + return $stylesheet; + } + + /** + * Returns the page templates of the current theme. + * + * @since 5.9.0 + * + * @return array + */ + public function get_custom_templates() { + $custom_templates = array(); + if ( ! isset( $this->theme_json['customTemplates'] ) ) { + return $custom_templates; + } + + foreach ( $this->theme_json['customTemplates'] as $item ) { + if ( isset( $item['name'] ) ) { + $custom_templates[ $item['name'] ] = array( + 'title' => isset( $item['title'] ) ? $item['title'] : '', + 'postTypes' => isset( $item['postTypes'] ) ? $item['postTypes'] : array( 'page' ), + ); + } + } + return $custom_templates; + } + + /** + * Returns the template part data of current theme. + * + * @since 5.9.0 + * + * @return array + */ + public function get_template_parts() { + $template_parts = array(); + if ( ! isset( $this->theme_json['templateParts'] ) ) { + return $template_parts; + } + + foreach ( $this->theme_json['templateParts'] as $item ) { + if ( isset( $item['name'] ) ) { + $template_parts[ $item['name'] ] = array( + 'title' => isset( $item['title'] ) ? $item['title'] : '', + 'area' => isset( $item['area'] ) ? $item['area'] : '', + ); + } + } + return $template_parts; } /** @@ -526,37 +652,15 @@ class WP_Theme_JSON { * style-property-one: value; * } * - * Additionally, it'll also create new rulesets - * as classes for each preset value such as: - * - * .has-value-color { - * color: value; - * } - * - * .has-value-background-color { - * background-color: value; - * } - * - * .has-value-font-size { - * font-size: value; - * } - * - * .has-value-gradient-background { - * background: value; - * } - * - * p.has-value-gradient-background { - * background: value; - * } - * * @since 5.8.0 + * @since 5.9.0 Renamed to get_block_classes and no longer returns preset classes. * - * @param array $style_nodes Nodes with styles. - * @param array $setting_nodes Nodes with settings. + * @param array $style_nodes Nodes with styles. * @return string The new stylesheet. */ - private function get_block_styles( $style_nodes, $setting_nodes ) { + private function get_block_classes( $style_nodes ) { $block_rules = ''; + foreach ( $style_nodes as $metadata ) { if ( null === $metadata['selector'] ) { continue; @@ -564,11 +668,77 @@ class WP_Theme_JSON { $node = _wp_array_get( $this->theme_json, $metadata['path'], array() ); $selector = $metadata['selector']; - $declarations = self::compute_style_properties( $node ); + $settings = _wp_array_get( $this->theme_json, array( 'settings' ) ); + $declarations = self::compute_style_properties( $node, $settings ); + + // 1. Separate the ones who use the general selector + // and the ones who use the duotone selector. + $declarations_duotone = array(); + foreach ( $declarations as $index => $declaration ) { + if ( 'filter' === $declaration['name'] ) { + unset( $declarations[ $index ] ); + $declarations_duotone[] = $declaration; + } + } + + // 2. Generate the rules that use the general selector. $block_rules .= self::to_ruleset( $selector, $declarations ); + + // 3. Generate the rules that use the duotone selector. + if ( isset( $metadata['duotone'] ) && ! empty( $declarations_duotone ) ) { + $selector_duotone = self::scope_selector( $metadata['selector'], $metadata['duotone'] ); + $block_rules .= self::to_ruleset( $selector_duotone, $declarations_duotone ); + } + + if ( self::ROOT_BLOCK_SELECTOR === $selector ) { + $block_rules .= 'body { margin: 0; }'; + $block_rules .= '.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }'; + $block_rules .= '.wp-site-blocks > .alignright { float: right; margin-left: 2em; }'; + $block_rules .= '.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + + $has_block_gap_support = _wp_array_get( $this->theme_json, array( 'settings', 'spacing', 'blockGap' ) ) !== null; + if ( $has_block_gap_support ) { + $block_rules .= '.wp-site-blocks > * { margin-top: 0; margin-bottom: 0; }'; + $block_rules .= '.wp-site-blocks > * + * { margin-top: var( --wp--style--block-gap ); }'; + } + } } + return $block_rules; + } + + /** + * Creates new rulesets as classes for each preset value such as: + * + * .has-value-color { + * color: value; + * } + * + * .has-value-background-color { + * background-color: value; + * } + * + * .has-value-font-size { + * font-size: value; + * } + * + * .has-value-gradient-background { + * background: value; + * } + * + * p.has-value-gradient-background { + * background: value; + * } + * + * @since 5.9.0 + * + * @param array $setting_nodes Nodes with settings. + * @param array $origins List of origins to process presets from. + * @return string The new stylesheet. + */ + private function get_preset_classes( $setting_nodes, $origins ) { $preset_rules = ''; + foreach ( $setting_nodes as $metadata ) { if ( null === $metadata['selector'] ) { continue; @@ -576,10 +746,10 @@ class WP_Theme_JSON { $selector = $metadata['selector']; $node = _wp_array_get( $this->theme_json, $metadata['path'], array() ); - $preset_rules .= self::compute_preset_classes( $node, $selector ); + $preset_rules .= self::compute_preset_classes( $node, $selector, $origins ); } - return $block_rules . $preset_rules; + return $preset_rules; } /** @@ -597,11 +767,13 @@ class WP_Theme_JSON { * } * * @since 5.8.0 + * @since 5.9.0 Added origins parameter. * * @param array $nodes Nodes with settings. + * @param array $origins List of origins to process. * @return string The new stylesheet. */ - private function get_css_variables( $nodes ) { + private function get_css_variables( $nodes, $origins ) { $stylesheet = ''; foreach ( $nodes as $metadata ) { if ( null === $metadata['selector'] ) { @@ -611,7 +783,7 @@ class WP_Theme_JSON { $selector = $metadata['selector']; $node = _wp_array_get( $this->theme_json, $metadata['path'], array() ); - $declarations = array_merge( self::compute_preset_vars( $node ), self::compute_theme_vars( $node ) ); + $declarations = array_merge( self::compute_preset_vars( $node, $origins ), self::compute_theme_vars( $node ) ); $stylesheet .= self::to_ruleset( $selector, $declarations ); } @@ -667,46 +839,19 @@ class WP_Theme_JSON { return implode( ',', $new_selectors ); } - /** - * Given an array of presets keyed by origin and the value key of the preset, - * it returns an array where each key is the preset slug and each value the preset value. - * - * @since 5.8.0 - * - * @param array $preset_per_origin Array of presets keyed by origin. - * @param string $value_key The property of the preset that contains its value. - * @return array Array of presets where each key is a slug and each value is the preset value. - */ - private static function get_merged_preset_by_slug( $preset_per_origin, $value_key ) { - $result = array(); - foreach ( self::VALID_ORIGINS as $origin ) { - if ( ! isset( $preset_per_origin[ $origin ] ) ) { - continue; - } - foreach ( $preset_per_origin[ $origin ] as $preset ) { - /* - * We don't want to use kebabCase here, - * see https://github.com/WordPress/gutenberg/issues/32347 - * However, we need to make sure the generated class or CSS variable - * doesn't contain spaces. - */ - $result[ preg_replace( '/\s+/', '-', $preset['slug'] ) ] = $preset[ $value_key ]; - } - } - return $result; - } - /** * Given a settings array, it returns the generated rulesets * for the preset classes. * * @since 5.8.0 + * @since 5.9.0 Added origins parameter. * * @param array $settings Settings to process. * @param string $selector Selector wrapping the classes. + * @param array $origins List of origins to process. * @return string The result of processing the presets. */ - private static function compute_preset_classes( $settings, $selector ) { + private static function compute_preset_classes( $settings, $selector, $origins ) { if ( self::ROOT_BLOCK_SELECTOR === $selector ) { // Classes at the global level do not need any CSS prefixed, // and we don't want to increase its specificity. @@ -714,17 +859,18 @@ class WP_Theme_JSON { } $stylesheet = ''; - foreach ( self::PRESETS_METADATA as $preset ) { - $preset_per_origin = _wp_array_get( $settings, $preset['path'], array() ); - $preset_by_slug = self::get_merged_preset_by_slug( $preset_per_origin, $preset['value_key'] ); - foreach ( $preset['classes'] as $class ) { - foreach ( $preset_by_slug as $slug => $value ) { + foreach ( self::PRESETS_METADATA as $preset_metadata ) { + $slugs = self::get_settings_slugs( $settings, $preset_metadata, $origins ); + foreach ( $preset_metadata['classes'] as $class => $property ) { + foreach ( $slugs as $slug ) { + $css_var = self::replace_slug_in_string( $preset_metadata['css_vars'], $slug ); + $class_name = self::replace_slug_in_string( $class, $slug ); $stylesheet .= self::to_ruleset( - self::append_to_selector( $selector, '.has-' . _wp_to_kebab_case( $slug ) . '-' . $class['class_suffix'] ), + self::append_to_selector( $selector, $class_name ), array( array( - 'name' => $class['property_name'], - 'value' => 'var(--wp--preset--' . $preset['css_var_infix'] . '--' . _wp_to_kebab_case( $slug ) . ') !important', + 'name' => $property, + 'value' => 'var(' . $css_var . ') !important', ), ) ); @@ -735,6 +881,147 @@ class WP_Theme_JSON { return $stylesheet; } + /** + * Function that scopes a selector with another one. This works a bit like + * SCSS nesting except the `&` operator isn't supported. + * + * + * $scope = '.a, .b .c'; + * $selector = '> .x, .y'; + * $merged = scope_selector( $scope, $selector ); + * // $merged is '.a > .x, .a .y, .b .c > .x, .b .c .y' + * + * + * @since 5.9.0 + * + * @param string $scope Selector to scope to. + * @param string $selector Original selector. + * + * @return string Scoped selector. + */ + private static function scope_selector( $scope, $selector ) { + $scopes = explode( ',', $scope ); + $selectors = explode( ',', $selector ); + + $selectors_scoped = array(); + foreach ( $scopes as $outer ) { + foreach ( $selectors as $inner ) { + $selectors_scoped[] = trim( $outer ) . ' ' . trim( $inner ); + } + } + + return implode( ', ', $selectors_scoped ); + } + + /** + * Gets preset values keyed by slugs based on settings and metadata. + * + * + * $settings = array( + * 'typography' => array( + * 'fontFamilies' => array( + * array( + * 'slug' => 'sansSerif', + * 'fontFamily' => '"Helvetica Neue", sans-serif', + * ), + * array( + * 'slug' => 'serif', + * 'colors' => 'Georgia, serif', + * ) + * ), + * ), + * ); + * $meta = array( + * 'path' => array( 'typography', 'fontFamilies' ), + * 'value_key' => 'fontFamily', + * ); + * $values_by_slug = get_settings_values_by_slug(); + * // $values_by_slug === array( + * // 'sans-serif' => '"Helvetica Neue", sans-serif', + * // 'serif' => 'Georgia, serif', + * // ); + * + * + * @since 5.9.0 + * + * @param array $settings Settings to process. + * @param array $preset_metadata One of the PRESETS_METADATA values. + * @param array $origins List of origins to process. + * @return array Array of presets where each key is a slug and each value is the preset value. + */ + private static function get_settings_values_by_slug( $settings, $preset_metadata, $origins ) { + $preset_per_origin = _wp_array_get( $settings, $preset_metadata['path'], array() ); + + $result = array(); + foreach ( $origins as $origin ) { + if ( ! isset( $preset_per_origin[ $origin ] ) ) { + continue; + } + foreach ( $preset_per_origin[ $origin ] as $preset ) { + $slug = _wp_to_kebab_case( $preset['slug'] ); + + $value = ''; + if ( isset( $preset_metadata['value_key'] ) ) { + $value_key = $preset_metadata['value_key']; + $value = $preset[ $value_key ]; + } elseif ( + isset( $preset_metadata['value_func'] ) && + is_callable( $preset_metadata['value_func'] ) + ) { + $value_func = $preset_metadata['value_func']; + $value = call_user_func( $value_func, $preset ); + } else { + // If we don't have a value, then don't add it to the result. + continue; + } + + $result[ $slug ] = $value; + } + } + return $result; + } + + /** + * Similar to get_settings_values_by_slug, but doesn't compute the value. + * + * @since 5.9.0 + * + * @param array $settings Settings to process. + * @param array $preset_metadata One of the PRESETS_METADATA values. + * @param array $origins List of origins to process. + * @return array Array of presets where the key and value are both the slug. + */ + private static function get_settings_slugs( $settings, $preset_metadata, $origins = self::VALID_ORIGINS ) { + $preset_per_origin = _wp_array_get( $settings, $preset_metadata['path'], array() ); + + $result = array(); + foreach ( $origins as $origin ) { + if ( ! isset( $preset_per_origin[ $origin ] ) ) { + continue; + } + foreach ( $preset_per_origin[ $origin ] as $preset ) { + $slug = _wp_to_kebab_case( $preset['slug'] ); + + // Use the array as a set so we don't get duplicates. + $result[ $slug ] = $slug; + } + } + return $result; + } + + /** + * Transform a slug into a CSS Custom Property. + * + * @since 5.9.0 + * + * @param string $input String to replace. + * @param string $slug The slug value to use to generate the custom property. + * @return string The CSS Custom Property. Something along the lines of --wp--preset--color--black. + */ + private static function replace_slug_in_string( $input, $slug ) { + return strtr( $input, array( '$slug' => $slug ) ); + } + /** * Given the block settings, it extracts the CSS Custom Properties * for the presets and adds them to the $declarations array @@ -748,16 +1035,16 @@ class WP_Theme_JSON { * @since 5.8.0 * * @param array $settings Settings to process. + * @param array $origins List of origins to process. * @return array Returns the modified $declarations. */ - private static function compute_preset_vars( $settings ) { + private static function compute_preset_vars( $settings, $origins ) { $declarations = array(); - foreach ( self::PRESETS_METADATA as $preset ) { - $preset_per_origin = _wp_array_get( $settings, $preset['path'], array() ); - $preset_by_slug = self::get_merged_preset_by_slug( $preset_per_origin, $preset['value_key'] ); - foreach ( $preset_by_slug as $slug => $value ) { + foreach ( self::PRESETS_METADATA as $preset_metadata ) { + $values_by_slug = self::get_settings_values_by_slug( $settings, $preset_metadata, $origins ); + foreach ( $values_by_slug as $slug => $value ) { $declarations[] = array( - 'name' => '--wp--preset--' . $preset['css_var_infix'] . '--' . _wp_to_kebab_case( $slug ), + 'name' => self::replace_slug_in_string( $preset_metadata['css_vars'], $slug ), 'value' => $value, ); } @@ -864,46 +1151,42 @@ class WP_Theme_JSON { * ) * * @since 5.8.0 + * @since 5.9.0 Added theme setting and properties parameters. * * @param array $styles Styles to process. + * @param array $settings Theme settings. + * @param array $properties Properties metadata. * @return array Returns the modified $declarations. */ - private static function compute_style_properties( $styles ) { + private static function compute_style_properties( $styles, $settings = array(), $properties = self::PROPERTIES_METADATA ) { $declarations = array(); if ( empty( $styles ) ) { return $declarations; } - $properties = array(); - foreach ( self::PROPERTIES_METADATA as $name => $metadata ) { - /* - * Some properties can be shorthand properties, meaning that - * they contain multiple values instead of a single one. - * An example of this is the padding property. - */ - if ( self::has_properties( $metadata ) ) { - foreach ( $metadata['properties'] as $property ) { - $properties[] = array( - 'name' => $name . '-' . $property, - 'value' => array_merge( $metadata['value'], array( $property ) ), - ); - } - } else { - $properties[] = array( - 'name' => $name, - 'value' => $metadata['value'], - ); - } - } + foreach ( $properties as $css_property => $value_path ) { + $value = self::get_property_value( $styles, $value_path ); - foreach ( $properties as $prop ) { - $value = self::get_property_value( $styles, $prop['value'] ); - if ( empty( $value ) ) { + // Look up protected properties, keyed by value path. + // Skip protected properties that are explicitly set to `null`. + if ( is_array( $value_path ) ) { + $path_string = implode( '.', $value_path ); + if ( + array_key_exists( $path_string, self::PROTECTED_PROPERTIES ) && + _wp_array_get( $settings, self::PROTECTED_PROPERTIES[ $path_string ], null ) === null + ) { + continue; + } + } + + // Skip if empty and not "0" or value represents array of longhand values. + $has_missing_value = empty( $value ) && ! is_numeric( $value ); + if ( $has_missing_value || is_array( $value ) ) { continue; } $declarations[] = array( - 'name' => $prop['name'], + 'name' => $css_property, 'value' => $value, ); } @@ -911,22 +1194,6 @@ class WP_Theme_JSON { return $declarations; } - /** - * Whether the metadata contains a key named properties. - * - * @since 5.8.0 - * - * @param array $metadata Description of the style property. - * @return bool True if properties exists, false otherwise. - */ - private static function has_properties( $metadata ) { - if ( array_key_exists( 'properties', $metadata ) ) { - return true; - } - - return false; - } - /** * Returns the style property for the given path. * @@ -935,6 +1202,7 @@ class WP_Theme_JSON { * "--wp--preset--color--secondary". * * @since 5.8.0 + * @since 5.9.0 Consider $value that are arrays as well. * * @param array $styles Styles subtree. * @param array $path Which property to process. @@ -943,7 +1211,7 @@ class WP_Theme_JSON { private static function get_property_value( $styles, $path ) { $value = _wp_array_get( $styles, $path, '' ); - if ( '' === $value ) { + if ( '' === $value || is_array( $value ) ) { return $value; } @@ -1015,18 +1283,19 @@ class WP_Theme_JSON { return $nodes; } - /** * Builds metadata for the style nodes, which returns in the form of: * * [ * [ * 'path' => [ 'path', 'to', 'some', 'node' ], - * 'selector' => 'CSS selector for some node' + * 'selector' => 'CSS selector for some node', + * 'duotone' => 'CSS selector for duotone for some node' * ], * [ * 'path' => ['path', 'to', 'other', 'node' ], - * 'selector' => 'CSS selector for other node' + * 'selector' => 'CSS selector for other node', + * 'duotone' => null * ], * ] * @@ -1068,9 +1337,15 @@ class WP_Theme_JSON { $selector = $selectors[ $name ]['selector']; } + $duotone_selector = null; + if ( isset( $selectors[ $name ]['duotone'] ) ) { + $duotone_selector = $selectors[ $name ]['duotone']; + } + $nodes[] = array( 'path' => array( 'styles', 'blocks', $name ), 'selector' => $selector, + 'duotone' => $duotone_selector, ); if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) { @@ -1090,6 +1365,7 @@ class WP_Theme_JSON { * Merge new incoming data. * * @since 5.8.0 + * @since 5.9.0 Duotone preset also has origins. * * @param WP_Theme_JSON $incoming Data to merge. */ @@ -1098,14 +1374,14 @@ class WP_Theme_JSON { $this->theme_json = array_replace_recursive( $this->theme_json, $incoming_data ); /* - * The array_replace_recursive() algorithm merges at the leaf level. + * The array_replace_recursive algorithm merges at the leaf level. * For leaf values that are arrays it will use the numeric indexes for replacement. * In those cases, we want to replace the existing with the incoming value, if it exists. */ $to_replace = array(); $to_replace[] = array( 'spacing', 'units' ); - $to_replace[] = array( 'color', 'duotone' ); foreach ( self::VALID_ORIGINS as $origin ) { + $to_replace[] = array( 'color', 'duotone', $origin ); $to_replace[] = array( 'color', 'palette', $origin ); $to_replace[] = array( 'color', 'gradients', $origin ); $to_replace[] = array( 'typography', 'fontSizes', $origin ); @@ -1122,6 +1398,164 @@ class WP_Theme_JSON { } } } + + } + + /** + * Removes insecure data from theme.json. + * + * @since 5.9.0 + * + * @param array $theme_json Structure to sanitize. + * @return array Sanitized structure. + */ + public static function remove_insecure_properties( $theme_json ) { + $sanitized = array(); + + $theme_json = WP_Theme_JSON_Schema::migrate( $theme_json ); + + $valid_block_names = array_keys( self::get_blocks_metadata() ); + $valid_element_names = array_keys( self::ELEMENTS ); + $theme_json = self::sanitize( $theme_json, $valid_block_names, $valid_element_names ); + + $blocks_metadata = self::get_blocks_metadata(); + $style_nodes = self::get_style_nodes( $theme_json, $blocks_metadata ); + foreach ( $style_nodes as $metadata ) { + $input = _wp_array_get( $theme_json, $metadata['path'], array() ); + if ( empty( $input ) ) { + continue; + } + + $output = self::remove_insecure_styles( $input ); + if ( ! empty( $output ) ) { + _wp_array_set( $sanitized, $metadata['path'], $output ); + } + } + + $setting_nodes = self::get_setting_nodes( $theme_json ); + foreach ( $setting_nodes as $metadata ) { + $input = _wp_array_get( $theme_json, $metadata['path'], array() ); + if ( empty( $input ) ) { + continue; + } + + $output = self::remove_insecure_settings( $input ); + if ( ! empty( $output ) ) { + _wp_array_set( $sanitized, $metadata['path'], $output ); + } + } + + if ( empty( $sanitized['styles'] ) ) { + unset( $theme_json['styles'] ); + } else { + $theme_json['styles'] = $sanitized['styles']; + } + + if ( empty( $sanitized['settings'] ) ) { + unset( $theme_json['settings'] ); + } else { + $theme_json['settings'] = $sanitized['settings']; + } + + return $theme_json; + } + + /** + * Processes a setting node and returns the same node + * without the insecure settings. + * + * @since 5.9.0 + * + * @param array $input Node to process. + * @return array + */ + private static function remove_insecure_settings( $input ) { + $output = array(); + foreach ( self::PRESETS_METADATA as $preset_metadata ) { + $presets = _wp_array_get( $input, $preset_metadata['path'], null ); + if ( null === $presets ) { + continue; + } + + $escaped_preset = array(); + foreach ( $presets as $preset ) { + if ( + esc_attr( esc_html( $preset['name'] ) ) === $preset['name'] && + sanitize_html_class( $preset['slug'] ) === $preset['slug'] + ) { + $value = null; + if ( isset( $preset_metadata['value_key'] ) ) { + $value = $preset[ $preset_metadata['value_key'] ]; + } elseif ( + isset( $preset_metadata['value_func'] ) && + is_callable( $preset_metadata['value_func'] ) + ) { + $value = call_user_func( $preset_metadata['value_func'], $preset ); + } + + $preset_is_valid = true; + foreach ( $preset_metadata['properties'] as $property ) { + if ( ! self::is_safe_css_declaration( $property, $value ) ) { + $preset_is_valid = false; + break; + } + } + + if ( $preset_is_valid ) { + $escaped_preset[] = $preset; + } + } + } + + if ( ! empty( $escaped_preset ) ) { + _wp_array_set( $output, $preset_metadata['path'], $escaped_preset ); + } + } + + return $output; + } + + /** + * Processes a style node and returns the same node + * without the insecure styles. + * + * @since 5.9.0 + * + * @param array $input Node to process. + * @return array + */ + private static function remove_insecure_styles( $input ) { + $output = array(); + $declarations = self::compute_style_properties( $input ); + + foreach ( $declarations as $declaration ) { + if ( self::is_safe_css_declaration( $declaration['name'], $declaration['value'] ) ) { + $path = self::PROPERTIES_METADATA[ $declaration['name'] ]; + + // Check the value isn't an array before adding so as to not + // double up shorthand and longhand styles. + $value = _wp_array_get( $input, $path, array() ); + if ( ! is_array( $value ) ) { + _wp_array_set( $output, $path, $value ); + } + } + } + return $output; + } + + /** + * Checks that a declaration provided by the user is safe. + * + * @since 5.9.0 + * + * @param string $property_name Property name in a CSS declaration, i.e. the `color` in `color: red`. + * @param string $property_value Value in a CSS declaration, i.e. the `red` in `color: red`. + * @return boolean + */ + private static function is_safe_css_declaration( $property_name, $property_value ) { + $style_to_validate = $property_name . ': ' . $property_value; + $filtered = esc_html( safecss_filter_attr( $style_to_validate ) ); + return ! empty( trim( $filtered ) ); } /** @@ -1176,7 +1610,7 @@ class WP_Theme_JSON { if ( ! isset( $theme_settings['settings']['typography'] ) ) { $theme_settings['settings']['typography'] = array(); } - $theme_settings['settings']['typography']['customLineHeight'] = $settings['enableCustomLineHeight']; + $theme_settings['settings']['typography']['lineHeight'] = $settings['enableCustomLineHeight']; } if ( isset( $settings['enableCustomUnits'] ) ) { @@ -1220,7 +1654,7 @@ class WP_Theme_JSON { if ( ! isset( $theme_settings['settings']['spacing'] ) ) { $theme_settings['settings']['spacing'] = array(); } - $theme_settings['settings']['spacing']['customPadding'] = $settings['enableCustomSpacing']; + $theme_settings['settings']['spacing']['padding'] = $settings['enableCustomSpacing']; } return $theme_settings; diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php index a446eed1c2..c4a88b69d2 100644 --- a/wp-includes/default-filters.php +++ b/wp-includes/default-filters.php @@ -336,6 +336,7 @@ add_action( 'current_screen', '_load_remote_block_patterns' ); add_action( 'init', 'check_theme_switched', 99 ); add_action( 'init', array( 'WP_Block_Supports', 'init' ), 22 ); add_action( 'switch_theme', array( 'WP_Theme_JSON_Resolver', 'clean_cached_data' ) ); +add_action( 'start_previewing_theme', array( 'WP_Theme_JSON_Resolver', 'clean_cached_data' ) ); add_action( 'after_switch_theme', '_wp_menus_changed' ); add_action( 'after_switch_theme', '_wp_sidebars_changed' ); add_action( 'wp_print_styles', 'print_emoji_styles' ); diff --git a/wp-includes/kses.php b/wp-includes/kses.php index 3a7f99dd1a..ed480c242a 100644 --- a/wp-includes/kses.php +++ b/wp-includes/kses.php @@ -2260,6 +2260,8 @@ function safecss_filter_attr( $css, $deprecated = '' ) { 'border-bottom-color', 'border-bottom-style', 'border-bottom-width', + 'border-bottom-right-radius', + 'border-bottom-left-radius', 'border-left', 'border-left-color', 'border-left-style', @@ -2268,6 +2270,8 @@ function safecss_filter_attr( $css, $deprecated = '' ) { 'border-top-color', 'border-top-style', 'border-top-width', + 'border-top-left-radius', + 'border-top-right-radius', 'border-spacing', 'border-collapse', @@ -2282,6 +2286,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { 'column-width', 'color', + 'filter', 'font', 'font-family', 'font-size', diff --git a/wp-includes/script-loader.php b/wp-includes/script-loader.php index 44e9263b78..5ab358facb 100644 --- a/wp-includes/script-loader.php +++ b/wp-includes/script-loader.php @@ -2321,8 +2321,7 @@ function wp_enqueue_global_styles() { } if ( null === $stylesheet ) { - $settings = get_default_block_editor_settings(); - $theme_json = WP_Theme_JSON_Resolver::get_merged_data( $settings ); + $theme_json = WP_Theme_JSON_Resolver::get_merged_data(); $stylesheet = $theme_json->get_stylesheet(); if ( $can_use_cache ) { diff --git a/wp-includes/theme-i18n.json b/wp-includes/theme-i18n.json index a01a5aa566..98b2680792 100644 --- a/wp-includes/theme-i18n.json +++ b/wp-includes/theme-i18n.json @@ -5,6 +5,11 @@ { "name": "Font size name" } + ], + "fontFamilies": [ + { + "name": "Font family name" + } ] }, "color": { @@ -31,6 +36,11 @@ { "name": "Font size name" } + ], + "fontFamilies": [ + { + "name": "Font family name" + } ] }, "color": { @@ -47,5 +57,15 @@ } } } - } + }, + "customTemplates": [ + { + "title": "Custom template name" + } + ], + "templateParts": [ + { + "title": "Template part name" + } + ] } diff --git a/wp-includes/theme.json b/wp-includes/theme.json index 17389579ea..f2c71be11a 100644 --- a/wp-includes/theme.json +++ b/wp-includes/theme.json @@ -1,14 +1,19 @@ { - "version": 1, + "version": 2, "settings": { "border": { - "customRadius": false + "color": false, + "radius": false, + "style": false, + "width": false }, "color": { "custom": true, "customDuotone": true, "customGradient": true, "link": false, + "background": true, + "text": true, "duotone": [ { "name": "Dark grayscale" , @@ -177,14 +182,20 @@ ] }, "spacing": { - "customMargin": false, - "customPadding": false, + "blockGap": null, + "margin": false, + "padding": false, "units": [ "px", "em", "rem", "vh", "vw", "%" ] }, "typography": { "customFontSize": true, - "customLineHeight": false, "dropCap": true, + "fontStyle": true, + "fontWeight": true, + "letterSpacing": true, + "lineHeight": false, + "textDecoration": true, + "textTransform": true, "fontSizes": [ { "name": "Small", @@ -216,9 +227,20 @@ "blocks": { "core/button": { "border": { - "customRadius": true + "radius": true + } + }, + "core/pullquote": { + "border": { + "color": true, + "radius": true, + "style": true, + "width": true } } } + }, + "styles": { + "spacing": { "blockGap": "24px" } } } diff --git a/wp-includes/version.php b/wp-includes/version.php index bfab917a7d..f70675a27a 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '5.9-alpha-52048'; +$wp_version = '5.9-alpha-52049'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. diff --git a/wp-settings.php b/wp-settings.php index 33fe2bfcb3..d2778166c1 100644 --- a/wp-settings.php +++ b/wp-settings.php @@ -170,6 +170,7 @@ require ABSPATH . WPINC . '/query.php'; require ABSPATH . WPINC . '/class-wp-date-query.php'; require ABSPATH . WPINC . '/theme.php'; require ABSPATH . WPINC . '/class-wp-theme.php'; +require ABSPATH . WPINC . '/class-wp-theme-json-schema.php'; require ABSPATH . WPINC . '/class-wp-theme-json.php'; require ABSPATH . WPINC . '/class-wp-theme-json-resolver.php'; require ABSPATH . WPINC . '/class-wp-block-template.php';