diff --git a/lib/block-supports/elements.php b/lib/block-supports/elements.php index 9230fd48b1c102..6f8b8816cf51a9 100644 --- a/lib/block-supports/elements.php +++ b/lib/block-supports/elements.php @@ -105,17 +105,13 @@ function gutenberg_render_elements_support_styles( $pre_render, $block ) { $link_block_styles = isset( $element_block_styles['link'] ) ? $element_block_styles['link'] : null; if ( $link_block_styles ) { - $styles = gutenberg_style_engine_generate( + gutenberg_style_engine_enqueue_styles( $link_block_styles, array( 'selector' => ".$class_name a", - 'css_vars' => true, + 'layer' => 'block-supports', ) ); - - if ( ! empty( $styles['css'] ) ) { - gutenberg_enqueue_block_support_styles( $styles['css'] ); - } } return null; diff --git a/lib/load.php b/lib/load.php index f7cad4597b6928..7d4ee33f4767a7 100644 --- a/lib/load.php +++ b/lib/load.php @@ -157,6 +157,14 @@ function gutenberg_is_experiment_enabled( $name ) { require_once __DIR__ . '/../build/style-engine/class-wp-style-engine-gutenberg.php'; } +if ( file_exists( __DIR__ . '/../build/style-engine/class-wp-style-engine-store-gutenberg.php' ) ) { + require_once __DIR__ . '/../build/style-engine/class-wp-style-engine-store-gutenberg.php'; +} + +if ( file_exists( __DIR__ . '/../build/style-engine/class-wp-style-engine-renderer-gutenberg.php' ) ) { + require_once __DIR__ . '/../build/style-engine/class-wp-style-engine-renderer-gutenberg.php'; +} + // Block supports overrides. require __DIR__ . '/block-supports/utils.php'; require __DIR__ . '/block-supports/elements.php'; diff --git a/packages/style-engine/class-wp-style-engine-renderer.php b/packages/style-engine/class-wp-style-engine-renderer.php new file mode 100644 index 00000000000000..4cefd248753a9b --- /dev/null +++ b/packages/style-engine/class-wp-style-engine-renderer.php @@ -0,0 +1,194 @@ +enqueue_block_support_styles + */ + public static function render_registered_block_supports_styles() { + $style_engine = WP_Style_Engine::get_instance(); + $block_support_styles = $style_engine->get_registered_styles(); + + if ( empty( $block_support_styles ) ) { + return; + } + + $output = ''; + + foreach ( $block_support_styles as $selector => $css_definitions ) { + $output .= self::generate_css_rule( $selector, $css_definitions, array( 'prettify' => true ) ); + } + + echo "\n"; + } + + /** + * Filters incoming CSS properties against WordPress Core's allowed CSS attributes in wp-includes/kses.php. + * + * @param string $property_declaration A CSS property declaration, e.g., `color: 'pink'`. + * + * @return string A filtered CSS property. Empty if not allowed. + */ + public static function sanitize_property_declaration( $property_declaration ) { + return esc_html( safecss_filter_attr( $property_declaration ) ); + } + + /** + * Creates a string consisting of CSS property declarations suitable for the value of an HTML element's style attribute. + * + * @param array $css_definitions An collection of CSS definitions `[ [ 'color' => 'red' ] ]`. + * + * @return string A concatenated string of CSS properties, e.g. `'color: red; font-size:12px'` + */ + public static function generate_inline_property_declarations( $css_definitions ) { + $css_rule_inline = ''; + + if ( empty( $css_definitions ) ) { + return $css_rule_inline; + } + foreach ( $css_definitions as $definition => $value ) { + $filtered_css = self::sanitize_property_declaration( "{$definition}: {$value}" ); + if ( ! empty( $filtered_css ) ) { + $css_rule_inline .= $filtered_css . ';'; + } + } + return $css_rule_inline; + } + + /** + * Creates a string consisting of a CSS rule. + * + * @param string $selector A CSS selector, e.g., `.some-class-name`. + * @param array $css_definitions An collection of CSS definitions `[ [ 'color' => 'red' ] ]`. + * @param array $options array( + * 'prettify' => (boolean) Whether to add carriage returns and indenting. + * 'indent' => (number) The number of tab indents to apply to the rule. Applies if `prettify` is `true`. + * );. + * + * @return string A CSS rule, e.g. `'.some-selector { color: red; font-size:12px }'` + */ + public static function generate_css_rule( $selector, $css_definitions, $options = array() ) { + $css_rule_block = ''; + + if ( ! $selector || empty( $css_definitions ) ) { + return $css_rule_block; + } + + $defaults = array( + 'prettify' => false, + 'indent' => 0, + ); + $options = wp_parse_args( $options, $defaults ); + $indent = str_repeat( "\t", $options['indent'] ); + $css_rule_block = $options['prettify'] ? "$indent$selector {\n" : "$selector { "; + + foreach ( $css_definitions as $definition => $value ) { + $filtered_css = self::sanitize_property_declaration( "{$definition}: {$value}" ); + if ( ! empty( $filtered_css ) ) { + if ( $options['prettify'] ) { + $css_rule_block .= "\t$indent$filtered_css;\n"; + } else { + $css_rule_block .= $filtered_css . ';'; + } + } + } + $css_rule_block .= $options['prettify'] ? "$indent}\n" : ' }'; + return $css_rule_block; + } + + // @TODO Using cascade layers should be opt-in. + /** + * Builds layers and styles rules from registered layers and styles for output. + */ + public static function enqueue_cascade_layers() { + $style_engine = WP_Style_Engine::get_instance(); + $registered_layers = $style_engine->get_registered_styles(); + + if ( empty( $registered_layers ) ) { + return; + } + + $layer_output = array(); + $styles_output = ''; + + foreach ( $style_engine::STYLE_LAYERS as $layer_name ) { + if ( ! isset( $registered_layers[ $layer_name ] ) || empty( $registered_layers[ $layer_name ] ) ) { + continue; + } + + $layer_output[] = $layer_name; + $styles_output .= "@layer {$layer_name} {\n"; + + foreach ( $registered_layers[ $layer_name ] as $selector => $css_definitions ) { + $styles_output .= self::generate_css_rule( + $selector, + $css_definitions, + array( + 'prettify' => true, + 'indent' => 1, + ) + ); + } + $styles_output .= '}'; + } + + if ( ! empty( $styles_output ) ) { + $layer_output = '@layer ' . implode( ', ', $layer_output ) . ";\n"; + wp_register_style( 'wp-styles-layers', false, array(), true, true ); + wp_add_inline_style( 'wp-styles-layers', $layer_output . $styles_output ); + wp_enqueue_style( 'wp-styles-layers' ); + } + } + + /** + * Taken from gutenberg_enqueue_block_support_styles() + * + * This function takes care of adding inline styles + * in the proper place, depending on the theme in use. + * + * For block themes, it's loaded in the head. + * For classic ones, it's loaded in the body + * because the wp_head action happens before + * the render_block. + * + * @see gutenberg_enqueue_block_support_styles() + * + * @param int $priority To set the priority for the add_action. + */ + public static function enqueue_registered_styles( $priority = 10 ) { + $action_hook_name = 'wp_footer'; + if ( wp_is_block_theme() ) { + $action_hook_name = 'wp_head'; + } + add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_cascade_layers' ) ); + add_action( + $action_hook_name, + array( __CLASS__, 'enqueue_cascade_layers' ), + $priority + ); + } +} + diff --git a/packages/style-engine/class-wp-style-engine-store.php b/packages/style-engine/class-wp-style-engine-store.php new file mode 100644 index 00000000000000..186bfb76be155d --- /dev/null +++ b/packages/style-engine/class-wp-style-engine-store.php @@ -0,0 +1,80 @@ +registered_styles[ $layer ] = array(); + } + } + + /** + * Register a style + * + * @param string $layer Unique key for a layer. + * @param string $key Unique key for a $style_data object. + * @param array $style_data Associative array of style information. + * @return boolean Whether registration was successful. + */ + public function register( $layer, $key, $style_data ) { + if ( empty( $layer ) || empty( $key ) || empty( $style_data ) ) { + return false; + } + + if ( isset( $this->registered_styles[ $layer ][ $key ] ) ) { + $style_data = array_unique( array_merge( $this->registered_styles[ $layer ][ $key ], $style_data ) ); + } + $this->registered_styles[ $layer ][ $key ] = $style_data; + return true; + } + + /** + * Retrieves style data from the store. If neither $layer nor $key are provided, + * this method will return everything in the store. + * + * @param string $layer Optional unique key for a layer to return all styles for a layer. + * @param string $key Optional unique key for a $style_data object to return a single style object. + * + * @return array Registered styles + */ + public function get( $layer = null, $key = null ) { + if ( isset( $this->registered_styles[ $layer ][ $key ] ) ) { + return $this->registered_styles[ $layer ][ $key ]; + } + + if ( isset( $this->registered_styles[ $layer ] ) ) { + return $this->registered_styles[ $layer ]; + } + + return $this->registered_styles; + } +} diff --git a/packages/style-engine/class-wp-style-engine.php b/packages/style-engine/class-wp-style-engine.php index a1961241047213..35a3c5ba9efdc1 100644 --- a/packages/style-engine/class-wp-style-engine.php +++ b/packages/style-engine/class-wp-style-engine.php @@ -2,6 +2,7 @@ /** * WP_Style_Engine * + * Singleton. * Generates classnames and block styles. * * @package Gutenberg @@ -30,6 +31,25 @@ class WP_Style_Engine { */ private static $instance = null; + /** + * Registered styles. + * + * @var WP_Style_Engine_Store|null + */ + private $styles_store = null; + + /** + * An ordered list of style layers from least specific to most specific. + * The layers loosely represent a cascade layer system. See: https://developer.mozilla.org/en-US/docs/Web/CSS/@layer + * The layer name will also be the key to retrieve the corresponding styles from the store. + * Using a "store" for each layer of the style hierarchy, one can control the order in which they're rendered. + * + * @TODO are static, named layers dynamic enough? Or should we define numerically according specificity? E.g., @wp-layer-1? + */ + const STYLE_LAYERS = array( + 'block-supports', // User-defined block-level overrides. + ); + /** * Style definitions that contain the instructions to * parse/output valid Gutenberg styles from a block's attributes. @@ -215,6 +235,15 @@ class WP_Style_Engine { ), ); + /** + * Gather internals. + */ + public function __construct() { + // @TODO not sure where keep as singleton or whether to dependency inject yet. + $this->styles_store = new WP_Style_Engine_Store( self::STYLE_LAYERS ); + WP_Style_Engine_Renderer::enqueue_registered_styles(); + } + /** * Utility method to retrieve the main instance of the class. * @@ -230,6 +259,32 @@ public static function get_instance() { return self::$instance; } + /** + * Global public interface to register block support styles support. + * + * @access private + * + * @param string $layer Unique key for a layer. + * @param string $key Unique key for a $style_data object. + * @param array $style_data Associative array of style information. + * @return void + */ + public function register_styles( $layer, $key, $style_data ) { + $this->styles_store->register( $layer, $key, $style_data ); + } + + /** + * Returns all registered block supports styles + * + * @access private + * + * @param string $layer Unique key for a layer. + * @return array All registered block support styles. + */ + public function get_registered_styles( $layer = null ) { + return $this->styles_store->get( $layer ); + } + /** * Extracts the slug in kebab case from a preset string, e.g., "heavenly-blue" from 'var:preset|color|heavenlyBlue'. * @@ -375,9 +430,11 @@ public function generate( $block_styles, $options ) { return null; } - $css_rules = array(); - $classnames = array(); - $should_return_css_vars = isset( $options['css_vars'] ) && true === $options['css_vars']; + $css_definitions = array(); + $classnames = array(); + $should_return_css_vars = isset( $options['css_vars'] ) && true === $options['css_vars']; + $should_register_and_enqueue = isset( $options['enqueue'] ) ? $options['enqueue'] : null; + $registry_layer_key = isset( $options['layer'] ) && in_array( $options['layer'], self::STYLE_LAYERS, true ) ? $options['layer'] : null; // Collect CSS and classnames. foreach ( self::BLOCK_STYLE_DEFINITIONS_METADATA as $definition_group_key => $definition_group_style ) { @@ -391,37 +448,33 @@ public function generate( $block_styles, $options ) { continue; } - $classnames = array_merge( $classnames, static::get_classnames( $style_value, $style_definition ) ); - $css_rules = array_merge( $css_rules, static::get_css( $style_value, $style_definition, $should_return_css_vars ) ); + $classnames = array_merge( $classnames, static::get_classnames( $style_value, $style_definition ) ); + $css_definitions = array_merge( $css_definitions, static::get_css( $style_value, $style_definition, $should_return_css_vars ) ); } } // Build CSS rules output. $selector = isset( $options['selector'] ) ? $options['selector'] : null; - $css = array(); $styles_output = array(); - if ( ! empty( $css_rules ) ) { - // Generate inline style rules. - foreach ( $css_rules as $rule => $value ) { - $filtered_css = esc_html( safecss_filter_attr( "{$rule}: {$value}" ) ); - if ( ! empty( $filtered_css ) ) { - $css[] = $filtered_css . ';'; - } - } + if ( $registry_layer_key && $selector && $should_register_and_enqueue ) { + // @TODO this could all change. Maybe we'd want to sanitize and build the rules later. + // Or return $css_rules here so another method can register. + // Just doing it all here for convenience while testing. + $this->register_styles( + $registry_layer_key, + $selector, + $css_definitions + ); + return null; } - // Return css, if any. - if ( ! empty( $css ) ) { - // Return an entire rule if there is a selector. - if ( $selector ) { - $style_block = "$selector { "; - $style_block .= implode( ' ', $css ); - $style_block .= ' }'; - $styles_output['css'] = $style_block; - } else { - $styles_output['css'] = implode( ' ', $css ); - } + // Return rendered CSS. + // Consider dependency injection of `WP_Style_Engine_Renderer` for testability. + if ( $selector ) { + $styles_output['css'] = WP_Style_Engine_Renderer::generate_css_rule( $selector, $css_definitions ); + } else { + $styles_output['css'] = WP_Style_Engine_Renderer::generate_inline_property_declarations( $css_definitions ); } // Return classnames, if any. @@ -513,3 +566,27 @@ function wp_style_engine_generate( $block_styles, $options = array() ) { } return null; } + +// @TODO Just testing! +/** + * Global public interface to register block support styles support to be output in the frontend. + * + * @access public + * + * @param array $block_styles An array of styles from a block's attributes. + * @param array $options An array of options to determine the output. + * @return void + */ +function wp_style_engine_enqueue_styles( $block_styles, $options = array() ) { + if ( class_exists( 'WP_Style_Engine' ) ) { + $style_engine = WP_Style_Engine::get_instance(); + $defaults = array( + 'enqueue' => true, + 'css_vars' => true, + ); + $new_options = wp_parse_args( $options, $defaults ); + + $style_engine->generate( $block_styles, $new_options ); + } +} + diff --git a/packages/style-engine/phpunit/class-wp-style-engine-test.php b/packages/style-engine/phpunit/class-wp-style-engine-test.php index e8274e85425fa3..c3da010dd1a123 100644 --- a/packages/style-engine/phpunit/class-wp-style-engine-test.php +++ b/packages/style-engine/phpunit/class-wp-style-engine-test.php @@ -7,6 +7,8 @@ */ require __DIR__ . '/../class-wp-style-engine.php'; +require __DIR__ . '/../class-wp-style-engine-store.php'; +require __DIR__ . '/../class-wp-style-engine-renderer.php'; /** * Tests for registering, storing and generating styles.