From bcbaecf9a6f98fb9c59befaecef5df88899a58ca Mon Sep 17 00:00:00 2001 From: David Arenas Date: Thu, 8 Feb 2024 16:20:13 +0100 Subject: [PATCH] Interactivity API: Move Core implementation to compat 6.5 folder (#58829) Co-authored-by: DAreRodz Co-authored-by: youknowriad Co-authored-by: c4rl0sbr4v0 Co-authored-by: luisherranz Co-authored-by: getdave --- ...interactivity-api-directives-processor.php | 160 ++-- .../class-wp-interactivity-api.php | 316 ++++---- .../interactivity-api/interactivity-api.php | 88 ++- lib/experimental/interactivity-api.php | 2 +- ...activity-api-directives-processor-test.php | 703 ------------------ .../class-wp-interactivity-api-test.php | 592 --------------- ...lass-wp-interactivity-api-wp-bind-test.php | 338 --------- ...ass-wp-interactivity-api-wp-class-test.php | 291 -------- ...s-wp-interactivity-api-wp-context-test.php | 476 ------------ ...lass-wp-interactivity-api-wp-each-test.php | 636 ---------------- ...-interactivity-api-wp-interactive-test.php | 206 ----- ...nteractivity-api-wp-router-region-test.php | 147 ---- ...ass-wp-interactivity-api-wp-style-test.php | 403 ---------- ...lass-wp-interactivity-api-wp-text-test.php | 137 ---- .../interactivity-api-test.php | 217 ------ 15 files changed, 344 insertions(+), 4368 deletions(-) delete mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php delete mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-test.php delete mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-bind-test.php delete mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-class-test.php delete mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-context-test.php delete mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php delete mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-interactive-test.php delete mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-router-region-test.php delete mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-style-test.php delete mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-text-test.php delete mode 100644 phpunit/interactivity-api/interactivity-api-test.php diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php index b4cfa5a499872c..32f8c60567c49a 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php @@ -4,6 +4,7 @@ * * @package WordPress * @subpackage Interactivity API + * @since 6.5.0 */ if ( ! class_exists( 'WP_Interactivity_API_Directives_Processor' ) ) { @@ -11,77 +12,86 @@ * Class used to iterate over the tags of an HTML string and help process the * directive attributes. * + * @since 6.5.0 + * * @access private */ - class WP_Interactivity_API_Directives_Processor extends Gutenberg_HTML_Tag_Processor_6_5 { + final class WP_Interactivity_API_Directives_Processor extends WP_HTML_Tag_Processor { + /** + * List of tags whose closer tag is not visited by the WP_HTML_Tag_Processor. + * + * @since 6.5.0 + * @var string[] + */ + const TAGS_THAT_DONT_VISIT_CLOSER_TAG = array( + 'SCRIPT', + 'IFRAME', + 'NOEMBED', + 'NOFRAMES', + 'STYLE', + 'TEXTAREA', + 'TITLE', + 'XMP', + ); + /** * Returns the content between two balanced template tags. * * It positions the cursor in the closer tag of the balanced template tag, * if it exists. * + * @since 6.5.0 + * * @access private * * @return string|null The content between the current opener template tag and its matching closer tag or null if it - * doesn't find the matching closing tag. + * doesn't find the matching closing tag or the current tag is not a template opener tag. */ public function get_content_between_balanced_template_tags() { - if ( 'TEMPLATE' !== $this->get_tag() || $this->is_tag_closer() ) { + if ( 'TEMPLATE' !== $this->get_tag() ) { return null; } - // Flushes any changes. - $this->get_updated_html(); - - $bookmarks = $this->get_balanced_tag_bookmarks(); - if ( ! $bookmarks ) { + $positions = $this->get_after_opener_tag_and_before_closer_tag_positions(); + if ( ! $positions ) { return null; } - list( $start_name, $end_name ) = $bookmarks; - - $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; - $end = $this->bookmarks[ $end_name ]->start; + list( $after_opener_tag, $before_closer_tag ) = $positions; - $this->release_bookmark( $start_name ); - $this->release_bookmark( $end_name ); - - return substr( $this->html, $start, $end - $start ); + return substr( $this->html, $after_opener_tag, $before_closer_tag - $after_opener_tag ); } /** * Sets the content between two balanced tags. * + * @since 6.5.0 + * * @access private * * @param string $new_content The string to replace the content between the matching tags. * @return bool Whether the content was successfully replaced. */ public function set_content_between_balanced_tags( string $new_content ): bool { - // Flushes any changes. - $this->get_updated_html(); - - $bookmarks = $this->get_balanced_tag_bookmarks(); - if ( ! $bookmarks ) { + $positions = $this->get_after_opener_tag_and_before_closer_tag_positions( true ); + if ( ! $positions ) { return false; } - list( $start_name, $end_name ) = $bookmarks; - - $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; - $end = $this->bookmarks[ $end_name ]->start; + list( $after_opener_tag, $before_closer_tag ) = $positions; - $this->seek( $start_name ); - $this->release_bookmark( $start_name ); - $this->release_bookmark( $end_name ); + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $after_opener_tag, + $before_closer_tag - $after_opener_tag, + esc_html( $new_content ) + ); - $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $start, $end - $start, esc_html( $new_content ) ); return true; } /** * Appends content after the closing tag of a template tag. * - * This method positions the processor in the last tag of the appended - * content, if it exists. + * It positions the cursor in the closer tag of the balanced template tag, + * if it exists. * * @access private * @@ -89,7 +99,6 @@ public function set_content_between_balanced_tags( string $new_content ): bool { * @return bool Whether the content was successfully appended. */ public function append_content_after_template_tag_closer( string $new_content ): bool { - // Refuses to process if the content is empty or this is not a closer template tag. if ( empty( $new_content ) || 'TEMPLATE' !== $this->get_tag() || ! $this->is_tag_closer() ) { return false; } @@ -99,44 +108,89 @@ public function append_content_after_template_tag_closer( string $new_content ): $bookmark = 'append_content_after_template_tag_closer'; $this->set_bookmark( $bookmark ); - $end = $this->bookmarks[ $bookmark ]->start + $this->bookmarks[ $bookmark ]->length + 1; + $after_closing_tag = $this->bookmarks[ $bookmark ]->start + $this->bookmarks[ $bookmark ]->length + 1; $this->release_bookmark( $bookmark ); // Appends the new content. - $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $end, 0, $new_content ); + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $after_closing_tag, 0, $new_content ); return true; } /** - * Returns a pair of bookmarks for the current opening tag and the matching - * closing tag. + * Gets the positions right after the opener tag and right before the closer + * tag in a balanced tag. + * + * By default, it positions the cursor in the closer tag of the balanced tag. + * If $rewind is true, it seeks back to the opener tag. + * + * @since 6.5.0 + * + * @access private + * + * @param bool $rewind Optional. Whether to seek back to the opener tag after finding the positions. Defaults to false. + * @return array|null Start and end byte position, or null when no balanced tag bookmarks. + */ + private function get_after_opener_tag_and_before_closer_tag_positions( bool $rewind = false ) { + // Flushes any changes. + $this->get_updated_html(); + + $bookmarks = $this->get_balanced_tag_bookmarks(); + if ( ! $bookmarks ) { + return null; + } + list( $opener_tag, $closer_tag ) = $bookmarks; + + $after_opener_tag = $this->bookmarks[ $opener_tag ]->start + $this->bookmarks[ $opener_tag ]->length + 1; + $before_closer_tag = $this->bookmarks[ $closer_tag ]->start; + + if ( $rewind ) { + $this->seek( $opener_tag ); + } + + $this->release_bookmark( $opener_tag ); + $this->release_bookmark( $closer_tag ); + + return array( $after_opener_tag, $before_closer_tag ); + } + + /** + * Returns a pair of bookmarks for the current opener tag and the matching + * closer tag. + * + * It positions the cursor in the closer tag of the balanced tag, if it + * exists. + * + * @since 6.5.0 * * @return array|null A pair of bookmarks, or null if there's no matching closing tag. */ private function get_balanced_tag_bookmarks() { static $i = 0; - $start_name = 'start_of_balanced_tag_' . ++$i; + $opener_tag = 'opener_tag_of_balanced_tag_' . ++$i; - $this->set_bookmark( $start_name ); + $this->set_bookmark( $opener_tag ); if ( ! $this->next_balanced_tag_closer_tag() ) { - $this->release_bookmark( $start_name ); + $this->release_bookmark( $opener_tag ); return null; } - $end_name = 'end_of_balanced_tag_' . ++$i; - $this->set_bookmark( $end_name ); + $closer_tag = 'closer_tag_of_balanced_tag_' . ++$i; + $this->set_bookmark( $closer_tag ); - return array( $start_name, $end_name ); + return array( $opener_tag, $closer_tag ); } /** * Finds the matching closing tag for an opening tag. * * When called while the processor is on an open tag, it traverses the HTML - * until it finds the matching closing tag, respecting any in-between - * content, including nested tags of the same name. Returns false when - * called on a closing or void tag, or if no matching closing tag was found. + * until it finds the matching closer tag, respecting any in-between content, + * including nested tags of the same name. Returns false when called on a + * closer tag, a tag that doesn't have a closer tag (void), a tag that + * doesn't visit the closer tag, or if no matching closing tag was found. + * + * @since 6.5.0 * * @access private * @@ -146,7 +200,7 @@ public function next_balanced_tag_closer_tag(): bool { $depth = 0; $tag_name = $this->get_tag(); - if ( $this->is_void() ) { + if ( ! $this->has_and_visits_its_closer_tag() ) { return false; } @@ -172,15 +226,21 @@ public function next_balanced_tag_closer_tag(): bool { } /** - * Checks whether the current tag is void. + * Checks whether the current tag has and will visit its matching closer tag. + * + * @since 6.5.0 * * @access private * - * @return bool Whether the current tag is void or not. + * @return bool Whether the current tag has a closer tag. */ - public function is_void(): bool { + public function has_and_visits_its_closer_tag(): bool { $tag_name = $this->get_tag(); - return Gutenberg_HTML_Processor_6_5::is_void( null !== $tag_name ? $tag_name : '' ); + + return null !== $tag_name && ( + ! WP_HTML_Processor::is_void( $tag_name ) && + ! in_array( $tag_name, self::TAGS_THAT_DONT_VISIT_CLOSER_TAG, true ) + ); } } } diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php index be9203198d3f2f..3bfade3aa4aa75 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php @@ -4,13 +4,16 @@ * * @package WordPress * @subpackage Interactivity API + * @since 6.5.0 */ if ( ! class_exists( 'WP_Interactivity_API' ) ) { /** - * Class used to process the Interactivity API in the server. + * Class used to process the Interactivity API on the server. + * + * @since 6.5.0 */ - class WP_Interactivity_API { + final class WP_Interactivity_API { /** * Holds the mapping of directive attribute names to their processor methods. * @@ -83,9 +86,10 @@ class WP_Interactivity_API { * @param string $store_namespace The unique store namespace identifier. * @param array $state Optional. The array that will be merged with the existing state for the specified * store namespace. - * @return array The current state for the specified store namespace. + * @return array The current state for the specified store namespace. This will be the updated state if a $state + * argument was provided. */ - public function state( string $store_namespace, array $state = null ): array { + public function state( string $store_namespace, array $state = array() ): array { if ( ! isset( $this->state_data[ $store_namespace ] ) ) { $this->state_data[ $store_namespace ] = array(); } @@ -110,9 +114,10 @@ public function state( string $store_namespace, array $state = null ): array { * @param string $store_namespace The unique store namespace identifier. * @param array $config Optional. The array that will be merged with the existing configuration for the * specified store namespace. - * @return array The current configuration for the specified store namespace. + * @return array The configuration for the specified store namespace. This will be the updated configuration if a + * $config argument was provided. */ - public function config( string $store_namespace, array $config = null ): array { + public function config( string $store_namespace, array $config = array() ): array { if ( ! isset( $this->config_data[ $store_namespace ] ) ) { $this->config_data[ $store_namespace ] = array(); } @@ -166,18 +171,17 @@ public function print_client_interactivity_data() { * @since 6.5.0 */ public function register_script_modules() { + $suffix = wp_scripts_get_suffix(); + wp_register_script_module( '@wordpress/interactivity', - gutenberg_url( '/build/interactivity/index.min.js' ), - array(), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) + includes_url( "js/dist/interactivity$suffix.js" ) ); wp_register_script_module( '@wordpress/interactivity-router', - gutenberg_url( '/build/interactivity/router.min.js' ), - array( '@wordpress/interactivity' ), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) + includes_url( "js/dist/interactivity-router$suffix.js" ), + array( '@wordpress/interactivity' ) ); } @@ -187,6 +191,7 @@ public function register_script_modules() { * @since 6.5.0 */ public function add_hooks() { + add_action( 'wp_enqueue_scripts', array( $this, 'register_script_modules' ) ); add_action( 'wp_footer', array( $this, 'print_client_interactivity_data' ) ); } @@ -210,7 +215,7 @@ public function process_directives( string $html ): string { * Processes the interactivity directives contained within the HTML content * and updates the markup accordingly. * - * It needs the context and namespace stacks to be passed by reference and + * It needs the context and namespace stacks to be passed by reference, and * it returns null if the HTML contains unbalanced tags. * * @since 6.5.0 @@ -228,9 +233,14 @@ private function process_directives_args( string $html, array &$context_stack, a $directive_processor_prefixes = array_keys( self::$directive_processors ); $directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes ); - while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) && false === $unbalanced ) { + while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { $tag_name = $p->get_tag(); + if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) { + $unbalanced = true; + break; + } + if ( $p->is_tag_closer() ) { list( $opening_tag_name, $directives_prefixes ) = end( $tag_stack ); @@ -242,27 +252,23 @@ private function process_directives_args( string $html, array &$context_stack, a * stops processing it. */ $unbalanced = true; - continue; + break; } else { - - /* - * It removes the last tag from the stack. - */ + // Remove the last tag from the stack. array_pop( $tag_stack ); - - /* - * If the matching opening tag didn't have any directives, it can skip - * the processing. - */ - if ( 0 === count( $directives_prefixes ) ) { - continue; - } } } else { - if ( 0 === count( $p->get_attribute_names_with_prefix( 'data-wp-each-child' ) ) ) { + if ( 0 !== count( $p->get_attribute_names_with_prefix( 'data-wp-each-child' ) ) ) { + /* + * If the tag has a `data-wp-each-child` directive, jump to its closer + * tag because those tags have already been processed. + */ + $p->next_balanced_tag_closer_tag(); + continue; + } else { $directives_prefixes = array(); - // Checks if there is is a server directive processor registered for each directive. + // Checks if there is a server directive processor registered for each directive. foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name ); if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { @@ -271,18 +277,21 @@ private function process_directives_args( string $html, array &$context_stack, a } /* - * If this is not a void element, it adds it to the tag stack so it can - * process its closing tag and check for unbalanced tags. - */ - if ( ! $p->is_void() ) { + * If this tag will visit its closer tag, it adds it to the tag stack + * so it can process its closing tag and check for unbalanced tags. + */ + if ( $p->has_and_visits_its_closer_tag() ) { $tag_stack[] = array( $tag_name, $directives_prefixes ); } - } else { - // Jumps to the tag closer if the tag has a `data-wp-each-child` directive. - $p->next_balanced_tag_closer_tag(); - continue; } } + /* + * If the matching opener tag didn't have any directives, it can skip the + * processing. + */ + if ( 0 === count( $directives_prefixes ) ) { + continue; + } /* * Sorts the attributes by the order of the `directives_processor` array @@ -291,8 +300,8 @@ private function process_directives_args( string $html, array &$context_stack, a */ $directives_prefixes = array_intersect( $p->is_tag_closer() - ? $directive_processor_prefixes_reversed - : $directive_processor_prefixes, + ? $directive_processor_prefixes_reversed + : $directive_processor_prefixes, $directives_prefixes ); @@ -336,11 +345,11 @@ private function evaluate( $directive_value, string $default_namespace, $context } $store = array( - 'state' => isset( $this->state_data[ $ns ] ) ? $this->state_data[ $ns ] : array(), - 'context' => isset( $context[ $ns ] ) ? $context[ $ns ] : array(), + 'state' => $this->state_data[ $ns ] ?? array(), + 'context' => $context[ $ns ] ?? array(), ); - // Checks if the reference path is preceded by a negator operator (!). + // Checks if the reference path is preceded by a negation operator (!). $should_negate_value = '!' === $path[0]; $path = $should_negate_value ? substr( $path, 1 ) : $path; @@ -355,7 +364,7 @@ private function evaluate( $directive_value, string $default_namespace, $context } } - // Returns the opposite if it contains a negator operator (!). + // Returns the opposite if it contains a negation operator (!). return $should_negate_value ? ! $current : $current; } @@ -388,7 +397,7 @@ private function extract_prefix_and_suffix( string $directive_name ): array { * If the value doesn't contain an explicit namespace, it returns the * default one. If the value contains a JSON object instead of a reference * path, the function tries to parse it and return the resulting array. If - * the value contains strings that reprenset booleans ("true" and "false"), + * the value contains strings that represent booleans ("true" and "false"), * numbers ("1" and "1.2") or "null", the function also transform them to * regular booleans, numbers and `null`. * @@ -443,7 +452,7 @@ private function kebab_to_camel_case( string $str ): string { function ( $matches ) { return strtoupper( $matches[2] ); }, - strtolower( preg_replace( '/-+$/', '', $str ) ) + strtolower( rtrim( $str, '-' ) ) ) ); } @@ -452,7 +461,7 @@ function ( $matches ) { * Processes the `data-wp-interactive` directive. * * It adds the default store namespace defined in the directive value to the - * stack so it's available for the nested interactivity elements. + * stack so that it's available for the nested interactivity elements. * * @since 6.5.0 * @@ -463,14 +472,12 @@ function ( $matches ) { private function data_wp_interactive_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { // In closing tags, it removes the last namespace from the stack. if ( $p->is_tag_closer() ) { - return array_pop( $namespace_stack ); + array_pop( $namespace_stack ); + return; } // Tries to decode the `data-wp-interactive` attribute value. $attribute_value = $p->get_attribute( 'data-wp-interactive' ); - $decoded_json = is_string( $attribute_value ) && ! empty( $attribute_value ) - ? json_decode( $attribute_value, true ) - : null; /* * Pushes the newly defined namespace or the current one if the @@ -480,16 +487,25 @@ private function data_wp_interactive_processor( WP_Interactivity_API_Directives_ * independently of whether the previous `data-wp-interactive` definition * contained a valid namespace. */ - $namespace_stack[] = isset( $decoded_json['namespace'] ) - ? $decoded_json['namespace'] - : end( $namespace_stack ); + $new_namespace = null; + if ( is_string( $attribute_value ) && ! empty( $attribute_value ) ) { + $decoded_json = json_decode( $attribute_value, true ); + if ( is_array( $decoded_json ) ) { + $new_namespace = $decoded_json['namespace'] ?? null; + } else { + $new_namespace = $attribute_value; + } + } + $namespace_stack[] = ( $new_namespace && 1 === preg_match( '/^([\w\-_\/]+)/', $new_namespace ) ) + ? $new_namespace + : end( $namespace_stack ); } /** * Processes the `data-wp-context` directive. * - * It adds the context defined in the directive value to the stack so it's - * available for the nested interactivity elements. + * It adds the context defined in the directive value to the stack so that + * it's available for the nested interactivity elements. * * @since 6.5.0 * @@ -500,7 +516,8 @@ private function data_wp_interactive_processor( WP_Interactivity_API_Directives_ private function data_wp_context_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { // In closing tags, it removes the last context from the stack. if ( $p->is_tag_closer() ) { - return array_pop( $context_stack ); + array_pop( $context_stack ); + return; } $attribute_value = $p->get_attribute( 'data-wp-context' ); @@ -508,20 +525,17 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc // Separates the namespace from the context JSON object. list( $namespace_value, $decoded_json ) = is_string( $attribute_value ) && ! empty( $attribute_value ) - ? $this->extract_directive_value( $attribute_value, $namespace_value ) - : array( $namespace_value, null ); + ? $this->extract_directive_value( $attribute_value, $namespace_value ) + : array( $namespace_value, null ); /* * If there is a namespace, it adds a new context to the stack merging the * previous context with the new one. */ if ( is_string( $namespace_value ) ) { - array_push( - $context_stack, - array_replace_recursive( - end( $context_stack ) !== false ? end( $context_stack ) : array(), - array( $namespace_value => is_array( $decoded_json ) ? $decoded_json : array() ) - ) + $context_stack[] = array_replace_recursive( + end( $context_stack ) !== false ? end( $context_stack ) : array(), + array( $namespace_value => is_array( $decoded_json ) ? $decoded_json : array() ) ); } else { /* @@ -529,7 +543,7 @@ private function data_wp_context_processor( WP_Interactivity_API_Directives_Proc * It needs to do so because the function pops out the current context * from the stack whenever it finds a `data-wp-context`'s closing tag. */ - array_push( $context_stack, end( $context_stack ) ); + $context_stack[] = end( $context_stack ); } } @@ -577,7 +591,6 @@ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Process } } - /** * Processes the `data-wp-class` directive. * @@ -644,8 +657,8 @@ private function data_wp_style_processor( WP_Interactivity_API_Directives_Proces * attribute value is not empty because if it is, it doesn't need to * update the attribute value. */ - if ( $style_property_value || ( ! $style_property_value && $style_attribute_value ) ) { - $style_attribute_value = $this->set_style_property( $style_attribute_value, $style_property, $style_property_value ); + if ( $style_property_value || $style_attribute_value ) { + $style_attribute_value = $this->merge_style_property( $style_attribute_value, $style_property, $style_property_value ); /* * If the style attribute value is not empty, it sets it. Otherwise, * it removes it. @@ -661,19 +674,19 @@ private function data_wp_style_processor( WP_Interactivity_API_Directives_Proces } /** - * Sets an individual style property in the `style` attribute of an HTML + * Merges an individual style property in the `style` attribute of an HTML * element, updating or removing the property when necessary. * - * If a property is modified, it is added at the end of the list to make sure - * that it overrides the previous ones. + * If a property is modified, the old one is removed and the new one is added + * at the end of the list. * * @since 6.5.0 * * Example: * - * set_style_property( 'color:green;', 'color', 'red' ) => 'color:red;' - * set_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;' - * set_style_property( 'color:green;', 'color', null ) => '' + * merge_style_property( 'color:green;', 'color', 'red' ) => 'color:red;' + * merge_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;' + * merge_style_property( 'color:green;', 'color', null ) => '' * * @param string $style_attribute_value The current style attribute value. * @param string $style_property_name The style property name to set. @@ -681,13 +694,13 @@ private function data_wp_style_processor( WP_Interactivity_API_Directives_Proces * empty string, it removes the style property. * @return string The new style attribute value after the specified property has been added, updated or removed. */ - private function set_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string { + private function merge_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string { $style_assignments = explode( ';', $style_attribute_value ); $result = array(); $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null; $new_style_property = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : ''; - // Generate an array with all the properties but the modified one. + // Generates an array with all the properties but the modified one. foreach ( $style_assignments as $style_assignment ) { if ( empty( trim( $style_assignment ) ) ) { continue; @@ -698,8 +711,8 @@ private function set_style_property( string $style_attribute_value, string $styl } } - // Add the new/modified property at the end of the list. - array_push( $result, $new_style_property ); + // Adds the new/modified property at the end of the list. + $result[] = $new_style_property; return implode( '', $result ); } @@ -734,6 +747,72 @@ private function data_wp_text_processor( WP_Interactivity_API_Directives_Process } } + /** + * Returns the CSS styles for animating the top loading bar in the router. + * + * @since 6.5.0 + * + * @return string The CSS styles for the router's top loading bar animation. + */ + private function get_router_animation_styles(): string { + return << +
+HTML; + } + /** * Processes the `data-wp-router-region` directive. * @@ -755,10 +834,7 @@ private function data_wp_router_region_processor( WP_Interactivity_API_Directive 'core/router', array( 'navigation' => array( - 'message' => '', - 'hasStarted' => false, - 'hasFinished' => false, - 'texts' => array( + 'texts' => array( 'loading' => __( 'Loading page, please wait.' ), 'loaded' => __( 'Page Loaded.' ), ), @@ -766,54 +842,13 @@ private function data_wp_router_region_processor( WP_Interactivity_API_Directive ) ); - $callback = static function () { - echo << -.wp-interactivity-router_loading-bar { - position: fixed; - top: 0; - left: 0; - margin: 0; - padding: 0; - width: 100vw; - max-width: 100vw !important; - height: 4px; - background-color: var(--wp--preset--color--primary, #000); - opacity: 0 -} -.wp-interactivity-router_loading-bar.start-animation { - animation: wp-interactivity-router_loading-bar-start-animation 30s cubic-bezier(0.03, 0.5, 0, 1) forwards -} -.wp-interactivity-router_loading-bar.finish-animation { - animation: wp-interactivity-router_loading-bar-finish-animation 300ms ease-in -} - -@keyframes wp-interactivity-router_loading-bar-start-animation { - 0% { transform: scaleX(0); transform-origin: 0% 0%; opacity: 1 } - 100% { transform: scaleX(1); transform-origin: 0% 0%; opacity: 1 } -} -@keyframes wp-interactivity-router_loading-bar-finish-animation { - 0% { opacity: 1 } - 50% { opacity: 1 } - 100% { opacity: 0 } -} - -
-
-HTML; - }; + // Enqueues as an inline style. + wp_register_style( 'wp-interactivity-router-animations', false ); + wp_add_inline_style( 'wp-interactivity-router-animations', $this->get_router_animation_styles() ); + wp_enqueue_style( 'wp-interactivity-router-animations' ); - add_action( 'wp_footer', $callback ); + // Adds the necessary markup to the footer. + add_action( 'wp_footer', array( $this, 'print_router_loading_and_screen_reader_markup' ) ); } } @@ -859,13 +894,13 @@ private function data_wp_each_processor( WP_Interactivity_API_Directives_Process * identified and removed in the client. */ if ( - $manual_sdp || - empty( $result ) || - ! is_array( $result ) || - ! array_is_list( $result ) || - ! str_starts_with( trim( $inner_content ), '<' ) || - ! str_ends_with( trim( $inner_content ), '>' ) - ) { + $manual_sdp || + empty( $result ) || + ! is_array( $result ) || + ! array_is_list( $result ) || + ! str_starts_with( trim( $inner_content ), '<' ) || + ! str_ends_with( trim( $inner_content ), '>' ) + ) { array_pop( $tag_stack ); return; } @@ -880,12 +915,9 @@ private function data_wp_each_processor( WP_Interactivity_API_Directives_Process $processed_content = ''; foreach ( $result as $item ) { // Creates a new context that includes the current item of the array. - array_push( - $context_stack, - array_replace_recursive( - end( $context_stack ) !== false ? end( $context_stack ) : array(), - array( $namespace_value => array( $item_name => $item ) ) - ) + $context_stack[] = array_replace_recursive( + end( $context_stack ) !== false ? end( $context_stack ) : array(), + array( $namespace_value => array( $item_name => $item ) ) ); // Processes the inner content with the new context. diff --git a/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php index 32900a78f6d691..051803f3b1a1e5 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php +++ b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php @@ -4,6 +4,7 @@ * * @package WordPress * @subpackage Interactivity API + * @since 6.5.0 */ if ( ! function_exists( 'wp_interactivity_process_directives_of_interactive_blocks' ) ) { @@ -20,7 +21,7 @@ * @param array $parsed_block The parsed block. * @return array The same parsed block. */ - function wp_interactivity_process_directives_of_interactive_blocks( $parsed_block ) { + function wp_interactivity_process_directives_of_interactive_blocks( array $parsed_block ): array { static $root_interactive_block = null; /* @@ -32,41 +33,41 @@ function wp_interactivity_process_directives_of_interactive_blocks( $parsed_bloc $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); if ( - isset( $block_name ) && - ( ( isset( $block_type->supports['interactivity'] ) && true === $block_type->supports['interactivity'] ) || - ( isset( $block_type->supports['interactivity']['interactive'] ) && true === $block_type->supports['interactivity']['interactive'] ) ) + isset( $block_name ) && + ( ( isset( $block_type->supports['interactivity'] ) && true === $block_type->supports['interactivity'] ) || + ( isset( $block_type->supports['interactivity']['interactive'] ) && true === $block_type->supports['interactivity']['interactive'] ) ) ) { // Annotates the root interactive block for processing. - $root_interactive_block = array( $block_name, md5( serialize( $parsed_block ) ) ); + $root_interactive_block = array( $block_name, $parsed_block ); /* * Adds a filter to process the root interactive block once it has * finished rendering. */ - $process_interactive_blocks = static function ( $content, $parsed_block ) use ( &$root_interactive_block, &$process_interactive_blocks ) { + $process_interactive_blocks = static function ( string $content, array $parsed_block ) use ( &$root_interactive_block, &$process_interactive_blocks ): string { // Checks whether the current block is the root interactive block. - list($root_block_name, $root_block_md5) = $root_interactive_block; - if ( $root_block_name === $parsed_block['blockName'] && md5( serialize( $parsed_block ) ) === $root_block_md5 ) { + list($root_block_name, $root_parsed_block) = $root_interactive_block; + if ( $root_block_name === $parsed_block['blockName'] && $parsed_block === $root_parsed_block ) { // The root interactive blocks has finished rendering, process it. $content = wp_interactivity_process_directives( $content ); // Removes the filter and reset the root interactive block. - remove_filter( 'render_block', $process_interactive_blocks ); + remove_filter( 'render_block_' . $parsed_block['blockName'], $process_interactive_blocks ); $root_interactive_block = null; } return $content; }; /* - * Uses a priority of 20 to ensure that other filters can add additional - * directives before the processing starts. - */ - add_filter( 'render_block', $process_interactive_blocks, 20, 2 ); + * Uses a priority of 20 to ensure that other filters can add additional + * directives before the processing starts. + */ + add_filter( 'render_block_' . $block_name, $process_interactive_blocks, 20, 2 ); } } return $parsed_block; } - add_filter( 'render_block_data', 'wp_interactivity_process_directives_of_interactive_blocks', 10, 1 ); + add_filter( 'render_block_data', 'wp_interactivity_process_directives_of_interactive_blocks' ); } if ( ! function_exists( 'wp_interactivity' ) ) { @@ -74,21 +75,20 @@ function wp_interactivity_process_directives_of_interactive_blocks( $parsed_bloc * Retrieves the main WP_Interactivity_API instance. * * It provides access to the WP_Interactivity_API instance, creating one if it - * doesn't exist yet. It also registers the hooks and necessary script - * modules. + * doesn't exist yet. + * + * @global WP_Interactivity_API $wp_interactivity * * @since 6.5.0 * * @return WP_Interactivity_API The main WP_Interactivity_API instance. */ - function wp_interactivity() { - static $instance = null; - if ( is_null( $instance ) ) { - $instance = new WP_Interactivity_API(); - $instance->add_hooks(); - $instance->register_script_modules(); + function wp_interactivity(): WP_Interactivity_API { + global $wp_interactivity; + if ( ! ( $wp_interactivity instanceof WP_Interactivity_API ) ) { + $wp_interactivity = new WP_Interactivity_API(); } - return $instance; + return $wp_interactivity; } } @@ -102,7 +102,7 @@ function wp_interactivity() { * @param string $html The HTML content to process. * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags. */ - function wp_interactivity_process_directives( $html ) { + function wp_interactivity_process_directives( string $html ): string { return wp_interactivity()->process_directives( $html ); } } @@ -120,9 +120,10 @@ function wp_interactivity_process_directives( $html ) { * @param string $store_namespace The unique store namespace identifier. * @param array $state Optional. The array that will be merged with the existing state for the specified * store namespace. - * @return array The current state for the specified store namespace. + * @return array The state for the specified store namespace. This will be the updated state if a $state argument was + * provided. */ - function wp_interactivity_state( $store_namespace, $state = null ) { + function wp_interactivity_state( string $store_namespace, array $state = array() ): array { return wp_interactivity()->state( $store_namespace, $state ); } } @@ -140,9 +141,38 @@ function wp_interactivity_state( $store_namespace, $state = null ) { * @param string $store_namespace The unique store namespace identifier. * @param array $config Optional. The array that will be merged with the existing configuration for the * specified store namespace. - * @return array The current configuration for the specified store namespace. + * @return array The configuration for the specified store namespace. This will be the updated configuration if a + * $config argument was provided. + */ + function wp_interactivity_config( string $store_namespace, array $config = array() ): array { + return wp_interactivity()->config( $store_namespace, $config ); + } +} + +if ( ! function_exists( 'data_wp_context' ) ) { + /** + * Generates a `data-wp-context` directive attribute by encoding a context + * array. + * + * This helper function simplifies the creation of `data-wp-context` directives + * by providing a way to pass an array of data, which encodes into a JSON string + * safe for direct use as a HTML attribute value. + * + * Example: + * + *
true, 'count' => 0 ) ); ?>> + * + * @since 6.5.0 + * + * @param array $context The array of context data to encode. + * @param string $store_namespace Optional. The unique store namespace identifier. + * @return string A complete `data-wp-context` directive with a JSON encoded value representing the context array and + * the store namespace if specified. */ - function wp_interactivity_config( $store_namespace, $initial_state = null ) { - return wp_interactivity()->config( $store_namespace, $initial_state ); + function data_wp_context( array $context, string $store_namespace = '' ): string { + return 'data-wp-context=\'' . + ( $store_namespace ? $store_namespace . '::' : '' ) . + ( empty( $context ) ? '{}' : wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ) . + '\''; } } diff --git a/lib/experimental/interactivity-api.php b/lib/experimental/interactivity-api.php index aff57bf0bce807..cae05a84d716c1 100644 --- a/lib/experimental/interactivity-api.php +++ b/lib/experimental/interactivity-api.php @@ -14,7 +14,7 @@ function gutenberg_interactivity_override_script_module_urls( $url ) { $pattern = '/wp-includes\/js\/dist\/interactivity(-router)?(\.min)?\.js/'; if ( preg_match( $pattern, $url, $matches ) ) { - return gutenberg_url( '/build/interactivity/' . ( $matches[1] ? 'router' : 'index' ) . wp_scripts_get_suffix() . '.js' ); + return gutenberg_url( '/build/interactivity/' . ( ( isset( $matches[1] ) && $matches[1] ) ? 'router' : 'index' ) . wp_scripts_get_suffix() . '.js' ); } return $url; } diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php deleted file mode 100644 index 6536fb6cb4b365..00000000000000 --- a/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php +++ /dev/null @@ -1,703 +0,0 @@ -Text'; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertEquals( 'Text', $p->get_content_between_balanced_template_tags() ); - - $content = ''; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertEquals( 'Text', $p->get_content_between_balanced_template_tags() ); - $p->next_tag(); - $this->assertEquals( 'More text', $p->get_content_between_balanced_template_tags() ); - } - - /** - * Tests the `get_content_between_balanced_template_tags` method on an empty - * tag. - * - * @covers ::get_content_between_balanced_template_tags - */ - public function test_get_content_between_balanced_template_tags_empty_tag() { - $content = ''; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertEquals( '', $p->get_content_between_balanced_template_tags() ); - } - - /** - * Tests the `get_content_between_balanced_template_tags` method with - * non-template tags. - * - * @covers ::get_content_between_balanced_template_tags - */ - public function test_get_content_between_balanced_template_tags_self_closing_tag() { - $content = ''; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertNull( $p->get_content_between_balanced_template_tags() ); - - $content = '
Text
'; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertNull( $p->get_content_between_balanced_template_tags() ); - } - - /** - * Tests the `get_content_between_balanced_template_tags` method with nested - * template tags. - * - * @covers ::get_content_between_balanced_template_tags - */ - public function test_get_content_between_balanced_template_tags_nested_tags() { - $content = ''; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertEquals( 'ContentMore Content', $p->get_content_between_balanced_template_tags() ); - - $content = ''; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertEquals( '', $p->get_content_between_balanced_template_tags() ); - } - - /** - * Tests the `get_content_between_balanced_template_tags` method when no tags - * are present. - * - * @covers ::get_content_between_balanced_template_tags - */ - public function test_get_content_between_balanced_template_tags_no_tags() { - $content = 'Just a string with no tags.'; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertNull( $p->get_content_between_balanced_template_tags() ); - } - - /** - * Tests the `get_content_between_balanced_template_tags` method with unbalanced tags. - * - * @covers ::get_content_between_balanced_template_tags - */ - public function test_get_content_between_balanced_template_tags_with_unbalanced_tags() { - $content = '