From 1d21b92a078c9b504b899aa93a19f533d4405eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=20Andr=C3=A9?= Date: Fri, 13 Nov 2020 10:52:47 +0100 Subject: [PATCH] Extract theme json processor (#26803) --- lib/class-wp-theme-json.php | 947 ++++++++++++++++++ lib/global-styles.php | 683 +------------ lib/load.php | 1 + .../editor/global-styles-provider.js | 33 +- .../editor/global-styles-renderer.js | 66 +- phpunit/class-wp-theme-json-test.php | 515 ++++++++++ 6 files changed, 1538 insertions(+), 707 deletions(-) create mode 100644 lib/class-wp-theme-json.php create mode 100644 phpunit/class-wp-theme-json-test.php diff --git a/lib/class-wp-theme-json.php b/lib/class-wp-theme-json.php new file mode 100644 index 0000000000000..c0a3401ef5953 --- /dev/null +++ b/lib/class-wp-theme-json.php @@ -0,0 +1,947 @@ + array( + '__experimentalFontAppearance' => false, + '__experimentalFontFamily' => true, + '__experimentalSelector' => self::GLOBAL_SELECTOR, + '__experimentalTextDecoration' => true, + '__experimentalTextTransform' => true, + 'color' => array( + 'gradients' => true, + 'link' => true, + ), + 'fontSize' => true, + 'lineHeight' => true, + ), + ); + + /** + * Data schema of each context within a theme.json. + * + * Example: + * + * { + * 'context-one': { + * 'styles': { + * 'color': { + * 'background': 'color' + * } + * }, + * 'settings': { + * 'color': { + * 'custom': true + * } + * } + * }, + * 'context-two': { + * 'styles': { + * 'color': { + * 'link': 'color' + * } + * } + * } + * } + */ + const SCHEMA = array( + 'selector' => null, + 'supports' => null, + 'styles' => array( + 'color' => array( + 'background' => null, + 'gradient' => null, + 'link' => null, + 'text' => null, + ), + 'typography' => array( + 'fontFamily' => null, + 'fontSize' => null, + 'fontStyle' => null, + 'fontWeight' => null, + 'lineHeight' => null, + 'textDecoration' => null, + 'textTransform' => null, + ), + ), + 'settings' => array( + 'color' => array( + 'custom' => null, + 'customGradient' => null, + 'gradients' => null, + 'link' => null, + 'palette' => null, + ), + 'spacing' => array( + 'customPadding' => null, + 'units' => null, + ), + 'typography' => array( + 'customFontSize' => null, + 'customLineHeight' => null, + 'dropCap' => null, + 'fontFamilies' => null, + 'fontSizes' => null, + 'fontStyles' => null, + 'fontWeights' => null, + 'textDecorations' => null, + 'textTransforms' => null, + ), + 'custom' => null, + ), + ); + + /** + * Presets are a set of values that serve + * to bootstrap some styles: colors, font sizes, etc. + * + * They are a unkeyed array of values such as: + * + * ```php + * array( + * array( + * 'slug' => 'unique-name-within-the-set', + * 'name' => 'Name for the UI', + * => 'value' + * ), + * ) + * ``` + * + * This contains the necessary metadata to process them: + * + * - path => where to find the preset in a theme.json context + * + * - 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-- { + * : + * } + */ + const PRESETS_METADATA = array( + array( + 'path' => array( 'settings', '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', + ), + ), + ), + array( + 'path' => array( 'settings', 'color', 'gradients' ), + 'value_key' => 'gradient', + 'css_var_infix' => 'gradient', + 'classes' => array( + array( + 'class_suffix' => 'gradient-background', + 'property_name' => 'background', + ), + ), + ), + array( + 'path' => array( 'settings', 'typography', 'fontSizes' ), + 'value_key' => 'size', + 'css_var_infix' => 'font-size', + 'classes' => array( + array( + 'class_suffix' => 'font-size', + 'property_name' => 'font-size', + ), + ), + ), + array( + 'path' => array( 'settings', 'typography', 'fontFamilies' ), + 'value_key' => 'fontFamily', + 'css_var_infix' => 'font-family', + 'classes' => array(), + ), + array( + 'path' => array( 'settings', 'typography', 'fontStyles' ), + 'value_key' => 'slug', + 'css_var_infix' => 'font-style', + 'classes' => array( + array( + 'class_suffix' => 'font-style', + 'property_name' => 'font-style', + ), + ), + ), + array( + 'path' => array( 'settings', 'typography', 'fontWeights' ), + 'value_key' => 'slug', + 'css_var_infix' => 'font-weight', + 'classes' => array( + array( + 'class_suffix' => 'font-weight', + 'property_name' => 'font-weight', + ), + ), + ), + array( + 'path' => array( 'settings', 'typography', 'textDecorations' ), + 'value_key' => 'value', + 'css_var_infix' => 'text-decoration', + 'classes' => array( + array( + 'class_suffix' => 'text-decoration', + 'property_name' => 'text-decoration', + ), + ), + ), + array( + 'path' => array( 'settings', 'typography', 'textTransforms' ), + 'value_key' => 'slug', + 'css_var_infix' => 'text-transform', + 'classes' => array( + array( + 'class_suffix' => 'text-transform', + 'property_name' => 'text-transform', + ), + ), + ), + ); + + /** + * Metadata for style properties. + * + * - 'theme_json' => where the property value is stored + * - 'block_json' => whether the block has declared support for it + */ + const PROPERTIES_METADATA = array( + '--wp--style--color--link' => array( + 'theme_json' => array( 'color', 'link' ), + 'block_json' => array( 'color', 'link' ), + ), + 'background' => array( + 'theme_json' => array( 'color', 'gradient' ), + 'block_json' => array( 'color', 'gradients' ), + ), + 'backgroundColor' => array( + 'theme_json' => array( 'color', 'background' ), + 'block_json' => array( 'color' ), + ), + 'color' => array( + 'theme_json' => array( 'color', 'text' ), + 'block_json' => array( 'color' ), + ), + 'fontFamily' => array( + 'theme_json' => array( 'typography', 'fontFamily' ), + 'block_json' => array( '__experimentalFontFamily' ), + ), + 'fontSize' => array( + 'theme_json' => array( 'typography', 'fontSize' ), + 'block_json' => array( 'fontSize' ), + ), + 'fontStyle' => array( + 'theme_json' => array( 'typography', 'fontStyle' ), + 'block_json' => array( '__experimentalFontAppearance' ), + ), + 'fontWeight' => array( + 'theme_json' => array( 'typography', 'fontWeight' ), + 'block_json' => array( '__experimentalFontAppearance' ), + ), + 'lineHeight' => array( + 'theme_json' => array( 'typography', 'lineHeight' ), + 'block_json' => array( 'lineHeight' ), + ), + 'textDecoration' => array( + 'theme_json' => array( 'typography', 'textDecoration' ), + 'block_json' => array( '__experimentalTextDecoration' ), + ), + 'textTransform' => array( + 'theme_json' => array( 'typography', 'textTransform' ), + 'block_json' => array( '__experimentalTextTransform' ), + ), + ); + + /** + * Constructor. + * + * @param array $contexts A structure that follows the theme.json schema. + */ + public function __construct( $contexts = array() ) { + $this->contexts = array(); + + if ( ! is_array( $contexts ) ) { + return; + } + + $metadata = $this->get_blocks_metadata(); + foreach ( $contexts as $key => $context ) { + if ( ! array_key_exists( $key, $metadata ) ) { + // Skip incoming contexts that can't be found + // within the contexts registered. + continue; + } + + // Filter out top-level keys that aren't valid according to the schema. + $context = array_intersect_key( $context, self::SCHEMA ); + + // Selector & Supports are always taken from metadata. + $this->contexts[ $key ]['selector'] = $metadata[ $key ]['selector']; + $this->contexts[ $key ]['supports'] = $metadata[ $key ]['supports']; + + // Process styles subtree. + $this->process_key( 'styles', $context, self::SCHEMA ); + if ( array_key_exists( 'styles', $context ) ) { + $this->process_key( 'color', $context['styles'], self::SCHEMA['styles'] ); + $this->process_key( 'typography', $context['styles'], self::SCHEMA['styles'] ); + + if ( 0 === count( $context['styles'] ) ) { + unset( $context['styles'] ); + } else { + $this->contexts[ $key ]['styles'] = $context['styles']; + } + } + + // Process settings subtree. + $this->process_key( 'settings', $context, self::SCHEMA ); + if ( array_key_exists( 'settings', $context ) ) { + $this->process_key( 'color', $context['settings'], self::SCHEMA['settings'] ); + $this->process_key( 'spacing', $context['settings'], self::SCHEMA['settings'] ); + $this->process_key( 'typography', $context['settings'], self::SCHEMA['settings'] ); + + if ( 0 === count( $context['settings'] ) ) { + unset( $context['settings'] ); + } else { + $this->contexts[ $key ]['settings'] = $context['settings']; + } + } + } + } + + /** + * Returns the metadata for each block. + * + * Example: + * + * { + * 'global': { + * 'selector': ':root' + * 'supports': [ 'fontSize', 'backgroundColor' ], + * 'blockName': 'global', + * }, + * 'core/heading/h1': { + * 'selector': 'h1' + * 'supports': [ 'fontSize', 'backgroundColor' ], + * 'blockName': 'core/heading', + * } + * } + * + * @return array Block metadata. + */ + public static function get_blocks_metadata() { + if ( null !== self::$blocks_metadata ) { + return self::$blocks_metadata; + } + + self::$blocks_metadata = array(); + + $registry = WP_Block_Type_Registry::get_instance(); + $blocks = array_merge( + $registry->get_all_registered(), + array( self::GLOBAL_TYPE => new WP_Block_Type( self::GLOBAL_TYPE, self::GLOBAL_ARGS ) ) + ); + foreach ( $blocks as $block_name => $block_type ) { + if ( + ! property_exists( $block_type, 'supports' ) || + empty( $block_type->supports ) || + ! is_array( $block_type->supports ) ) { + + // Skips blocks that don't declare support. + // + // TODO: what if there are blocks that don't support + // any style but still need the settings passed down? + continue; + } + + $block_supports = array(); + foreach ( self::PROPERTIES_METADATA as $key => $metadata ) { + if ( gutenberg_experimental_get( $block_type->supports, $metadata['block_json'] ) ) { + $block_supports[] = $key; + } + } + + /* + * Assign the selector for the block. + * + * Some blocks can declare multiple selectors: + * + * - core/heading represents the H1-H6 HTML elements + * - core/list represents the UL and OL HTML elements + * - core/group is meant to represent DIV and other HTML elements + * + * Some other blocks don't provide a selector, + * so we generate a class for them based on their name: + * + * - 'core/group' => '.wp-block-group' + * - 'my-custom-library/block-name' => '.wp-block-my-custom-library-block-name' + * + * Note that, for core blocks, we don't add the `core/` prefix to its class name. + * This is for historical reasons, as they come with a class without that infix. + * + */ + if ( + isset( $block_type->supports['__experimentalSelector'] ) && + is_string( $block_type->supports['__experimentalSelector'] ) + ) { + self::$blocks_metadata[ $block_name ] = array( + 'selector' => $block_type->supports['__experimentalSelector'], + 'supports' => $block_supports, + 'blockName' => $block_name, + ); + } elseif ( + isset( $block_type->supports['__experimentalSelector'] ) && + is_array( $block_type->supports['__experimentalSelector'] ) + ) { + foreach ( $block_type->supports['__experimentalSelector'] as $key => $selector ) { + self::$blocks_metadata[ $key ] = array( + 'selector' => $selector, + 'supports' => $block_supports, + 'blockName' => $block_name, + ); + } + } else { + self::$blocks_metadata[ $block_name ] = array( + 'selector' => '.wp-block-' . str_replace( '/', '-', str_replace( 'core/', '', $block_name ) ), + 'supports' => $block_supports, + 'blockName' => $block_name, + ); + } + } + + return self::$blocks_metadata; + } + + /** + * Normalize the subtree according to the given schema. + * This function modifies the given input by removing + * the nodes that aren't valid per the schema. + * + * @param string $key Key of the subtree to normalize. + * @param array $input Whole tree to normalize. + * @param array $schema Schema to use for normalization. + */ + private static function process_key( $key, &$input, $schema ) { + if ( ! is_array( $input ) || ! array_key_exists( $key, $input ) ) { + return; + } + + // Consider valid the input value. + if ( null === $schema[ $key ] ) { + return; + } + + if ( ! is_array( $input[ $key ] ) ) { + unset( $input[ $key ] ); + return; + } + + $input[ $key ] = array_intersect_key( + $input[ $key ], + $schema[ $key ] + ); + + if ( 0 === count( $input[ $key ] ) ) { + unset( $input[ $key ] ); + } + } + + /** + * Given a context, it returns its settings subtree. + * + * @param array $context Context adhering to the theme.json schema. + * + * @return array|null The settings subtree. + */ + private static function extract_settings( $context ) { + if ( + ! array_key_exists( 'settings', $context ) || + empty( $context['settings'] ) + ) { + return null; + } + + return $context['settings']; + } + + /** + * Given a tree, it creates a flattened one + * by merging the keys and binding the leaf values + * to the new keys. + * + * It also transforms camelCase names into kebab-case + * and substitutes '/' by '-'. + * + * This is thought to be useful to generate + * CSS Custom Properties from a tree, + * although there's nothing in the implementation + * of this function that requires that format. + * + * For example, assuming the given prefix is '--wp' + * and the token is '--', for this input tree: + * + * { + * 'some/property': 'value', + * 'nestedProperty': { + * 'sub-property': 'value' + * } + * } + * + * it'll return this output: + * + * { + * '--wp--some-property': 'value', + * '--wp--nested-property--sub-property': 'value' + * } + * + * @param array $tree Input tree to process. + * @param string $prefix Prefix to prepend to each variable. '' by default. + * @param string $token Token to use between levels. '--' by default. + * + * @return array The flattened tree. + */ + private static function flatten_tree( $tree, $prefix = '', $token = '--' ) { + $result = array(); + foreach ( $tree as $property => $value ) { + $new_key = $prefix . str_replace( + '/', + '-', + strtolower( preg_replace( '/(? 'property_name', + * 'value' => 'property_value, + * ) + * ``` + * + * Note that this modifies the $declarations in place. + * + * @param array $declarations Holds the existing declarations. + * @param array $context Input context to process. + */ + private static function compute_style_properties( &$declarations, $context ) { + if ( + ! array_key_exists( 'supports', $context ) || + empty( $context['supports'] ) || + ! array_key_exists( 'styles', $context ) || + empty( $context['styles'] ) + ) { + return; + } + + foreach ( self::PROPERTIES_METADATA as $name => $metadata ) { + if ( ! in_array( $name, $context['supports'], true ) ) { + continue; + } + + $value = self::get_property_value( $context['styles'], $metadata['theme_json'] ); + if ( ! empty( $value ) ) { + $kebabcased_name = strtolower( preg_replace( '/(? $kebabcased_name, + 'value' => $value, + ); + } + } + } + + /** + * Given a context, it extracts its presets + * and adds them to the given input $stylesheet. + * + * Note this function modifies $stylesheet in place. + * + * @param string $stylesheet Input stylesheet to add the presets to. + * @param array $context Context to process. + */ + private static function compute_preset_classes( &$stylesheet, $context ) { + $selector = $context['selector']; + if ( self::GLOBAL_SELECTOR === $selector ) { + // Classes at the global level do not need any CSS prefixed, + // and we don't want to increase its specificity. + $selector = ''; + } + + foreach ( self::PRESETS_METADATA as $preset ) { + $values = gutenberg_experimental_get( $context, $preset['path'], array() ); + foreach ( $values as $value ) { + foreach ( $preset['classes'] as $class ) { + $stylesheet .= self::to_ruleset( + $selector . '.has-' . $value['slug'] . '-' . $class['class_suffix'], + array( + array( + 'name' => $class['property_name'], + 'value' => $value[ $preset['value_key'] ], + ), + ) + ); + } + } + } + } + + /** + * Given a context, it extracts the CSS Custom Properties + * for the presets and adds them to the $declarations array + * following the format: + * + * ```php + * array( + * 'name' => 'property_name', + * 'value' => 'property_value, + * ) + * ``` + * + * Note that this modifies the $declarations in place. + * + * @param array $declarations Holds the existing declarations. + * @param array $context Input context to process. + */ + private static function compute_preset_vars( &$declarations, $context ) { + foreach ( self::PRESETS_METADATA as $preset ) { + $values = gutenberg_experimental_get( $context, $preset['path'], array() ); + foreach ( $values as $value ) { + $declarations[] = array( + 'name' => '--wp--preset--' . $preset['css_var_infix'] . '--' . $value['slug'], + 'value' => $value[ $preset['value_key'] ], + ); + } + } + } + + /** + * Given a context, it extracts the CSS Custom Properties + * for the custom values and adds them to the $declarations + * array following the format: + * + * ```php + * array( + * 'name' => 'property_name', + * 'value' => 'property_value, + * ) + * ``` + * + * Note that this modifies the $declarations in place. + * + * @param array $declarations Holds the existing declarations. + * @param array $context Input context to process. + */ + private static function compute_theme_vars( &$declarations, $context ) { + $custom_values = gutenberg_experimental_get( $context, array( 'settings', 'custom' ) ); + $css_vars = self::flatten_tree( $custom_values ); + foreach ( $css_vars as $key => $value ) { + $declarations[] = array( + 'name' => '--wp--custom--' . $key, + 'value' => $value, + ); + } + } + + /** + * Given a selector and a declaration list, + * creates the corresponding ruleset. + * + * To help debugging, will add some space + * if SCRIPT_DEBUG is defined and true. + * + * @param string $selector CSS selector. + * @param array $declarations List of declarations. + * + * @return string CSS ruleset. + */ + private static function to_ruleset( $selector, $declarations ) { + $ruleset = ''; + + if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { + $declaration_block = array_reduce( + $declarations, + function ( $carry, $element ) { + return $carry .= "\t" . $element['name'] . ': ' . $element['value'] . ";\n"; }, + '' + ); + $ruleset .= $selector . " {\n" . $declaration_block . "}\n"; + } else { + $declaration_block = array_reduce( + $declarations, + function ( $carry, $element ) { + return $carry .= $element['name'] . ': ' . $element['value'] . ';'; }, + '' + ); + $ruleset .= $selector . '{' . $declaration_block . '}'; + } + + return $ruleset; + } + + /** + * Converts each context into a list of rulesets + * to be appended to the stylesheet. + * + * See glossary at https://developer.mozilla.org/en-US/docs/Web/CSS/Syntax + * + * For each context this creates a new ruleset such as: + * + * context-selector { + * style-property-one: value; + * --wp--preset--category--slug: value; + * --wp--custom--variable: 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; + * } + * + * @param string $stylesheet Stylesheet to append new rules to. + * @param array $context Context to be processed. + * + * @return string The new stylesheet. + */ + private static function to_stylesheet( $stylesheet, $context ) { + if ( + ! array_key_exists( 'selector', $context ) || + empty( $context['selector'] ) + ) { + return ''; + } + + $declarations = array(); + self::compute_style_properties( $declarations, $context ); + self::compute_preset_vars( $declarations, $context ); + self::compute_theme_vars( $declarations, $context ); + + // If there are no declarations at this point, + // it won't have any preset classes either, + // so bail out earlier. + if ( empty( $declarations ) ) { + return ''; + } + + // Attach the ruleset for style and custom properties. + $stylesheet .= self::to_ruleset( $context['selector'], $declarations ); + + // Attach the rulesets for the classes. + self::compute_preset_classes( $stylesheet, $context ); + + return $stylesheet; + } + + /** + * Returns the existing settings for each context. + * + * Example: + * + * { + * 'global': { + * 'color': { + * 'custom': true + * } + * }, + * 'core/paragraph': { + * 'spacing': { + * 'customPadding': true + * } + * } + * } + * + * @return array Settings per context. + */ + public function get_settings() { + return array_filter( + array_map( array( $this, 'extract_settings' ), $this->contexts ), + function ( $element ) { + return null !== $element; + } + ); + } + + /** + * Returs the stylesheet that results of processing + * the theme.json structure this object represents. + * + * @return string Stylesheet. + */ + public function get_stylesheet() { + return array_reduce( $this->contexts, array( $this, 'to_stylesheet' ), '' ); + } + + /** + * Merge new incoming data. + * + * @param WP_Theme_JSON $theme_json Data to merge. + */ + public function merge( $theme_json ) { + $incoming_data = $theme_json->get_raw_data(); + $metadata = $this->get_blocks_metadata(); + + foreach ( array_keys( $incoming_data ) as $context ) { + // Selector & Supports are always taken from metadata. + $this->contexts[ $context ]['selector'] = $metadata[ $context ]['selector']; + $this->contexts[ $context ]['supports'] = $metadata[ $context ]['supports']; + + foreach ( array( 'settings', 'styles' ) as $subtree ) { + if ( ! array_key_exists( $subtree, $incoming_data[ $context ] ) ) { + continue; + } + + if ( ! array_key_exists( $subtree, $this->contexts[ $context ] ) ) { + $this->contexts[ $context ][ $subtree ] = $incoming_data[ $context ][ $subtree ]; + continue; + } + + foreach ( array_keys( self::SCHEMA[ $subtree ] ) as $leaf ) { + if ( ! array_key_exists( $leaf, $incoming_data[ $context ][ $subtree ] ) ) { + continue; + } + + if ( ! array_key_exists( $leaf, $this->contexts[ $context ][ $subtree ] ) ) { + $this->contexts[ $context ][ $subtree ][ $leaf ] = $incoming_data[ $context ][ $subtree ][ $leaf ]; + continue; + } + + $this->contexts[ $context ][ $subtree ][ $leaf ] = array_merge( + $this->contexts[ $context ][ $subtree ][ $leaf ], + $incoming_data[ $context ][ $subtree ][ $leaf ] + ); + } + } + } + } + + /** + * Retuns the raw data. + * + * @return array Raw data. + */ + public function get_raw_data() { + return $this->contexts; + } + +} diff --git a/lib/global-styles.php b/lib/global-styles.php index 0ea333bad8c4e..7efd18b26bae5 100644 --- a/lib/global-styles.php +++ b/lib/global-styles.php @@ -14,64 +14,6 @@ function gutenberg_experimental_global_styles_has_theme_json_support() { return is_readable( locate_template( 'experimental-theme.json' ) ); } -/** - * Given a tree, it creates a flattened one - * by merging the keys and binding the leaf values - * to the new keys. - * - * It also transforms camelCase names into kebab-case - * and substitutes '/' by '-'. - * - * This is thought to be useful to generate - * CSS Custom Properties from a tree, - * although there's nothing in the implementation - * of this function that requires that format. - * - * For example, assuming the given prefix is '--wp' - * and the token is '--', for this input tree: - * - * { - * 'some/property': 'value', - * 'nestedProperty': { - * 'sub-property': 'value' - * } - * } - * - * it'll return this output: - * - * { - * '--wp--some-property': 'value', - * '--wp--nested-property--sub-property': 'value' - * } - * - * @param array $tree Input tree to process. - * @param string $prefix Prefix to prepend to each variable. '' by default. - * @param string $token Token to use between levels. '--' by default. - * - * @return array The flattened tree. - */ -function gutenberg_experimental_global_styles_get_css_vars( $tree, $prefix = '', $token = '--' ) { - $result = array(); - foreach ( $tree as $property => $value ) { - $new_key = $prefix . str_replace( - '/', - '-', - strtolower( preg_replace( '/(? __( 'Black', 'gutenberg' ), @@ -209,7 +152,6 @@ function gutenberg_experimental_global_styles_get_core() { 'vivid-cyan-blue' => __( 'Vivid cyan blue', 'gutenberg' ), 'vivid-purple' => __( 'Vivid purple', 'gutenberg' ), ); - if ( ! empty( $config['global']['settings']['color']['palette'] ) ) { foreach ( $config['global']['settings']['color']['palette'] as &$color ) { $color['name'] = $default_colors_i18n[ $color['slug'] ]; @@ -230,7 +172,6 @@ function gutenberg_experimental_global_styles_get_core() { 'electric-grass' => __( 'Electric grass', 'gutenberg' ), 'midnight' => __( 'Midnight', 'gutenberg' ), ); - if ( ! empty( $config['global']['settings']['color']['gradients'] ) ) { foreach ( $config['global']['settings']['color']['gradients'] as &$gradient ) { $gradient['name'] = $default_gradients_i18n[ $gradient['slug'] ]; @@ -244,7 +185,6 @@ function gutenberg_experimental_global_styles_get_core() { 'large' => __( 'Large', 'gutenberg' ), 'huge' => __( 'Huge', 'gutenberg' ), ); - if ( ! empty( $config['global']['settings']['typography']['fontSizes'] ) ) { foreach ( $config['global']['settings']['typography']['fontSizes'] as &$font_size ) { $font_size['name'] = $default_font_sizes_i18n[ $font_size['slug'] ]; @@ -257,7 +197,6 @@ function gutenberg_experimental_global_styles_get_core() { 'initial' => __( 'Initial', 'gutenberg' ), 'inherit' => __( 'Inherit', 'gutenberg' ), ); - if ( ! empty( $config['global']['settings']['typography']['fontStyles'] ) ) { foreach ( $config['global']['settings']['typography']['fontStyles'] as &$font_style ) { $font_style['name'] = $default_font_styles_i18n[ $font_style['slug'] ]; @@ -277,15 +216,14 @@ function gutenberg_experimental_global_styles_get_core() { 'initial' => __( 'Initial', 'gutenberg' ), 'inherit' => __( 'Inherit', 'gutenberg' ), ); - if ( ! empty( $config['global']['settings']['typography']['fontWeights'] ) ) { foreach ( $config['global']['settings']['typography']['fontWeights'] as &$font_weight ) { $font_weight['name'] = $default_font_weights_i18n[ $font_weight['slug'] ]; } } - // End i18n logic to remove when JSON i18 strings are extracted. - return $config; + + return new WP_Theme_JSON( $config ); } /** @@ -305,30 +243,35 @@ function gutenberg_experimental_global_styles_get_theme_support_settings() { } $theme_settings['global']['settings']['color']['custom'] = false; } + if ( get_theme_support( 'disable-custom-gradients' ) ) { if ( ! isset( $theme_settings['global']['settings']['color'] ) ) { $theme_settings['global']['settings']['color'] = array(); } $theme_settings['global']['settings']['color']['customGradient'] = false; } + if ( get_theme_support( 'disable-custom-font-sizes' ) ) { if ( ! isset( $theme_settings['global']['settings']['typography'] ) ) { $theme_settings['global']['settings']['typography'] = array(); } $theme_settings['global']['settings']['typography']['customFontSize'] = false; } + if ( get_theme_support( 'custom-line-height' ) ) { if ( ! isset( $theme_settings['global']['settings']['typography'] ) ) { $theme_settings['global']['settings']['typography'] = array(); } $theme_settings['global']['settings']['typography']['customLineHeight'] = true; } + if ( get_theme_support( 'custom-spacing' ) ) { if ( ! isset( $theme_settings['global']['settings']['spacing'] ) ) { $theme_settings['global']['settings']['spacing'] = array(); } $theme_settings['global']['settings']['spacing']['custom'] = true; } + if ( get_theme_support( 'experimental-link-color' ) ) { if ( ! isset( $theme_settings['global']['settings']['color'] ) ) { $theme_settings['global']['settings']['color'] = array(); @@ -386,389 +329,34 @@ function gutenberg_experimental_global_styles_get_theme_support_settings() { * It also fetches the existing presets the theme declared via add_theme_support * and uses them if the theme hasn't declared any via theme.json. * - * @return array Config that adheres to the theme.json schema. + * @return WP_Theme_JSON Entity that holds theme data. */ function gutenberg_experimental_global_styles_get_theme() { - $theme_support_settings = gutenberg_experimental_global_styles_get_theme_support_settings(); - $theme_config = gutenberg_experimental_global_styles_get_from_file( + $theme_support_data = gutenberg_experimental_global_styles_get_theme_support_settings(); + $theme_json_data = gutenberg_experimental_global_styles_get_from_file( locate_template( 'experimental-theme.json' ) ); /* * We want the presets declared in theme.json - * to take precedence over the ones declared via add_theme_support. - * - * Note that merging happens at the preset category level. Example: - * - * - if the theme declares a color palette via add_theme_support & - * a set of font sizes via theme.json, both will be included in the output. - * - * - if the theme declares a color palette both via add_theme_support & - * via theme.json, the later takes precedence. - * + * to override the ones declared via add_theme_support. */ - $theme_config = gutenberg_experimental_global_styles_merge_trees( - $theme_support_settings, - $theme_config - ); - - return $theme_config; -} - -/** - * Convert style property to its CSS name. - * - * @param string $style_property Style property name. - * @return string CSS property name. - */ -function gutenberg_experimental_global_styles_get_css_property( $style_property ) { - switch ( $style_property ) { - case 'backgroundColor': - return 'background-color'; - case 'fontSize': - return 'font-size'; - case 'fontStyle': - return 'font-style'; - case 'fontWeight': - return 'font-weight'; - case 'lineHeight': - return 'line-height'; - case 'fontFamily': - return 'font-family'; - case 'textDecoration': - return 'text-decoration'; - case 'textTransform': - return 'text-transform'; - default: - return $style_property; - } -} + $result = new WP_Theme_JSON( $theme_support_data ); + $all = new WP_Theme_JSON( $theme_json_data ); + $result->merge( $all ); -/** - * Return how the style property is structured. - * - * @return array Style property structure. - */ -function gutenberg_experimental_global_styles_get_style_property() { - return array( - '--wp--style--color--link' => array( 'color', 'link' ), - 'background' => array( 'color', 'gradient' ), - 'backgroundColor' => array( 'color', 'background' ), - 'color' => array( 'color', 'text' ), - 'fontSize' => array( 'typography', 'fontSize' ), - 'fontFamily' => array( 'typography', 'fontFamily' ), - 'fontStyle' => array( 'typography', 'fontStyle' ), - 'fontWeight' => array( 'typography', 'fontWeight' ), - 'lineHeight' => array( 'typography', 'lineHeight' ), - 'textDecoration' => array( 'typography', 'textDecoration' ), - 'textTransform' => array( 'typography', 'textTransform' ), - ); -} - -/** - * Return how the support keys are structured. - * - * @return array Support keys structure. - */ -function gutenberg_experimental_global_styles_get_support_keys() { - return array( - '--wp--style--color--link' => array( 'color', 'linkColor' ), - 'background' => array( 'color', 'gradients' ), - 'backgroundColor' => array( 'color' ), - 'color' => array( 'color' ), - 'fontSize' => array( 'fontSize' ), - 'fontStyle' => array( '__experimentalFontAppearance' ), - 'fontWeight' => array( '__experimentalFontAppearance' ), - 'lineHeight' => array( 'lineHeight' ), - 'fontFamily' => array( '__experimentalFontFamily' ), - 'textDecoration' => array( '__experimentalTextDecoration' ), - 'textTransform' => array( '__experimentalTextTransform' ), - ); -} - -/** - * Returns how the presets css variables are structured on the global styles data. - * - * @return array Presets structure - */ -function gutenberg_experimental_global_styles_get_presets_structure() { - return array( - 'color' => array( - 'path' => array( 'color', 'palette' ), - 'key' => 'color', - ), - 'gradient' => array( - 'path' => array( 'color', 'gradients' ), - 'key' => 'gradient', - ), - 'fontSize' => array( - 'path' => array( 'typography', 'fontSizes' ), - 'key' => 'size', - ), - 'fontFamily' => array( - 'path' => array( 'typography', 'fontFamilies' ), - 'key' => 'fontFamily', - ), - 'fontStyle' => array( - 'path' => array( 'typography', 'fontStyles' ), - 'key' => 'slug', - ), - 'fontWeight' => array( - 'path' => array( 'typography', 'fontWeights' ), - 'key' => 'slug', - ), - 'textDecoration' => array( - 'path' => array( 'typography', 'textDecorations' ), - 'key' => 'value', - ), - 'textTransform' => array( - 'path' => array( 'typography', 'textTransforms' ), - 'key' => 'slug', - ), - ); -} - -/** - * Returns the style features a particular block supports. - * - * @param array $supports The block supports array. - * - * @return array Style features supported by the block. - */ -function gutenberg_experimental_global_styles_get_supported_styles( $supports ) { - $support_keys = gutenberg_experimental_global_styles_get_support_keys(); - $supported_features = array(); - foreach ( $support_keys as $key => $path ) { - if ( gutenberg_experimental_get( $supports, $path ) ) { - $supported_features[] = $key; - } - } - - return $supported_features; -} - -/** - * Retrieves the block data (selector/supports). - * - * @return array - */ -function gutenberg_experimental_global_styles_get_block_data() { - $block_data = array(); - - $registry = WP_Block_Type_Registry::get_instance(); - $blocks = array_merge( - $registry->get_all_registered(), - array( - 'global' => new WP_Block_Type( - 'global', - array( - 'supports' => array( - '__experimentalFontAppearance' => false, - '__experimentalFontFamily' => true, - '__experimentalSelector' => ':root', - '__experimentalTextDecoration' => true, - '__experimentalTextTransform' => true, - 'color' => array( - 'gradients' => true, - 'linkColor' => true, - ), - 'fontSize' => true, - 'lineHeight' => true, - ), - ) - ), - ) - ); - foreach ( $blocks as $block_name => $block_type ) { - if ( ! property_exists( $block_type, 'supports' ) || empty( $block_type->supports ) || ! is_array( $block_type->supports ) ) { - continue; - } - - $supports = gutenberg_experimental_global_styles_get_supported_styles( $block_type->supports ); - - /* - * Assign the selector for the block. - * - * Some blocks can declare multiple selectors: - * - * - core/heading represents the H1-H6 HTML elements - * - core/list represents the UL and OL HTML elements - * - core/group is meant to represent DIV and other HTML elements - * - * Some other blocks don't provide a selector, - * so we generate a class for them based on their name: - * - * - 'core/group' => '.wp-block-group' - * - 'my-custom-library/block-name' => '.wp-block-my-custom-library-block-name' - * - * Note that, for core blocks, we don't add the `core/` prefix to its class name. - * This is for historical reasons, as they come with a class without that infix. - * - */ - if ( - isset( $block_type->supports['__experimentalSelector'] ) && - is_string( $block_type->supports['__experimentalSelector'] ) - ) { - $block_data[ $block_name ] = array( - 'selector' => $block_type->supports['__experimentalSelector'], - 'supports' => $supports, - 'blockName' => $block_name, - ); - } elseif ( - isset( $block_type->supports['__experimentalSelector'] ) && - is_array( $block_type->supports['__experimentalSelector'] ) - ) { - foreach ( $block_type->supports['__experimentalSelector'] as $key => $selector ) { - $block_data[ $key ] = array( - 'selector' => $selector, - 'supports' => $supports, - 'blockName' => $block_name, - ); - } - } else { - $block_data[ $block_name ] = array( - 'selector' => '.wp-block-' . str_replace( '/', '-', str_replace( 'core/', '', $block_name ) ), - 'supports' => $supports, - 'blockName' => $block_name, - ); - } - } - - return $block_data; -} - -/** - * Given an array contain the styles shape returns the css for this styles. - * A similar function exists on the client at /packages/block-editor/src/hooks/style.js. - * - * @param array $styles Array containing the styles shape from global styles. - * - * @return array Containing a set of css rules. - */ -function gutenberg_experimental_global_styles_flatten_styles_tree( $styles ) { - $mappings = gutenberg_experimental_global_styles_get_style_property(); - - $result = array(); - foreach ( $mappings as $key => $path ) { - $value = gutenberg_experimental_get( $styles, $path, null ); - if ( null !== $value ) { - $variable_reference_prefix = 'var:'; - $variable_path_separator_token_attribute = '|'; - $variable_path_separator_token_style = '--'; - $variable_reference_prefix_length = strlen( $variable_reference_prefix ); - if ( strncmp( $value, $variable_reference_prefix, $variable_reference_prefix_length ) === 0 ) { - $variable = str_replace( $variable_path_separator_token_attribute, $variable_path_separator_token_style, substr( $value, $variable_reference_prefix_length ) ); - $result[ $key ] = "var(--wp--$variable)"; - } else { - $result[ $key ] = $value; - } - } - } return $result; - -} - -/** - * Given a selector for a block, and the settings of the block, returns a string - * with the stylesheet of the preset classes required for that block. - * - * @param string $selector String with the CSS selector for the block. - * @param array $settings Array containing the settings of the block. - * - * @return string Stylesheet with the preset classes. - */ -function gutenberg_experimental_global_styles_get_preset_classes( $selector, $settings ) { - if ( empty( $settings ) || empty( $selector ) ) { - return ''; - } - - $stylesheet = ''; - $class_prefix = 'has'; - $classes_structure = array( - 'color' => array( - 'path' => array( 'color', 'palette' ), - 'key' => 'color', - 'property' => 'color', - ), - 'background-color' => array( - 'path' => array( 'color', 'palette' ), - 'key' => 'color', - 'property' => 'background-color', - ), - 'gradient-background' => array( - 'path' => array( 'color', 'gradients' ), - 'key' => 'gradient', - 'property' => 'background', - ), - 'font-size' => array( - 'path' => array( 'typography', 'fontSizes' ), - 'key' => 'size', - 'property' => 'font-size', - ), - 'font-style' => array( - 'path' => array( 'typography', 'fontStyles' ), - 'key' => 'slug', - 'property' => 'font-style', - ), - 'font-weight' => array( - 'path' => array( 'typography', 'fontWeights' ), - 'key' => 'slug', - 'property' => 'font-weight', - ), - 'text-decoration' => array( - 'path' => array( 'typography', 'textDecorations' ), - 'key' => 'value', - 'property' => 'text-decoration', - ), - 'text-transform' => array( - 'path' => array( 'typography', 'textTransforms' ), - 'key' => 'slug', - 'property' => 'text-transform', - ), - ); - - foreach ( $classes_structure as $class_suffix => $preset_structure ) { - $path = $preset_structure['path']; - $presets = gutenberg_experimental_get( $settings, $path ); - - if ( empty( $presets ) ) { - continue; - } - - $key = $preset_structure['key']; - $property = $preset_structure['property']; - - foreach ( $presets as $preset ) { - $slug = $preset['slug']; - $value = $preset[ $key ]; - - $class_to_use = ".$class_prefix-$slug-$class_suffix"; - $selector_to_use = ''; - if ( ':root' === $selector ) { - $selector_to_use = $class_to_use; - } else { - $selector_to_use = "$selector$class_to_use"; - } - if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { - $stylesheet .= "$selector_to_use {\n\t$property: $value;\n}\n"; - } else { - $stylesheet .= $selector_to_use . '{' . "$property:$value;}\n"; - } - } - } - return $stylesheet; } /** * Takes a tree adhering to the theme.json schema and generates * the corresponding stylesheet. * - * @param array $tree Input tree. + * @param WP_Theme_JSON $tree Input tree. * * @return string Stylesheet. */ function gutenberg_experimental_global_styles_get_stylesheet( $tree ) { - // Check if we can use cached. $can_use_cached = ( ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) && @@ -778,7 +366,6 @@ function gutenberg_experimental_global_styles_get_stylesheet( $tree ) { ); if ( $can_use_cached ) { - // Check if we have the styles already cached. $cached = get_transient( 'global_styles' ); if ( $cached ) { @@ -786,56 +373,7 @@ function gutenberg_experimental_global_styles_get_stylesheet( $tree ) { } } - $stylesheet = ''; - $block_data = gutenberg_experimental_global_styles_get_block_data(); - foreach ( array_keys( $tree ) as $block_name ) { - if ( - ! array_key_exists( $block_name, $block_data ) || - ! array_key_exists( 'selector', $block_data[ $block_name ] ) || - ! array_key_exists( 'supports', $block_data[ $block_name ] ) - ) { - // Skip blocks that haven't declared support, - // because we don't know to process them. - continue; - } - - // Create the CSS Custom Properties for the presets. - $computed_presets = array(); - $presets_structure = gutenberg_experimental_global_styles_get_presets_structure(); - foreach ( $presets_structure as $token => $preset_meta ) { - $block_preset = gutenberg_experimental_get( $tree[ $block_name ]['settings'], $preset_meta['path'] ); - if ( ! empty( $block_preset ) ) { - $computed_presets[ $token ] = array(); - foreach ( $block_preset as $preset_value ) { - $computed_presets[ $token ][ $preset_value['slug'] ] = $preset_value[ $preset_meta['key'] ]; - } - } - } - $token = '--'; - $preset_prefix = '--wp--preset' . $token; - $preset_variables = gutenberg_experimental_global_styles_get_css_vars( $computed_presets, $preset_prefix, $token ); - - // Create the CSS Custom Properties that are specific to the theme. - $computed_theme_props = gutenberg_experimental_get( $tree[ $block_name ]['settings'], array( 'custom' ) ); - $theme_props_prefix = '--wp--custom' . $token; - $theme_variables = gutenberg_experimental_global_styles_get_css_vars( - $computed_theme_props, - $theme_props_prefix, - $token - ); - - $stylesheet .= gutenberg_experimental_global_styles_resolver_styles( - $block_data[ $block_name ]['selector'], - $block_data[ $block_name ]['supports'], - array_merge( - gutenberg_experimental_global_styles_flatten_styles_tree( $tree[ $block_name ]['styles'] ), - $preset_variables, - $theme_variables - ) - ); - - $stylesheet .= gutenberg_experimental_global_styles_get_preset_classes( $block_data[ $block_name ]['selector'], $tree[ $block_name ]['settings'] ); - } + $stylesheet = $tree->get_stylesheet(); if ( gutenberg_experimental_global_styles_has_theme_json_support() ) { // To support all themes, we added in the block-library stylesheet @@ -847,7 +385,6 @@ function gutenberg_experimental_global_styles_get_stylesheet( $tree ) { } if ( $can_use_cached ) { - // Cache for a minute. // This cache doesn't need to be any longer, we only want to avoid spikes on high-trafic sites. set_transient( 'global_styles', $stylesheet, MINUTE_IN_SECONDS ); @@ -856,146 +393,16 @@ function gutenberg_experimental_global_styles_get_stylesheet( $tree ) { return $stylesheet; } -/** - * Generates CSS declarations for a block. - * - * @param string $block_selector CSS selector for the block. - * @param array $block_supports A list of properties supported by the block. - * @param array $block_styles The list of properties/values to be converted to CSS. - * - * @return string The corresponding CSS rule. - */ -function gutenberg_experimental_global_styles_resolver_styles( $block_selector, $block_supports, $block_styles ) { - $css_property = ''; - $css_rule = ''; - $css_declarations = ''; - - foreach ( $block_styles as $property => $value ) { - // Only convert to CSS: - // - // 1) The style attributes the block has declared support for. - // 2) Any CSS custom property attached to the node. - if ( - in_array( $property, $block_supports, true ) || - strstr( $property, '--' ) - ) { - $css_property = gutenberg_experimental_global_styles_get_css_property( $property ); - - // Add whitespace if SCRIPT_DEBUG is defined and set to true. - if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { - $css_declarations .= "\t" . $css_property . ': ' . $value . ";\n"; - } else { - $css_declarations .= $css_property . ':' . $value . ';'; - } - } - } - - if ( '' !== $css_declarations ) { - - // Add whitespace if SCRIPT_DEBUG is defined and set to true. - if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { - $css_rule .= $block_selector . " {\n"; - $css_rule .= $css_declarations; - $css_rule .= "}\n"; - } else { - $css_rule .= $block_selector . '{' . $css_declarations . '}'; - } - } - - return $css_rule; -} - -/** - * Helper function that merges trees that adhere to the theme.json schema. - * - * @param array $core Core origin. - * @param array $theme Theme origin. - * @param array $user User origin. An empty array by default. - * - * @return array The merged result. - */ -function gutenberg_experimental_global_styles_merge_trees( $core, $theme, $user = array() ) { - $core = gutenberg_experimental_global_styles_normalize_schema( $core ); - $theme = gutenberg_experimental_global_styles_normalize_schema( $theme ); - $user = gutenberg_experimental_global_styles_normalize_schema( $user ); - $result = gutenberg_experimental_global_styles_normalize_schema( array() ); - - foreach ( array_keys( $core ) as $block_name ) { - foreach ( array_keys( $core[ $block_name ]['settings'] ) as $subtree ) { - $result[ $block_name ]['settings'][ $subtree ] = array_merge( - $core[ $block_name ]['settings'][ $subtree ], - $theme[ $block_name ]['settings'][ $subtree ], - $user[ $block_name ]['settings'][ $subtree ] - ); - } - foreach ( array_keys( $core[ $block_name ]['styles'] ) as $subtree ) { - $result[ $block_name ]['styles'][ $subtree ] = array_merge( - $core[ $block_name ]['styles'][ $subtree ], - $theme[ $block_name ]['styles'][ $subtree ], - $user[ $block_name ]['styles'][ $subtree ] - ); - } - } - - return $result; -} - -/** - * Given a tree, it normalizes it to the expected schema. - * - * @param array $tree Source tree to normalize. - * - * @return array Normalized tree. - */ -function gutenberg_experimental_global_styles_normalize_schema( $tree ) { - $block_schema = array( - 'styles' => array( - 'typography' => array(), - 'color' => array(), - ), - 'settings' => array( - 'color' => array(), - 'custom' => array(), - 'typography' => array(), - 'spacing' => array(), - ), - ); - - $normalized_tree = array(); - $block_data = gutenberg_experimental_global_styles_get_block_data(); - foreach ( array_keys( $block_data ) as $block_name ) { - $normalized_tree[ $block_name ] = $block_schema; - } - - $tree = array_merge_recursive( - $normalized_tree, - $tree - ); - - return $tree; -} - -/** - * Takes data from the different origins (core, theme, and user) - * and returns the merged result. - * - * @return array Merged trees - */ -function gutenberg_experimental_global_styles_get_merged_origins() { - $core = gutenberg_experimental_global_styles_get_core(); - $theme = gutenberg_experimental_global_styles_get_theme(); - $user = gutenberg_experimental_global_styles_get_user(); - - return gutenberg_experimental_global_styles_merge_trees( $core, $theme, $user ); -} - /** * Fetches the preferences for each origin (core, theme, user) * and enqueues the resulting stylesheet. */ function gutenberg_experimental_global_styles_enqueue_assets() { - $merged = gutenberg_experimental_global_styles_get_merged_origins(); - $stylesheet = gutenberg_experimental_global_styles_get_stylesheet( $merged ); + $all = gutenberg_experimental_global_styles_get_core(); + $all->merge( gutenberg_experimental_global_styles_get_theme() ); + $all->merge( gutenberg_experimental_global_styles_get_user() ); + + $stylesheet = gutenberg_experimental_global_styles_get_stylesheet( $all ); if ( empty( $stylesheet ) ) { return; } @@ -1005,28 +412,6 @@ function gutenberg_experimental_global_styles_enqueue_assets() { wp_enqueue_style( 'global-styles' ); } -/** - * Returns the default config for editor features, - * or an empty array if none found. - * - * @param array $config Config to extract values from. - * @return array Default features config for the editor. - */ -function gutenberg_experimental_global_styles_get_editor_settings( $config ) { - $settings = array(); - foreach ( array_keys( $config ) as $context ) { - if ( - empty( $config[ $context ]['settings'] ) || - ! is_array( $config[ $context ]['settings'] ) - ) { - $settings[ $context ] = array(); - } else { - $settings[ $context ] = $config[ $context ]['settings']; - } - } - return $settings; -} - /** * Adds the necessary data for the Global Styles client UI to the block settings. * @@ -1034,12 +419,17 @@ function gutenberg_experimental_global_styles_get_editor_settings( $config ) { * @return array New block editor settings */ function gutenberg_experimental_global_styles_settings( $settings ) { - $merged = gutenberg_experimental_global_styles_get_merged_origins(); + $base = gutenberg_experimental_global_styles_get_core(); + $base->merge( gutenberg_experimental_global_styles_get_theme() ); + + $all = gutenberg_experimental_global_styles_get_core(); + $all->merge( gutenberg_experimental_global_styles_get_theme() ); + $all->merge( gutenberg_experimental_global_styles_get_user() ); // STEP 1: ADD FEATURES // These need to be added to settings always. // We also need to unset the deprecated settings defined by core. - $settings['__experimentalFeatures'] = gutenberg_experimental_global_styles_get_editor_settings( $merged ); + $settings['__experimentalFeatures'] = $all->get_settings(); unset( $settings['colors'] ); unset( $settings['gradients'] ); @@ -1062,11 +452,8 @@ function_exists( 'gutenberg_is_edit_site_page' ) && gutenberg_experimental_global_styles_has_theme_json_support() ) { $settings['__experimentalGlobalStylesUserEntityId'] = gutenberg_experimental_global_styles_get_user_cpt_id(); - $settings['__experimentalGlobalStylesContexts'] = gutenberg_experimental_global_styles_get_block_data(); - $settings['__experimentalGlobalStylesBaseStyles'] = gutenberg_experimental_global_styles_merge_trees( - gutenberg_experimental_global_styles_get_core(), - gutenberg_experimental_global_styles_get_theme() - ); + $settings['__experimentalGlobalStylesContexts'] = $base->get_blocks_metadata(); + $settings['__experimentalGlobalStylesBaseStyles'] = $base->get_raw_data(); } else { // STEP 3 - OTHERWISE, ADD STYLES // @@ -1074,7 +461,7 @@ function_exists( 'gutenberg_is_edit_site_page' ) && // we need to add the styles via the settings. This is because // we want them processed as if they were added via add_editor_styles, // which adds the editor wrapper class. - $settings['styles'][] = array( 'css' => gutenberg_experimental_global_styles_get_stylesheet( $merged ) ); + $settings['styles'][] = array( 'css' => gutenberg_experimental_global_styles_get_stylesheet( $all ) ); } return $settings; diff --git a/lib/load.php b/lib/load.php index 3814e3cfcafd9..5c523aaf8f219 100644 --- a/lib/load.php +++ b/lib/load.php @@ -120,6 +120,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/navigation.php'; require __DIR__ . '/navigation-page.php'; require __DIR__ . '/experiments-page.php'; +require __DIR__ . '/class-wp-theme-json.php'; require __DIR__ . '/global-styles.php'; if ( ! class_exists( 'WP_Block_Supports' ) ) { diff --git a/packages/edit-site/src/components/editor/global-styles-provider.js b/packages/edit-site/src/components/editor/global-styles-provider.js index 12f3199102ac7..acddf47ea5493 100644 --- a/packages/edit-site/src/components/editor/global-styles-provider.js +++ b/packages/edit-site/src/components/editor/global-styles-provider.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { set, get, mapValues } from 'lodash'; +import { set, get, mapValues, mergeWith } from 'lodash'; /** * WordPress dependencies @@ -20,10 +20,7 @@ import { useSelect, useDispatch } from '@wordpress/data'; /** * Internal dependencies */ -import { - default as getGlobalStyles, - mergeTrees, -} from './global-styles-renderer'; +import { default as getGlobalStyles } from './global-styles-renderer'; const EMPTY_CONTENT = '{}'; @@ -33,10 +30,19 @@ const GlobalStylesContext = createContext( { setSetting: ( context, path, newValue ) => {}, getStyleProperty: ( context, propertyName, origin ) => {}, setStyleProperty: ( context, propertyName, newValue ) => {}, - globalContext: {}, + contexts: {}, /* eslint-enable no-unused-vars */ } ); +const mergeTreesCustomizer = ( objValue, srcValue ) => { + // We only pass as arrays the presets, + // in which case we want the new array of values + // to override the old array (no merging). + if ( Array.isArray( srcValue ) ) { + return srcValue; + } +}; + export const useGlobalStylesContext = () => useContext( GlobalStylesContext ); const useGlobalStylesEntityContent = () => { @@ -60,10 +66,17 @@ export default function GlobalStylesProvider( { const [ content, setContent ] = useGlobalStylesEntityContent(); const { userStyles, mergedStyles } = useMemo( () => { - const parsedContent = content ? JSON.parse( content ) : {}; + const newUserStyles = content ? JSON.parse( content ) : {}; + const newMergedStyles = mergeWith( + {}, + baseStyles, + newUserStyles, + mergeTreesCustomizer + ); + return { - userStyles: parsedContent, - mergedStyles: mergeTrees( baseStyles, parsedContent ), + userStyles: newUserStyles, + mergedStyles: newMergedStyles, }; }, [ content ] ); @@ -137,7 +150,7 @@ export default function GlobalStylesProvider( { ...settings, __experimentalFeatures: mapValues( mergedStyles, - ( value ) => value.settings || {} + ( value ) => value?.settings || {} ), } ); }, [ mergedStyles ] ); diff --git a/packages/edit-site/src/components/editor/global-styles-renderer.js b/packages/edit-site/src/components/editor/global-styles-renderer.js index a61d6839c92c2..57358eb89706a 100644 --- a/packages/edit-site/src/components/editor/global-styles-renderer.js +++ b/packages/edit-site/src/components/editor/global-styles-renderer.js @@ -17,44 +17,6 @@ import { LINK_COLOR_DECLARATION, } from './utils'; -export const mergeTrees = ( baseData, userData ) => { - // Deep clone from base data. - // - // We don't use cloneDeep from lodash here - // because we know the data is JSON compatible, - // see https://github.com/lodash/lodash/issues/1984 - const mergedTree = baseData ? JSON.parse( JSON.stringify( baseData ) ) : {}; - - const styleKeys = [ 'typography', 'color' ]; - const settingKeys = [ 'typography', 'color', 'custom', 'spacing' ]; - Object.keys( userData ).forEach( ( context ) => { - styleKeys.forEach( ( key ) => { - // Normalize object shape. - if ( ! mergedTree[ context ].styles?.[ key ] ) { - mergedTree[ context ].styles[ key ] = {}; - } - // Merge base + user data. - mergedTree[ context ].styles[ key ] = { - ...mergedTree[ context ].styles[ key ], - ...userData[ context ]?.styles?.[ key ], - }; - } ); - settingKeys.forEach( ( key ) => { - // Normalize object shape. - if ( ! mergedTree[ context ].settings?.[ key ] ) { - mergedTree[ context ].settings[ key ] = {}; - } - // Merge base + user data. - mergedTree[ context ].settings[ key ] = { - ...mergedTree[ context ].settings[ key ], - ...userData[ context ]?.settings?.[ key ], - }; - } ); - } ); - - return mergedTree; -}; - function compileStyleValue( uncompiledValue ) { const VARIABLE_REFERENCE_PREFIX = 'var:'; const VARIABLE_PATH_SEPARATOR_TOKEN_ATTRIBUTE = '|'; @@ -83,7 +45,7 @@ export default ( blockData, tree ) => { * * @return {Array} An array of style declarations. */ - const getBlockStylesDeclarations = ( blockSupports, blockStyles ) => { + const getBlockStylesDeclarations = ( blockSupports, blockStyles = {} ) => { const declarations = []; Object.keys( STYLE_PROPERTY ).forEach( ( key ) => { const cssProperty = key.startsWith( '--' ) ? key : kebabCase( key ); @@ -109,7 +71,7 @@ export default ( blockData, tree ) => { * @param {Object} blockPresets * @return {string} CSS declarations for the preset classes. */ - const getBlockPresetClasses = ( blockSelector, blockPresets ) => { + const getBlockPresetClasses = ( blockSelector, blockPresets = {} ) => { return reduce( PRESET_CLASSES, ( declarations, { path, key, property }, classSuffix ) => { @@ -119,7 +81,7 @@ export default ( blockData, tree ) => { const value = preset[ key ]; const classSelectorToUse = `.has-${ slug }-${ classSuffix }`; const selectorToUse = `${ blockSelector }${ classSelectorToUse }`; - declarations += `${ selectorToUse } {${ property }: ${ value };}\n`; + declarations += `${ selectorToUse } {${ property }: ${ value };}`; } ); return declarations; }, @@ -134,7 +96,7 @@ export default ( blockData, tree ) => { * * @return {Array} An array of style declarations. */ - const getBlockPresetsDeclarations = ( blockPresets ) => { + const getBlockPresetsDeclarations = ( blockPresets = {} ) => { return reduce( PRESET_CATEGORIES, ( declarations, { path, key }, category ) => { @@ -171,7 +133,7 @@ export default ( blockData, tree ) => { return result; }; - const getCustomDeclarations = ( blockCustom ) => { + const getCustomDeclarations = ( blockCustom = {} ) => { if ( Object.keys( blockCustom ).length === 0 ) { return []; } @@ -191,22 +153,28 @@ export default ( blockData, tree ) => { Object.keys( blockData ).forEach( ( context ) => { const blockSelector = getBlockSelector( blockData[ context ].selector ); + const blockDeclarations = [ ...getBlockStylesDeclarations( blockData[ context ].supports, - tree[ context ].styles + tree?.[ context ]?.styles ), - ...getBlockPresetsDeclarations( tree[ context ].settings ), - ...getCustomDeclarations( tree[ context ].settings.custom ), + ...getBlockPresetsDeclarations( tree?.[ context ]?.settings ), + ...getCustomDeclarations( tree?.[ context ]?.settings?.custom ), ]; - styles.push( - getBlockPresetClasses( blockSelector, tree[ context ].settings ) - ); if ( blockDeclarations.length > 0 ) { styles.push( `${ blockSelector } { ${ blockDeclarations.join( ';' ) } }` ); } + + const presetClasses = getBlockPresetClasses( + blockSelector, + tree?.[ context ]?.settings + ); + if ( presetClasses ) { + styles.push( presetClasses ); + } } ); return styles.join( '' ); diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php new file mode 100644 index 0000000000000..74bc99def4380 --- /dev/null +++ b/phpunit/class-wp-theme-json-test.php @@ -0,0 +1,515 @@ + array( + 'settings' => array( + 'color' => array( + 'custom' => 'false', + ), + ), + ), + 'core/invalid' => array( + 'settings' => array( + 'color' => array( + 'custom' => 'false', + ), + ), + ), + ) + ); + $result = $theme_json->get_raw_data(); + + $expected = array( + 'global' => array( + 'selector' => ':root', + 'supports' => array( + '--wp--style--color--link', + 'background', + 'backgroundColor', + 'color', + 'fontFamily', + 'fontSize', + 'lineHeight', + 'textDecoration', + 'textTransform', + ), + 'settings' => array( + 'color' => array( + 'custom' => 'false', + ), + ), + ), + ); + + $this->assertEqualSetsWithIndex( $expected, $result ); + } + + function test_properties_not_valid_are_skipped() { + $theme_json = new WP_Theme_JSON( + array( + 'global' => array( + 'invalidKey' => 'invalid value', + 'settings' => array( + 'color' => array( + 'custom' => 'false', + 'invalidKey' => 'invalid value', + ), + 'invalidSection' => array( + 'invalidKey' => 'invalid value', + ), + ), + 'styles' => array( + 'typography' => array( + 'fontSize' => '12', + 'invalidProperty' => 'invalid value', + ), + 'invalidSection' => array( + 'invalidProperty' => 'invalid value', + ), + ), + ), + ) + ); + $result = $theme_json->get_raw_data(); + + $expected = array( + 'global' => array( + 'selector' => ':root', + 'supports' => array( + '--wp--style--color--link', + 'background', + 'backgroundColor', + 'color', + 'fontFamily', + 'fontSize', + 'lineHeight', + 'textDecoration', + 'textTransform', + ), + 'settings' => array( + 'color' => array( + 'custom' => 'false', + ), + ), + 'styles' => array( + 'typography' => array( + 'fontSize' => '12', + ), + ), + ), + ); + + $this->assertEqualSetsWithIndex( $expected, $result ); + } + + function test_metadata_is_attached() { + $theme_json = new WP_Theme_JSON( array( 'global' => array() ) ); + $result = $theme_json->get_raw_data(); + + $expected = array( + 'global' => array( + 'selector' => ':root', + 'supports' => array( + '--wp--style--color--link', + 'background', + 'backgroundColor', + 'color', + 'fontFamily', + 'fontSize', + 'lineHeight', + 'textDecoration', + 'textTransform', + ), + ), + ); + + $this->assertEqualSetsWithIndex( $expected, $result ); + } + + function test_get_settings() { + // See schema at WP_Theme_JSON::SCHEMA. + $theme_json = new WP_Theme_JSON( + array( + 'global' => array( + 'settings' => array( + 'color' => array( + 'link' => 'value', + ), + 'custom' => 'value', + 'typography' => 'value', + 'misc' => 'value', + ), + 'styles' => array( + 'color' => 'value', + 'misc' => 'value', + ), + 'misc' => 'value', + ), + ) + ); + + $result = $theme_json->get_settings(); + + $this->assertArrayHasKey( 'global', $result ); + $this->assertCount( 1, $result ); + + $this->assertArrayHasKey( 'color', $result['global'] ); + $this->assertArrayHasKey( 'custom', $result['global'] ); + $this->assertCount( 2, $result['global'] ); + } + + function test_get_stylesheet() { + // See schema at WP_Theme_JSON::SCHEMA. + $theme_json = new WP_Theme_JSON( + array( + 'global' => array( + 'settings' => array( + 'color' => array( + 'text' => 'value', + 'palette' => array( + array( + 'slug' => 'grey', + 'color' => 'grey', + ), + ), + ), + 'typography' => array( + 'fontFamilies' => array( + array( + 'slug' => 'small', + 'fontFamily' => '14px', + ), + array( + 'slug' => 'big', + 'fontFamily' => '41px', + ), + ), + ), + 'misc' => 'value', + ), + 'styles' => array( + 'color' => array( + 'link' => '#111', + 'text' => 'var:preset|color|grey', + ), + 'misc' => 'value', + ), + 'misc' => 'value', + ), + ) + ); + + $result = $theme_json->get_stylesheet(); + $stylesheet = ':root{--wp--style--color--link: #111;color: var(--wp--preset--color--grey);--wp--preset--color--grey: grey;--wp--preset--font-family--small: 14px;--wp--preset--font-family--big: 41px;}.has-grey-color{color: grey;}.has-grey-background-color{background-color: grey;}'; + + $this->assertEquals( $stylesheet, $result ); + } + + public function test_merge_incoming_data() { + $initial = array( + 'global' => array( + 'settings' => array( + 'color' => array( + 'custom' => 'false', + 'palette' => array( + array( + 'slug' => 'red', + 'color' => 'red', + ), + array( + 'slug' => 'blue', + 'color' => 'blue', + ), + ), + ), + ), + 'styles' => array( + 'typography' => array( + 'fontSize' => '12', + ), + ), + ), + 'core/paragraph' => array( + 'settings' => array( + 'color' => array( + 'custom' => 'false', + ), + ), + ), + ); + + $add_new_context = array( + 'core/list' => array( + 'settings' => array( + 'color' => array( + 'custom' => 'false', + ), + ), + 'styles' => array( + 'typography' => array( + 'fontSize' => '12', + ), + 'color' => array( + 'link' => 'pink', + 'background' => 'brown', + ), + ), + ), + ); + + $add_key_in_settings = array( + 'global' => array( + 'settings' => array( + 'color' => array( + 'customGradient' => 'true', + ), + ), + ), + ); + + $update_key_in_settings = array( + 'global' => array( + 'settings' => array( + 'color' => array( + 'custom' => 'true', + ), + ), + ), + ); + + $add_styles = array( + 'core/paragraph' => array( + 'styles' => array( + 'typography' => array( + 'fontSize' => '12', + ), + 'color' => array( + 'link' => 'pink', + ), + ), + ), + ); + + $add_key_in_styles = array( + 'core/paragraph' => array( + 'styles' => array( + 'typography' => array( + 'lineHeight' => '12', + ), + ), + ), + ); + + $add_invalid_context = array( + 'core/para' => array( + 'styles' => array( + 'typography' => array( + 'lineHeight' => '12', + ), + ), + ), + ); + + $update_presets = array( + 'global' => array( + 'settings' => array( + 'color' => array( + 'palette' => array( + array( + 'slug' => 'color', + 'color' => 'color', + ), + ), + 'gradients' => array( + array( + 'slug' => 'gradient', + 'gradient' => 'gradient', + ), + ), + ), + 'typography' => array( + 'fontSizes' => array( + array( + 'slug' => 'fontSize', + 'size' => 'fontSize', + ), + ), + 'fontFamilies' => array( + array( + 'slug' => 'fontFamily', + 'fontFamily' => 'fontFamily', + ), + ), + 'fontStyles' => array( + array( + 'slug' => 'fontStyle', + ), + ), + 'fontWeights' => array( + array( + 'slug' => 'fontWeight', + ), + ), + 'textDecorations' => array( + array( + 'slug' => 'textDecoration', + 'value' => 'textDecoration', + ), + ), + 'textTransforms' => array( + array( + 'slug' => 'textTransform', + 'value' => 'textTransform', + ), + ), + ), + ), + ), + ); + + $expected = array( + 'global' => array( + 'selector' => ':root', + 'supports' => array( + '--wp--style--color--link', + 'background', + 'backgroundColor', + 'color', + 'fontFamily', + 'fontSize', + 'lineHeight', + 'textDecoration', + 'textTransform', + ), + 'settings' => array( + 'color' => array( + 'custom' => 'true', + 'customGradient' => 'true', + 'palette' => array( + array( + 'slug' => 'color', + 'color' => 'color', + ), + ), + 'gradients' => array( + array( + 'slug' => 'gradient', + 'gradient' => 'gradient', + ), + ), + ), + 'typography' => array( + 'fontSizes' => array( + array( + 'slug' => 'fontSize', + 'size' => 'fontSize', + ), + ), + 'fontFamilies' => array( + array( + 'slug' => 'fontFamily', + 'fontFamily' => 'fontFamily', + ), + ), + 'fontStyles' => array( + array( + 'slug' => 'fontStyle', + ), + ), + 'fontWeights' => array( + array( + 'slug' => 'fontWeight', + ), + ), + 'textDecorations' => array( + array( + 'slug' => 'textDecoration', + 'value' => 'textDecoration', + ), + ), + 'textTransforms' => array( + array( + 'slug' => 'textTransform', + 'value' => 'textTransform', + ), + ), + ), + ), + 'styles' => array( + 'typography' => array( + 'fontSize' => '12', + ), + ), + ), + 'core/paragraph' => array( + 'selector' => 'p', + 'supports' => array( + '--wp--style--color--link', + 'backgroundColor', + 'color', + 'fontSize', + 'lineHeight', + ), + 'settings' => array( + 'color' => array( + 'custom' => 'false', + ), + ), + 'styles' => array( + 'typography' => array( + 'fontSize' => '12', + 'lineHeight' => '12', + ), + 'color' => array( + 'link' => 'pink', + ), + ), + ), + 'core/list' => array( + 'selector' => '.wp-block-list', + 'supports' => array( + 'background', + 'backgroundColor', + 'color', + 'fontSize', + ), + 'settings' => array( + 'color' => array( + 'custom' => 'false', + ), + ), + 'styles' => array( + 'typography' => array( + 'fontSize' => '12', + ), + 'color' => array( + 'link' => 'pink', + 'background' => 'brown', + ), + ), + ), + ); + + $theme_json = new WP_Theme_JSON( $initial ); + $theme_json->merge( new WP_Theme_JSON( $add_new_context ) ); + $theme_json->merge( new WP_Theme_JSON( $add_key_in_settings ) ); + $theme_json->merge( new WP_Theme_JSON( $update_key_in_settings ) ); + $theme_json->merge( new WP_Theme_JSON( $add_styles ) ); + $theme_json->merge( new WP_Theme_JSON( $add_key_in_styles ) ); + $theme_json->merge( new WP_Theme_JSON( $add_invalid_context ) ); + $theme_json->merge( new WP_Theme_JSON( $update_presets ) ); + $result = $theme_json->get_raw_data(); + + $this->assertEqualSetsWithIndex( $expected, $result ); + } +}