diff --git a/lib/experimental/interactivity-api/class-wp-directive-processor.php b/lib/experimental/interactivity-api/class-wp-directive-processor.php index e717b2e553943..048a324cb9a22 100644 --- a/lib/experimental/interactivity-api/class-wp-directive-processor.php +++ b/lib/experimental/interactivity-api/class-wp-directive-processor.php @@ -23,9 +23,9 @@ class WP_Directive_Processor extends Gutenberg_HTML_Tag_Processor_6_4 { /** - * An array of root blocks. + * A string containing the main root block. * - * @var array + * @var string */ public static $root_block = null; @@ -69,6 +69,76 @@ public static function has_root_block() { return isset( self::$root_block ); } + /** + * An array containing the main children of interactive. + * + * @var array + */ + public static $children_of_interactive_block = array(); + + /** + * Add a root block to the variable. + * + * @param array $block The block to add. + * + * @return void + */ + public static function mark_children_of_interactive_block( $block ) { + self::$children_of_interactive_block[] = md5( serialize( $block ) ); + } + + /** + * Remove a root block to the variable. + * + * @param array $block The block to remove. + * @return void + */ + public static function unmark_children_of_interactive_block( $block ) { + self::$children_of_interactive_block = array_diff( self::$children_of_interactive_block, array( md5( serialize( $block ) ) ) ); + } + + /** + * Check if block is a root block. + * + * @param array $block The block to check. + * + * @return bool True if block is a root block, false otherwise. + */ + public static function is_marked_as_children_of_interactive_block( $block ) { + return in_array( md5( serialize( $block ) ), self::$children_of_interactive_block, true ); + } + + /** + * Stack of namespaces. + * + * @var string[] + */ + private $ns_stack = array(); + + /** + * Push a new namespace onto the namespace stack. + * + * @param string $ns The new namespace. + */ + public function push_namespace( string $ns ) { + $this->ns_stack[] = $ns; + } + + /** + * Discard the current namespace. + */ + public function pop_namespace() { + array_pop( $this->ns_stack ); + } + + /** + * Return the current namespace, or false if no namespace is set. + * + * @return string|bool + */ + public function get_namespace() { + return end( $this->ns_stack ); + } /** * Find the matching closing tag for an opening tag. @@ -114,22 +184,25 @@ public function next_balanced_closer() { * Traverses the HTML searching for Interactivity API directives and processing * them. * - * @param WP_Directive_Processor $tags An instance of the WP_Directive_Processor. - * @param string $prefix Attribute prefix. - * @param string[] $directives Directives. + * @param string $prefix Attribute prefix. + * @param string[] $directives Directives. * * @return WP_Directive_Processor The modified instance of the * WP_Directive_Processor. */ - public function process_rendered_html( $tags, $prefix, $directives ) { + public function process_rendered_html( $prefix, $directives ) { $context = new WP_Directive_Context(); $tag_stack = array(); - while ( $tags->next_tag( array( 'tag_closers' => 'visit' ) ) ) { - $tag_name = $tags->get_tag(); + // Extract all directive names. They'll be used later on. + $directive_names = array_keys( $directives ); + $directive_names_rev = array_reverse( $directive_names ); + + while ( $this->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + $tag_name = $this->get_tag(); // Is this a tag that closes the latest opening tag? - if ( $tags->is_tag_closer() ) { + if ( $this->is_tag_closer() ) { if ( 0 === count( $tag_stack ) ) { continue; } @@ -145,7 +218,7 @@ public function process_rendered_html( $tags, $prefix, $directives ) { } } else { $attributes = array(); - foreach ( $tags->get_attribute_names_with_prefix( $prefix ) as $name ) { + foreach ( $this->get_attribute_names_with_prefix( $prefix ) as $name ) { /* * Removes the part after the double hyphen before looking for * the directive processor inside `$directives`, e.g., "wp-bind" @@ -164,19 +237,32 @@ public function process_rendered_html( $tags, $prefix, $directives ) { * encounter the matching closing tag. */ if ( - ! WP_Directive_Processor::is_html_void_element( $tags->get_tag() ) && + ! WP_Directive_Processor::is_html_void_element( $this->get_tag() ) && ( 0 !== count( $attributes ) || 0 !== count( $tag_stack ) ) ) { $tag_stack[] = array( $tag_name, $attributes ); } } - foreach ( $attributes as $attribute ) { - call_user_func( $directives[ $attribute ], $tags, $context ); + /* + * Sort attributes by the order they appear in the `$directives` + * argument, considering it as the priority order in which + * directives should be processed. Note that the order is reversed + * for tag closers. + */ + $sorted_attrs = array_intersect( + $this->is_tag_closer() + ? $directive_names_rev + : $directive_names, + $attributes + ); + + foreach ( $sorted_attrs as $attribute ) { + call_user_func( $directives[ $attribute ], $this, $context ); } } - return $tags; + return $this; } /** @@ -312,4 +398,21 @@ public static function is_html_void_element( $tag_name ) { public static function parse_attribute_name( $name ) { return explode( '--', $name, 2 ); } + + /** + * Extract and return the namespace from the given directive value. + * + * @param string $value The directive value. + * @return array The resulting array + */ + public static function parse_value_ns( $value ) { + $matches = array(); + $has_ns = preg_match( '/^([\w\-_\/]+)::(.+)$/', $value, $matches ); + + if ( $has_ns ) { + return array_slice( $matches, 1 ); + } else { + return array( null, $value ); + } + } } diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php new file mode 100644 index 0000000000000..a1edbcff776fb --- /dev/null +++ b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php @@ -0,0 +1,85 @@ +%s', + wp_json_encode( self::$initial_state, JSON_HEX_TAG | JSON_HEX_AMP ) + ); + } +} diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-store.php b/lib/experimental/interactivity-api/class-wp-interactivity-store.php deleted file mode 100644 index c53701b14e8af..0000000000000 --- a/lib/experimental/interactivity-api/class-wp-interactivity-store.php +++ /dev/null @@ -1,69 +0,0 @@ -%s', - wp_json_encode( self::$store, JSON_HEX_TAG | JSON_HEX_AMP ) - ); - } -} diff --git a/lib/experimental/interactivity-api/directive-processing.php b/lib/experimental/interactivity-api/directive-processing.php index 064fc8ea62cbb..4a3ed4b4ed1ea 100644 --- a/lib/experimental/interactivity-api/directive-processing.php +++ b/lib/experimental/interactivity-api/directive-processing.php @@ -39,16 +39,18 @@ function gutenberg_process_directives_in_root_blocks( $block_content, $block ) { if ( WP_Directive_Processor::is_marked_as_root_block( $block ) ) { WP_Directive_Processor::unmark_root_block(); $directives = array( - 'data-wp-bind' => 'gutenberg_interactivity_process_wp_bind', - 'data-wp-context' => 'gutenberg_interactivity_process_wp_context', - 'data-wp-class' => 'gutenberg_interactivity_process_wp_class', - 'data-wp-style' => 'gutenberg_interactivity_process_wp_style', - 'data-wp-text' => 'gutenberg_interactivity_process_wp_text', + 'data-wp-interactive' => 'gutenberg_interactivity_process_wp_interactive', + 'data-wp-context' => 'gutenberg_interactivity_process_wp_context', + 'data-wp-bind' => 'gutenberg_interactivity_process_wp_bind', + 'data-wp-class' => 'gutenberg_interactivity_process_wp_class', + 'data-wp-style' => 'gutenberg_interactivity_process_wp_style', + 'data-wp-text' => 'gutenberg_interactivity_process_wp_text', ); $tags = new WP_Directive_Processor( $block_content ); - $tags = $tags->process_rendered_html( $tags, 'data-wp-', $directives ); - return $tags->get_updated_html(); + return $tags + ->process_rendered_html( 'data-wp-', $directives ) + ->get_updated_html(); } @@ -56,18 +58,84 @@ function gutenberg_process_directives_in_root_blocks( $block_content, $block ) { } add_filter( 'render_block', 'gutenberg_process_directives_in_root_blocks', 10, 2 ); +/** + * Creates a stack of interactive block children. + * + * @param array $parsed_block The parsed block. + * @param array $source_block The source block. + * @param WP_Block $parent_block The parent block. + */ +function gutenberg_mark_interactive_block_children( $parsed_block, $source_block, $parent_block ) { + if ( + isset( $parent_block ) && + isset( $parent_block->block_type->supports['interactivity'] ) && + $parent_block->block_type->supports['interactivity'] + ) { + WP_Directive_Processor::mark_children_of_interactive_block( $source_block ); + } + return $parsed_block; +} +add_filter( 'render_block_data', 'gutenberg_mark_interactive_block_children', 100, 3 ); + +/** + * Add a marker indicating if the block is interactive or not. + * core/interactivity-wrapper if it is interactive. + * core/non-interactivity-wrapper if it is not interactive. + * + * @param string $block_content The block content. + * @param array $block The full block, including name and attributes. + * @param WP_Block $block_instance The block instance. + */ +function gutenberg_mark_block_interactivity( $block_content, $block, $block_instance ) { + if ( + isset( $block_instance->block_type->supports['interactivity'] ) && + $block_instance->block_type->supports['interactivity'] + ) { + // Mark interactive blocks so we can process them later. + return get_comment_delimited_block_content( + 'core/interactivity-wrapper', + array( + 'blockName' => $block['blockName'], + // We can put extra information about the block here. + ), + $block_content + ); + } elseif ( WP_Directive_Processor::is_marked_as_children_of_interactive_block( $block ) ) { + WP_Directive_Processor::unmark_children_of_interactive_block( $block ); + // Mark children of interactive blocks that are not interactive themselves + // to so we can skip them later. + return get_comment_delimited_block_content( + 'core/non-interactivity-wrapper', + array( + 'blockName' => $block['blockName'], + // We can put extra information about the block here. + ), + $block_content + ); + } + return $block_content; +} + +add_filter( 'render_block', 'gutenberg_mark_block_interactivity', 10, 3 ); /** * Resolve the reference using the store and the context from the provided path. * - * @param string $path Path. + * @param string $path Path. + * @param string $ns Current namespace. * @param array $context Context data. * @return mixed */ -function gutenberg_interactivity_evaluate_reference( $path, array $context = array() ) { - $store = array_merge( - WP_Interactivity_Store::get_data(), - array( 'context' => $context ) +function gutenberg_interactivity_evaluate_reference( $path, $ns, array $context = array() ) { + // Separate the namespace from the path value (if present). + list( $path_ns, $path ) = WP_Directive_Processor::parse_value_ns( $path ); + + // Overwrite namespace if path value contains one. + $ns = $path_ns ?? $ns; + + $store = array( + 'state' => WP_Interactivity_Initial_State::get_state( $ns ), + 'context' => $context[ $ns ] ?? array(), ); /* @@ -76,10 +144,9 @@ function gutenberg_interactivity_evaluate_reference( $path, array $context = arr * passed context) using the subsequent path should be negated. */ $should_negate_value = '!' === $path[0]; - - $path = $should_negate_value ? substr( $path, 1 ) : $path; - $path_segments = explode( '.', $path ); - $current = $store; + $path = $should_negate_value ? substr( $path, 1 ) : $path; + $path_segments = explode( '.', $path ); + $current = $store; foreach ( $path_segments as $p ) { if ( isset( $current[ $p ] ) ) { $current = $current[ $p ]; diff --git a/lib/experimental/interactivity-api/directives/wp-bind.php b/lib/experimental/interactivity-api/directives/wp-bind.php index 54be4a9faeb7d..bccd94f81f267 100644 --- a/lib/experimental/interactivity-api/directives/wp-bind.php +++ b/lib/experimental/interactivity-api/directives/wp-bind.php @@ -26,7 +26,7 @@ function gutenberg_interactivity_process_wp_bind( $tags, $context ) { } $expr = $tags->get_attribute( $attr ); - $value = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + $value = gutenberg_interactivity_evaluate_reference( $expr, $tags->get_namespace(), $context->get_context() ); $tags->set_attribute( $bound_attr, $value ); } } diff --git a/lib/experimental/interactivity-api/directives/wp-class.php b/lib/experimental/interactivity-api/directives/wp-class.php index 741cc75b42c60..09750e426774f 100644 --- a/lib/experimental/interactivity-api/directives/wp-class.php +++ b/lib/experimental/interactivity-api/directives/wp-class.php @@ -26,7 +26,7 @@ function gutenberg_interactivity_process_wp_class( $tags, $context ) { } $expr = $tags->get_attribute( $attr ); - $add_class = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + $add_class = gutenberg_interactivity_evaluate_reference( $expr, $tags->get_namespace(), $context->get_context() ); if ( $add_class ) { $tags->add_class( $class_name ); } else { diff --git a/lib/experimental/interactivity-api/directives/wp-context.php b/lib/experimental/interactivity-api/directives/wp-context.php index 7d92b0ac7b0c6..3d00bede43e7a 100644 --- a/lib/experimental/interactivity-api/directives/wp-context.php +++ b/lib/experimental/interactivity-api/directives/wp-context.php @@ -19,10 +19,17 @@ function gutenberg_interactivity_process_wp_context( $tags, $context ) { $value = $tags->get_attribute( 'data-wp-context' ); - $new_context = json_decode( - is_string( $value ) && ! empty( $value ) ? $value : '{}', - true + /** + * Separate namespace and value from the context directive attribute. A + * check to ensure it's a non-empty string in order to avoid exceptions. + */ + list( $ns, $value ) = WP_Directive_Processor::parse_value_ns( + is_string( $value ) && ! empty( $value ) ? $value : '{}' ); - $context->set_context( $new_context ?? array() ); + // If there's no directive namespace, use the inherited one. + $ns = $ns ?? $tags->get_namespace(); + + $new_context = json_decode( $value, true ); + $context->set_context( array( $ns => $new_context ?? array() ) ); } diff --git a/lib/experimental/interactivity-api/directives/wp-interactive.php b/lib/experimental/interactivity-api/directives/wp-interactive.php new file mode 100644 index 0000000000000..a13451ef18c6f --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-interactive.php @@ -0,0 +1,23 @@ +is_tag_closer() ) { + $tags->pop_namespace(); + return; + } + + $value = $tags->get_attribute( 'data-wp-interactive' ); + $island = json_decode( $value, true ); + + $tags->push_namespace( $island['namespace'] ); +} diff --git a/lib/experimental/interactivity-api/directives/wp-style.php b/lib/experimental/interactivity-api/directives/wp-style.php index 9c37f9082c2c0..ace690a4bf161 100644 --- a/lib/experimental/interactivity-api/directives/wp-style.php +++ b/lib/experimental/interactivity-api/directives/wp-style.php @@ -26,7 +26,7 @@ function gutenberg_interactivity_process_wp_style( $tags, $context ) { } $expr = $tags->get_attribute( $attr ); - $style_value = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + $style_value = gutenberg_interactivity_evaluate_reference( $expr, $tags->get_namespace(), $context->get_context() ); if ( $style_value ) { $style_attr = $tags->get_attribute( 'style' ); $style_attr = gutenberg_interactivity_set_style( $style_attr, $style_name, $style_value ); diff --git a/lib/experimental/interactivity-api/directives/wp-text.php b/lib/experimental/interactivity-api/directives/wp-text.php index b0cfc98a74e70..ad1698006a9b5 100644 --- a/lib/experimental/interactivity-api/directives/wp-text.php +++ b/lib/experimental/interactivity-api/directives/wp-text.php @@ -22,6 +22,6 @@ function gutenberg_interactivity_process_wp_text( $tags, $context ) { return; } - $text = gutenberg_interactivity_evaluate_reference( $value, $context->get_context() ); + $text = gutenberg_interactivity_evaluate_reference( $value, $tags->get_namespace(), $context->get_context() ); $tags->set_inner_html( esc_html( $text ) ); } diff --git a/lib/experimental/interactivity-api/initial-state.php b/lib/experimental/interactivity-api/initial-state.php new file mode 100644 index 0000000000000..a38d0da631f3c --- /dev/null +++ b/lib/experimental/interactivity-api/initial-state.php @@ -0,0 +1,29 @@ +assertEmpty( WP_Interactivity_Initial_State::get_data() ); + } + + public function test_initial_state_can_be_merged() { + $state = array( + 'a' => 1, + 'b' => 2, + 'nested' => array( + 'c' => 3, + ), + ); + WP_Interactivity_Initial_State::merge_state( 'core', $state ); + $this->assertSame( $state, WP_Interactivity_Initial_State::get_state( 'core' ) ); + } + + public function test_initial_state_can_be_extended() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'b' => 2 ) ); + WP_Interactivity_Initial_State::merge_state( 'custom', array( 'c' => 3 ) ); + $this->assertSame( + array( + 'core' => array( + 'a' => 1, + 'b' => 2, + ), + 'custom' => array( + 'c' => 3, + ), + ), + WP_Interactivity_Initial_State::get_data() + ); + } + + public function test_initial_state_existing_props_should_be_overwritten() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 'overwritten' ) ); + $this->assertSame( + array( + 'core' => array( + 'a' => 'overwritten', + ), + ), + WP_Interactivity_Initial_State::get_data() + ); + } + + public function test_initial_state_existing_indexed_arrays_should_be_replaced() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => array( 1, 2 ) ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => array( 3, 4 ) ) ); + $this->assertSame( + array( + 'core' => array( + 'a' => array( 3, 4 ), + ), + ), + WP_Interactivity_Initial_State::get_data() + ); + } + + public function test_initial_state_should_be_correctly_rendered() { + WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) ); + WP_Interactivity_Initial_State::merge_state( 'core', array( 'b' => 2 ) ); + WP_Interactivity_Initial_State::merge_state( 'custom', array( 'c' => 3 ) ); + + ob_start(); + WP_Interactivity_Initial_State::render(); + $rendered = ob_get_clean(); + $this->assertSame( + '', + $rendered + ); + } + + public function test_initial_state_should_also_escape_tags_and_amps() { + WP_Interactivity_Initial_State::merge_state( + 'test', + array( + 'amps' => 'http://site.test/?foo=1&baz=2&bar=3', + 'tags' => 'Do not do this: