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 new file mode 100644 index 00000000000000..b437bcefa67568 --- /dev/null +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php @@ -0,0 +1,143 @@ +get_balanced_tag_bookmarks(); + if ( ! $bookmarks ) { + 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; + + $this->seek( $start_name ); + $this->release_bookmark( $start_name ); + $this->release_bookmark( $end_name ); + + return substr( $this->html, $start, $end - $start ); + } + + /** + * Sets the content between two balanced tags. + * + * @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 { + $this->get_updated_html(); + + $bookmarks = $this->get_balanced_tag_bookmarks(); + if ( ! $bookmarks ) { + 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; + + $this->seek( $start_name ); + $this->release_bookmark( $start_name ); + $this->release_bookmark( $end_name ); + + $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $start, $end - $start, esc_html( $new_content ) ); + return true; + } + + /** + * Returns a pair of bookmarks for the current opening tag and the matching + * closing tag. + * + * @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; + + $this->set_bookmark( $start_name ); + if ( ! $this->next_balanced_closer() ) { + $this->release_bookmark( $start_name ); + return null; + } + + $end_name = 'end_of_balanced_tag_' . ++$i; + $this->set_bookmark( $end_name ); + + return array( $start_name, $end_name ); + } + + /** + * 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. + * + * @return bool Whether a matching closing tag was found. + */ + private function next_balanced_closer(): bool { + $depth = 0; + $tag_name = $this->get_tag(); + + if ( $this->is_void() ) { + return false; + } + + while ( $this->next_tag( + array( + 'tag_name' => $tag_name, + 'tag_closers' => 'visit', + ) + ) ) { + if ( ! $this->is_tag_closer() ) { + ++$depth; + continue; + } + + if ( 0 === $depth ) { + return true; + } + + --$depth; + } + + return false; + } + + /** + * Checks whether the current tag is void. + * + * @access private + * + * @return bool Whether the current tag is void or not. + */ + public function is_void(): bool { + $tag_name = $this->get_tag(); + return Gutenberg_HTML_Processor_6_5::is_void( null !== $tag_name ? $tag_name : '' ); + } + } +} 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 new file mode 100644 index 00000000000000..de1d8b2a9e7890 --- /dev/null +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php @@ -0,0 +1,671 @@ + 'data_wp_interactive_processor', + 'data-wp-context' => 'data_wp_context_processor', + 'data-wp-bind' => 'data_wp_bind_processor', + 'data-wp-class' => 'data_wp_class_processor', + 'data-wp-style' => 'data_wp_style_processor', + 'data-wp-text' => 'data_wp_text_processor', + ); + + /** + * Holds the initial state of the different Interactivity API stores. + * + * This state is used during the server directive processing. Then, it is + * serialized and sent to the client as part of the interactivity data to be + * recovered during the hydration of the client interactivity stores. + * + * @since 6.5.0 + * @var array + */ + private $state_data = array(); + + /** + * Holds the configuration required by the different Interactivity API stores. + * + * This configuration is serialized and sent to the client as part of the + * interactivity data and can be accessed by the client interactivity stores. + * + * @since 6.5.0 + * @var array + */ + private $config_data = array(); + + /** + * Gets and/or sets the initial state of an Interactivity API store for a + * given namespace. + * + * If state for that store namespace already exists, it merges the new + * provided state with the existing one. + * + * @since 6.5.0 + * + * @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. + */ + public function state( string $store_namespace, array $state = null ): array { + if ( ! isset( $this->state_data[ $store_namespace ] ) ) { + $this->state_data[ $store_namespace ] = array(); + } + if ( is_array( $state ) ) { + $this->state_data[ $store_namespace ] = array_replace_recursive( + $this->state_data[ $store_namespace ], + $state + ); + } + return $this->state_data[ $store_namespace ]; + } + + /** + * Gets and/or sets the configuration of the Interactivity API for a given + * store namespace. + * + * If configuration for that store namespace exists, it merges the new + * provided configuration with the existing one. + * + * @since 6.5.0 + * + * @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. + */ + public function config( string $store_namespace, array $config = null ): array { + if ( ! isset( $this->config_data[ $store_namespace ] ) ) { + $this->config_data[ $store_namespace ] = array(); + } + if ( is_array( $config ) ) { + $this->config_data[ $store_namespace ] = array_replace_recursive( + $this->config_data[ $store_namespace ], + $config + ); + } + return $this->config_data[ $store_namespace ]; + } + + /** + * Prints the serialized client-side interactivity data. + * + * Encodes the config and initial state into JSON and prints them inside a + * script tag of type "application/json". Once in the browser, the state will + * be parsed and used to hydrate the client-side interactivity stores and the + * configuration will be available using a `getConfig` utility. + * + * @since 6.5.0 + */ + public function print_client_interactivity_data() { + $store = array(); + $has_state = ! empty( $this->state_data ); + $has_config = ! empty( $this->config_data ); + + if ( $has_state || $has_config ) { + if ( $has_config ) { + $store['config'] = $this->config_data; + } + if ( $has_state ) { + $store['state'] = $this->state_data; + } + wp_print_inline_script_tag( + wp_json_encode( + $store, + JSON_HEX_TAG | JSON_HEX_AMP + ), + array( + 'type' => 'application/json', + 'id' => 'wp-interactivity-data', + ) + ); + } + } + + /** + * Registers the `@wordpress/interactivity` script modules. + * + * @since 6.5.0 + */ + public function register_script_modules() { + wp_register_script_module( + '@wordpress/interactivity', + gutenberg_url( '/build/interactivity/index.min.js' ), + array(), + defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) + ); + } + + /** + * Adds the necessary hooks for the Interactivity API. + * + * @since 6.5.0 + */ + public function add_hooks() { + add_action( 'wp_footer', array( $this, 'print_client_interactivity_data' ) ); + } + + /** + * Processes the interactivity directives contained within the HTML content + * and updates the markup accordingly. + * + * @since 6.5.0 + * + * @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. + */ + public function process_directives( string $html ): string { + $p = new WP_Interactivity_API_Directives_Processor( $html ); + $tag_stack = array(); + $namespace_stack = array(); + $context_stack = array(); + $unbalanced = false; + + $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 ) { + $tag_name = $p->get_tag(); + + if ( $p->is_tag_closer() ) { + list( $opening_tag_name, $directives_prefixes ) = end( $tag_stack ); + + if ( 0 === count( $tag_stack ) || $opening_tag_name !== $tag_name ) { + + /* + * If the tag stack is empty or the matching opening tag is not the + * same than the closing tag, it means the HTML is unbalanced and it + * stops processing it. + */ + $unbalanced = true; + continue; + } else { + + /* + * It removes 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 { + $directives_prefixes = array(); + + foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { + + /* + * Extracts the directive prefix to see if there is a server directive + * processor registered for that directive. + */ + list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { + $directives_prefixes[] = $directive_prefix; + } + } + + /* + * 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() ) { + $tag_stack[] = array( $tag_name, $directives_prefixes ); + } + } + + /* + * Sorts the attributes by the order of the `directives_processor` array + * and checks what directives are present in this element. The processing + * order is reversed for tag closers. + */ + $directives_prefixes = array_intersect( + $p->is_tag_closer() + ? $directive_processor_prefixes_reversed + : $directive_processor_prefixes, + $directives_prefixes + ); + + // Executes the directive processors present in this element. + foreach ( $directives_prefixes as $directive_prefix ) { + $func = is_array( self::$directive_processors[ $directive_prefix ] ) + ? self::$directive_processors[ $directive_prefix ] + : array( $this, self::$directive_processors[ $directive_prefix ] ); + call_user_func_array( + $func, + array( $p, &$context_stack, &$namespace_stack ) + ); + } + } + + /* + * It returns the original content if the HTML is unbalanced because + * unbalanced HTML is not safe to process. In that case, the Interactivity + * API runtime will update the HTML on the client side during the hydration. + */ + return $unbalanced || 0 < count( $tag_stack ) ? $html : $p->get_updated_html(); + } + + /** + * Evaluates the reference path passed to a directive based on the current + * store namespace, state and context. + * + * @since 6.5.0 + * + * @param string|true $directive_value The directive attribute value string or `true` when it's a boolean attribute. + * @param string $default_namespace The default namespace to use if none is explicitly defined in the directive + * value. + * @param array|false $context The current context for evaluating the directive or false if there is no + * context. + * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist. + */ + private function evaluate( $directive_value, string $default_namespace, $context = false ) { + list( $ns, $path ) = $this->extract_directive_value( $directive_value, $default_namespace ); + if ( empty( $path ) ) { + return null; + } + + $store = array( + 'state' => isset( $this->state_data[ $ns ] ) ? $this->state_data[ $ns ] : array(), + 'context' => isset( $context[ $ns ] ) ? $context[ $ns ] : array(), + ); + + // Checks if the reference path is preceded by a negator operator (!). + $should_negate_value = '!' === $path[0]; + $path = $should_negate_value ? substr( $path, 1 ) : $path; + + // Extracts the value from the store using the reference path. + $path_segments = explode( '.', $path ); + $current = $store; + foreach ( $path_segments as $path_segment ) { + if ( isset( $current[ $path_segment ] ) ) { + $current = $current[ $path_segment ]; + } else { + return null; + } + } + + // Returns the opposite if it contains a negator operator (!). + return $should_negate_value ? ! $current : $current; + } + + /** + * Extracts the directive attribute name to separate and return the directive + * prefix and an optional suffix. + * + * The suffix is the string after the first double hyphen and the prefix is + * everything that comes before the suffix. + * + * Example: + * + * extract_prefix_and_suffix( 'data-wp-interactive' ) => array( 'data-wp-interactive', null ) + * extract_prefix_and_suffix( 'data-wp-bind--src' ) => array( 'data-wp-bind', 'src' ) + * extract_prefix_and_suffix( 'data-wp-foo--and--bar' ) => array( 'data-wp-foo', 'and--bar' ) + * + * @since 6.5.0 + * + * @param string $directive_name The directive attribute name. + * @return array An array containing the directive prefix and optional suffix. + */ + private function extract_prefix_and_suffix( string $directive_name ): array { + return explode( '--', $directive_name, 2 ); + } + + /** + * Parses and extracts the namespace and reference path from the given + * directive attribute value. + * + * 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"), + * numbers ("1" and "1.2") or "null", the function also transform them to + * regular booleans, numbers and `null`. + * + * Example: + * + * extract_directive_value( 'actions.foo', 'myPlugin' ) => array( 'myPlugin', 'actions.foo' ) + * extract_directive_value( 'otherPlugin::actions.foo', 'myPlugin' ) => array( 'otherPlugin', 'actions.foo' ) + * extract_directive_value( '{ "isOpen": false }', 'myPlugin' ) => array( 'myPlugin', array( 'isOpen' => false ) ) + * extract_directive_value( 'otherPlugin::{ "isOpen": false }', 'myPlugin' ) => array( 'otherPlugin', array( 'isOpen' => false ) ) + * + * @since 6.5.0 + * + * @param string|true $directive_value The directive attribute value. It can be `true` when it's a boolean + * attribute. + * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined. + * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the + * second item. + */ + private function extract_directive_value( $directive_value, $default_namespace = null ): array { + if ( empty( $directive_value ) || is_bool( $directive_value ) ) { + return array( $default_namespace, null ); + } + + // Replaces the value and namespace if there is a namespace in the value. + if ( 1 === preg_match( '/^([\w\-_\/]+)::./', $directive_value ) ) { + list($default_namespace, $directive_value) = explode( '::', $directive_value, 2 ); + } + + /* + * Tries to decode the value as a JSON object. If it fails and the value + * isn't `null`, it returns the value as it is. Otherwise, it returns the + * decoded JSON or null for the string `null`. + */ + $decoded_json = json_decode( $directive_value, true ); + if ( null !== $decoded_json || 'null' === $directive_value ) { + $directive_value = $decoded_json; + } + + return array( $default_namespace, $directive_value ); + } + + + /** + * 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. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + 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 ); + } + + // 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 + * `data-wp-interactive` definition was invalid or does not contain a + * namespace. It does so because the function pops out the current namespace + * from the stack whenever it finds a `data-wp-interactive`'s closing tag, + * 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 ); + } + + /** + * 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. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + 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 ); + } + + $attribute_value = $p->get_attribute( 'data-wp-context' ); + $namespace_value = end( $namespace_stack ); + + // 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 ); + + /* + * 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() ) + ) + ); + } else { + /* + * If there is no namespace, it pushes the current context to the stack. + * 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 ) ); + } + } + + /** + * Processes the `data-wp-bind` directive. + * + * It updates or removes the bound attributes based on the evaluation of its + * associated reference. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { + if ( ! $p->is_tag_closer() ) { + $all_bind_directives = $p->get_attribute_names_with_prefix( 'data-wp-bind--' ); + + foreach ( $all_bind_directives as $attribute_name ) { + list( , $bound_attribute ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( empty( $bound_attribute ) ) { + return; + } + + $attribute_value = $p->get_attribute( $attribute_name ); + $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) ); + + if ( null !== $result && ( false !== $result || '-' === $bound_attribute[4] ) ) { + /* + * If the result of the evaluation is a boolean and the attribute is + * `aria-` or `data-, convert it to a string "true" or "false". It + * follows the exact same logic as Preact because it needs to + * replicate what Preact will later do in the client: + * https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 + */ + if ( is_bool( $result ) && '-' === $bound_attribute[4] ) { + $result = $result ? 'true' : 'false'; + } + $p->set_attribute( $bound_attribute, $result ); + } else { + $p->remove_attribute( $bound_attribute ); + } + } + } + } + + + /** + * Processes the `data-wp-class` directive. + * + * It adds or removes CSS classes in the current HTML element based on the + * evaluation of its associated references. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { + if ( ! $p->is_tag_closer() ) { + $all_class_directives = $p->get_attribute_names_with_prefix( 'data-wp-class--' ); + + foreach ( $all_class_directives as $attribute_name ) { + list( , $class_name ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( empty( $class_name ) ) { + return; + } + + $attribute_value = $p->get_attribute( $attribute_name ); + $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) ); + + if ( $result ) { + $p->add_class( $class_name ); + } else { + $p->remove_class( $class_name ); + } + } + } + } + + /** + * Processes the `data-wp-style` directive. + * + * It updates the style attribute value of the current HTML element based on + * the evaluation of its associated references. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { + if ( ! $p->is_tag_closer() ) { + $all_style_attributes = $p->get_attribute_names_with_prefix( 'data-wp-style--' ); + + foreach ( $all_style_attributes as $attribute_name ) { + list( , $style_property ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( empty( $style_property ) ) { + continue; + } + + $directive_attribute_value = $p->get_attribute( $attribute_name ); + $style_property_value = $this->evaluate( $directive_attribute_value, end( $namespace_stack ), end( $context_stack ) ); + $style_attribute_value = $p->get_attribute( 'style' ); + $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : ''; + + /* + * Checks first if the style property is not falsy and the style + * 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 the style attribute value is not empty, it sets it. Otherwise, + * it removes it. + */ + if ( ! empty( $style_attribute_value ) ) { + $p->set_attribute( 'style', $style_attribute_value ); + } else { + $p->remove_attribute( 'style' ); + } + } + } + } + } + + /** + * Sets 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. + * + * @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 ) => '' + * + * @param string $style_attribute_value The current style attribute value. + * @param string $style_property_name The style property name to set. + * @param string|false|null $style_property_value The value to set for the style property. With false, null or an + * 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 { + $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. + foreach ( $style_assignments as $style_assignment ) { + if ( empty( trim( $style_assignment ) ) ) { + continue; + } + list( $name, $value ) = explode( ':', $style_assignment ); + if ( trim( $name ) !== $style_property_name ) { + $result[] = trim( $name ) . ':' . trim( $value ) . ';'; + } + } + + // Add the new/modified property at the end of the list. + array_push( $result, $new_style_property ); + + return implode( '', $result ); + } + + /** + * Processes the `data-wp-text` directive. + * + * It updates the inner content of the current HTML element based on the + * evaluation of its associated reference. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + */ + private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) { + if ( ! $p->is_tag_closer() ) { + $attribute_value = $p->get_attribute( 'data-wp-text' ); + $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) ); + + /* + * Follows the same logic as Preact in the client and only changes the + * content if the value is a string or a number. Otherwise, it removes the + * content. + */ + if ( is_string( $result ) || is_numeric( $result ) ) { + $p->set_content_between_balanced_tags( esc_html( $result ) ); + } else { + $p->set_content_between_balanced_tags( '' ); + } + } + } + } + +} diff --git a/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php new file mode 100644 index 00000000000000..cd7ca7fb902870 --- /dev/null +++ b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php @@ -0,0 +1,144 @@ +get_registered( $block_name ); + + if ( isset( $block_name ) && isset( $block_type->supports['interactivity'] ) && $block_type->supports['interactivity'] ) { + // Annotates the root interactive block for processing. + $root_interactive_block = array( $block_name, md5( serialize( $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 ) { + // 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 ) { + // 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 ); + $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 ); + } + } + + return $parsed_block; + } + add_filter( 'render_block_data', 'wp_interactivity_process_directives_of_interactive_blocks', 10, 1 ); +} + +if ( ! function_exists( 'wp_interactivity' ) ) { + /** + * 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. + * + * @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(); + } + return $instance; + } +} + +if ( ! function_exists( 'wp_interactivity_process_directives' ) ) { + /** + * Processes the interactivity directives contained within the HTML content + * and updates the markup accordingly. + * + * @since 6.5.0 + * + * @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 ) { + return wp_interactivity()->process_directives( $html ); + } +} + +if ( ! function_exists( 'wp_interactivity_state' ) ) { + /** + * Gets and/or sets the initial state of an Interactivity API store for a + * given namespace. + * + * If state for that store namespace already exists, it merges the new + * provided state with the existing one. + * + * @since 6.5.0 + * + * @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. + */ + function wp_interactivity_state( $store_namespace, $state = null ) { + return wp_interactivity()->state( $store_namespace, $state ); + } +} + +if ( ! function_exists( 'wp_interactivity_config' ) ) { + /** + * Gets and/or sets the configuration of the Interactivity API for a given + * store namespace. + * + * If configuration for that store namespace exists, it merges the new + * provided configuration with the existing one. + * + * @since 6.5.0 + * + * @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. + */ + function wp_interactivity_config( $store_namespace, $initial_state = null ) { + return wp_interactivity()->config( $store_namespace, $initial_state ); + } +} diff --git a/lib/experimental/interactivity-api/class-wp-directive-context.php b/lib/experimental/interactivity-api/class-wp-directive-context.php deleted file mode 100644 index 4276eddca20acb..00000000000000 --- a/lib/experimental/interactivity-api/class-wp-directive-context.php +++ /dev/null @@ -1,82 +0,0 @@ - - * - *
- * - *
- * - * - */ -class WP_Directive_Context { - /** - * The stack used to store contexts internally. - * - * @var array An array of contexts. - */ - protected $stack = array( array() ); - - /** - * Constructor. - * - * Accepts a context as an argument to initialize this with. - * - * @param array $context A context. - */ - public function __construct( $context = array() ) { - $this->set_context( $context ); - } - - /** - * Return the current context. - * - * @return array The current context. - */ - public function get_context() { - return end( $this->stack ); - } - - /** - * Set the current context. - * - * @param array $context The context to be set. - * - * @return void - */ - public function set_context( $context ) { - array_push( - $this->stack, - array_replace_recursive( $this->get_context(), $context ) - ); - } - - /** - * Reset the context to its previous state. - * - * @return void - */ - public function rewind_context() { - array_pop( $this->stack ); - } -} diff --git a/lib/experimental/interactivity-api/class-wp-directive-processor.php b/lib/experimental/interactivity-api/class-wp-directive-processor.php deleted file mode 100644 index 723b36026ce2af..00000000000000 --- a/lib/experimental/interactivity-api/class-wp-directive-processor.php +++ /dev/null @@ -1,283 +0,0 @@ -get_tag(); - - if ( self::is_html_void_element( $tag_name ) ) { - return false; - } - - while ( $this->next_tag( - array( - 'tag_name' => $tag_name, - 'tag_closers' => 'visit', - ) - ) ) { - if ( ! $this->is_tag_closer() ) { - ++$depth; - continue; - } - - if ( 0 === $depth ) { - return true; - } - - --$depth; - } - - return false; - } - - /** - * Returns the content between two balanced tags. - * - * When called on an opening tag, return the HTML content found between that - * opening tag and its matching closing tag. - * - * @return string The content between the current opening and its matching - * closing tag. - */ - public function get_inner_html() { - $bookmarks = $this->get_balanced_tag_bookmarks(); - if ( ! $bookmarks ) { - 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; - - $this->seek( $start_name ); // Return to original position. - $this->release_bookmark( $start_name ); - $this->release_bookmark( $end_name ); - - return substr( $this->html, $start, $end - $start ); - } - - /** - * Sets the content between two balanced tags. - * - * When called on an opening tag, set the HTML content found between that - * opening tag and its matching closing tag. - * - * @param string $new_html The string to replace the content between the - * matching tags with. - * @return bool Whether the content was successfully replaced. - */ - public function set_inner_html( $new_html ) { - $this->get_updated_html(); // Apply potential previous updates. - - $bookmarks = $this->get_balanced_tag_bookmarks(); - if ( ! $bookmarks ) { - 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; - - $this->seek( $start_name ); // Return to original position. - $this->release_bookmark( $start_name ); - $this->release_bookmark( $end_name ); - - $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $start, $end - $start, $new_html ); - return true; - } - - /** - * Returns a pair of bookmarks for the current opening tag and the matching - * closing tag. - * - * @return array|false A pair of bookmarks, or false if there's no matching - * closing tag. - */ - public function get_balanced_tag_bookmarks() { - $i = 0; - while ( array_key_exists( 'start' . $i, $this->bookmarks ) ) { - ++$i; - } - $start_name = 'start' . $i; - - $this->set_bookmark( $start_name ); - if ( ! $this->next_balanced_closer() ) { - $this->release_bookmark( $start_name ); - return false; - } - - $i = 0; - while ( array_key_exists( 'end' . $i, $this->bookmarks ) ) { - ++$i; - } - $end_name = 'end' . $i; - $this->set_bookmark( $end_name ); - - return array( $start_name, $end_name ); - } - - /** - * Checks whether a given HTML element is void (e.g.
). - * - * @see https://html.spec.whatwg.org/#elements-2 - * - * @param string $tag_name The element in question. - * @return bool True if the element is void. - */ - public static function is_html_void_element( $tag_name ) { - switch ( $tag_name ) { - case 'AREA': - case 'BASE': - case 'BR': - case 'COL': - case 'EMBED': - case 'HR': - case 'IMG': - case 'INPUT': - case 'LINK': - case 'META': - case 'SOURCE': - case 'TRACK': - case 'WBR': - return true; - - default: - return false; - } - } - - /** - * Extracts and return the directive type and the the part after the double - * hyphen from an attribute name (if present), in an array format. - * - * Examples: - * - * 'wp-island' => array( 'wp-island', null ) - * 'wp-bind--src' => array( 'wp-bind', 'src' ) - * 'wp-thing--and--thang' => array( 'wp-thing', 'and--thang' ) - * - * @param string $name The attribute name. - * @return array The resulting array. - */ - public static function parse_attribute_name( $name ) { - return explode( '--', $name, 2 ); - } - - /** - * Parse and extract the namespace and path from the given value. - * - * If the value contains a JSON instead of a path, the function parses it - * and returns the resulting array. - * - * @param string $value Passed value. - * @param string $ns Namespace fallback. - * @return array The resulting array - */ - public static function parse_attribute_value( $value, $ns = null ) { - $matches = array(); - $has_ns = preg_match( '/^([\w\-_\/]+)::(.+)$/', $value, $matches ); - - /* - * Overwrite both `$ns` and `$value` variables if `$value` explicitly - * contains a namespace. - */ - if ( $has_ns ) { - list( , $ns, $value ) = $matches; - } - - /* - * Try to decode `$value` as a JSON object. If it works, `$value` is - * replaced with the resulting array. The original string is preserved - * otherwise. - * - * Note that `json_decode` returns `null` both for an invalid JSON or - * the `'null'` string (a valid JSON). In the latter case, `$value` is - * replaced with `null`. - */ - $data = json_decode( $value, true ); - if ( null !== $data || 'null' === trim( $value ) ) { - $value = $data; - } - - return array( $ns, $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 deleted file mode 100644 index 15e57edfa4a6a2..00000000000000 --- a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php +++ /dev/null @@ -1,82 +0,0 @@ -%s', - wp_json_encode( self::$initial_state, JSON_HEX_TAG | JSON_HEX_AMP ) - ); - } -} diff --git a/lib/experimental/interactivity-api/directive-processing.php b/lib/experimental/interactivity-api/directive-processing.php deleted file mode 100644 index 5a97166d6d22bf..00000000000000 --- a/lib/experimental/interactivity-api/directive-processing.php +++ /dev/null @@ -1,214 +0,0 @@ -get_registered( $parsed_block['blockName'] ); - $is_interactive = isset( $block_type->supports['interactivity'] ) && $block_type->supports['interactivity']; - if ( $is_interactive ) { - WP_Directive_Processor::mark_interactive_root_block( $parsed_block ); - } - } - - return $parsed_block; -} -add_filter( 'render_block_data', 'gutenberg_interactivity_mark_root_interactive_blocks', 10, 1 ); - -/** - * Processes the directives in the root blocks. - * - * @param string $block_content The block content. - * @param array $block The full block. - * - * @return string Filtered block content. - */ -function gutenberg_process_directives_in_root_blocks( $block_content, $block ) { - if ( WP_Directive_Processor::is_marked_as_interactive_root_block( $block ) ) { - WP_Directive_Processor::unmark_interactive_root_block(); - $context = new WP_Directive_Context(); - $namespace_stack = array(); - return gutenberg_process_interactive_html( $block_content, $context, $namespace_stack ); - } - - return $block_content; -} -add_filter( 'render_block', 'gutenberg_process_directives_in_root_blocks', 10, 2 ); - -/** - * Processes interactive HTML by applying directives to the HTML tags. - * - * It uses the WP_Directive_Processor class to parse the HTML and apply the - * directives. If a tag contains a 'WP-INNER-BLOCKS' string and there are inner - * blocks to process, the function processes these inner blocks and replaces the - * 'WP-INNER-BLOCKS' tag in the HTML with those blocks. - * - * @param string $html The HTML to process. - * @param mixed $context The context to use when processing. - * @param array $inner_blocks The inner blocks to process. - * @param array $namespace_stack Stack of namespackes passed by reference. - * - * @return string The processed HTML. - */ -function gutenberg_process_interactive_html( $html, $context, &$namespace_stack = array() ) { - static $directives = array( - '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( $html ); - $prefix = 'data-wp-'; - $tag_stack = array(); - while ( $tags->next_tag( array( 'tag_closers' => 'visit' ) ) ) { - $tag_name = $tags->get_tag(); - - if ( $tags->is_tag_closer() ) { - if ( 0 === count( $tag_stack ) ) { - continue; - } - list( $latest_opening_tag_name, $attributes ) = end( $tag_stack ); - if ( $latest_opening_tag_name === $tag_name ) { - array_pop( $tag_stack ); - // If the matching opening tag didn't have any directives, we move on. - if ( 0 === count( $attributes ) ) { - continue; - } - } - } else { - $attributes = array(); - foreach ( $tags->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" - * from "wp-bind--src" and "wp-context" from "wp-context" etc... - */ - list( $type ) = $tags::parse_attribute_name( $name ); - if ( array_key_exists( $type, $directives ) ) { - $attributes[] = $type; - } - } - - /* - * If this is an open tag, and if it either has directives, or if - * we're inside a tag that does, take note of this tag and its - * directives so we can call its directive processor once we - * encounter the matching closing tag. - */ - if ( - ! $tags::is_html_void_element( $tag_name ) && - ( 0 !== count( $attributes ) || 0 !== count( $tag_stack ) ) - ) { - $tag_stack[] = array( $tag_name, $attributes ); - } - } - - // Extract all directive names. They'll be used later on. - $directive_names = array_keys( $directives ); - $directive_names_rev = array_reverse( $directive_names ); - - /* - * 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( - $tags->is_tag_closer() - ? $directive_names_rev - : $directive_names, - $attributes - ); - - foreach ( $sorted_attrs as $attribute ) { - call_user_func_array( - $directives[ $attribute ], - array( - $tags, - $context, - end( $namespace_stack ), - &$namespace_stack, - ) - ); - } - } - - return $tags->get_updated_html(); -} - -/** - * Resolves the passed reference from the store and the context under the given - * namespace. - * - * A reference could be either a single path or a namespace followed by a path, - * separated by two colons, i.e, `namespace::path.to.prop`. If the reference - * contains a namespace, that namespace overrides the one passed as argument. - * - * @param string $reference Reference value. - * @param string $ns Inherited namespace. - * @param array $context Context data. - * @return mixed Resolved value. - */ -function gutenberg_interactivity_evaluate_reference( $reference, $ns, array $context = array() ) { - // Extract the namespace from the reference (if present). - list( $ns, $path ) = WP_Directive_Processor::parse_attribute_value( $reference, $ns ); - - $store = array( - 'state' => WP_Interactivity_Initial_State::get_state( $ns ), - 'context' => $context[ $ns ] ?? array(), - ); - - /* - * Checks first if the directive path is preceded by a negator operator (!), - * indicating that the value obtained from the Interactivity Store (or the - * 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; - foreach ( $path_segments as $p ) { - if ( isset( $current[ $p ] ) ) { - $current = $current[ $p ]; - } else { - return null; - } - } - - /* - * Checks if $current is an anonymous function or an arrow function, and if - * so, call it passing the store. Other types of callables are ignored on - * purpose, as arbitrary strings or arrays could be wrongly evaluated as - * "callables". - * - * E.g., "file" is an string and a "callable" (the "file" function exists). - */ - if ( $current instanceof Closure ) { - /* - * TODO: Figure out a way to implement derived state without having to - * pass the store as argument: - * - * $current = call_user_func( $current ); - */ - } - - // Returns the opposite if it has a negator operator (!). - return $should_negate_value ? ! $current : $current; -} diff --git a/lib/experimental/interactivity-api/directives/wp-bind.php b/lib/experimental/interactivity-api/directives/wp-bind.php deleted file mode 100644 index 57d2e5deb23ab4..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-bind.php +++ /dev/null @@ -1,33 +0,0 @@ -is_tag_closer() ) { - return; - } - - $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-bind--' ); - - foreach ( $prefixed_attributes as $attr ) { - list( , $bound_attr ) = WP_Directive_Processor::parse_attribute_name( $attr ); - if ( empty( $bound_attr ) ) { - continue; - } - - $reference = $tags->get_attribute( $attr ); - $value = gutenberg_interactivity_evaluate_reference( $reference, $ns, $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 deleted file mode 100644 index ef91835be86fc1..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-class.php +++ /dev/null @@ -1,37 +0,0 @@ -is_tag_closer() ) { - return; - } - - $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-class--' ); - - foreach ( $prefixed_attributes as $attr ) { - list( , $class_name ) = WP_Directive_Processor::parse_attribute_name( $attr ); - if ( empty( $class_name ) ) { - continue; - } - - $reference = $tags->get_attribute( $attr ); - $add_class = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() ); - if ( $add_class ) { - $tags->add_class( $class_name ); - } else { - $tags->remove_class( $class_name ); - } - } -} diff --git a/lib/experimental/interactivity-api/directives/wp-context.php b/lib/experimental/interactivity-api/directives/wp-context.php deleted file mode 100644 index b41b47c86c78c3..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-context.php +++ /dev/null @@ -1,30 +0,0 @@ -is_tag_closer() ) { - $context->rewind_context(); - return; - } - - $attr_value = $tags->get_attribute( 'data-wp-context' ); - - //Separate namespace and value from the context directive attribute. - list( $ns, $data ) = is_string( $attr_value ) && ! empty( $attr_value ) - ? WP_Directive_Processor::parse_attribute_value( $attr_value, $ns ) - : array( $ns, null ); - - // Add parsed data to the context under the corresponding namespace. - $context->set_context( array( $ns => is_array( $data ) ? $data : array() ) ); -} diff --git a/lib/experimental/interactivity-api/directives/wp-interactive.php b/lib/experimental/interactivity-api/directives/wp-interactive.php deleted file mode 100644 index 9f3471a8b4e6a9..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-interactive.php +++ /dev/null @@ -1,44 +0,0 @@ -is_tag_closer() ) { - array_pop( $ns_stack ); - return; - } - - /* - * Decode the data-wp-interactive attribute. In the case it is not a valid - * JSON string, NULL is stored in `$island_data`. - */ - $island = $tags->get_attribute( 'data-wp-interactive' ); - $island_data = is_string( $island ) && ! empty( $island ) - ? json_decode( $island, true ) - : null; - - /* - * Push the newly defined namespace, or the current one if the island - * definition was invalid or does not contain a namespace. - * - * This is done because the function pops out the current namespace from the - * stack whenever it finds an island's closing tag, independently of whether - * the island definition was correct or it contained a valid namespace. - */ - $ns_stack[] = isset( $island_data ) && $island_data['namespace'] - ? $island_data['namespace'] - : $ns; -} diff --git a/lib/experimental/interactivity-api/directives/wp-style.php b/lib/experimental/interactivity-api/directives/wp-style.php deleted file mode 100644 index 16432e57282606..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-style.php +++ /dev/null @@ -1,73 +0,0 @@ -is_tag_closer() ) { - return; - } - - $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-style--' ); - - foreach ( $prefixed_attributes as $attr ) { - list( , $style_name ) = WP_Directive_Processor::parse_attribute_name( $attr ); - if ( empty( $style_name ) ) { - continue; - } - - $reference = $tags->get_attribute( $attr ); - $style_value = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() ); - if ( $style_value ) { - $style_attr = $tags->get_attribute( 'style' ) ?? ''; - $style_attr = gutenberg_interactivity_set_style( $style_attr, $style_name, $style_value ); - $tags->set_attribute( 'style', $style_attr ); - } else { - // TODO: Do we want to unset styles if they're null? - } - } -} - -/** - * Set style. - * - * @param string $style Existing style to amend. - * @param string $name Style property name. - * @param string $value Style property value. - * @return string Amended styles. - */ -function gutenberg_interactivity_set_style( $style, $name, $value ) { - $style_assignments = explode( ';', $style ); - $modified = false; - foreach ( $style_assignments as $style_assignment ) { - list( $style_name ) = explode( ':', $style_assignment ); - if ( trim( $style_name ) === $name ) { - // TODO: Retain surrounding whitespace from $style_value, if any. - $style_assignment = $style_name . ': ' . $value; - $modified = true; - break; - } - } - - if ( ! $modified ) { - $new_style_assignment = $name . ': ' . $value; - // If the last element is empty or whitespace-only, we insert - // the new "key: value" pair before it. - if ( empty( trim( end( $style_assignments ) ) ) ) { - array_splice( $style_assignments, - 1, 0, $new_style_assignment ); - } else { - array_push( $style_assignments, $new_style_assignment ); - } - } - return implode( ';', $style_assignments ); -} diff --git a/lib/experimental/interactivity-api/directives/wp-text.php b/lib/experimental/interactivity-api/directives/wp-text.php deleted file mode 100644 index c4c5bb27a31e10..00000000000000 --- a/lib/experimental/interactivity-api/directives/wp-text.php +++ /dev/null @@ -1,28 +0,0 @@ -is_tag_closer() ) { - return; - } - - $value = $tags->get_attribute( 'data-wp-text' ); - if ( null === $value ) { - return; - } - - $text = gutenberg_interactivity_evaluate_reference( $value, $ns, $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 deleted file mode 100644 index a38d0da631f3c4..00000000000000 --- a/lib/experimental/interactivity-api/initial-state.php +++ /dev/null @@ -1,29 +0,0 @@ - + HTML; diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index c1879af1fe19a6..250d3bde6084c9 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -177,9 +177,11 @@ export default () => { : name; useInit( () => { - // This seems necessary because Preact doesn't change the class - // names on the hydration, so we have to do it manually. It doesn't - // need deps because it only needs to do it the first time. + /* + * This seems necessary because Preact doesn't change the class + * names on the hydration, so we have to do it manually. It doesn't + * need deps because it only needs to do it the first time. + */ if ( ! result ) { element.ref.current.classList.remove( name ); } else { @@ -206,9 +208,11 @@ export default () => { else element.props.style[ key ] = result; useInit( () => { - // This seems necessary because Preact doesn't change the styles on - // the hydration, so we have to do it manually. It doesn't need deps - // because it only needs to do it the first time. + /* + * This seems necessary because Preact doesn't change the styles on + * the hydration, so we have to do it manually. It doesn't need deps + * because it only needs to do it the first time. + */ if ( ! result ) { element.ref.current.style.removeProperty( key ); } else { @@ -226,24 +230,36 @@ export default () => { const result = evaluate( entry ); element.props[ attribute ] = result; - // This seems necessary because Preact doesn't change the attributes - // on the hydration, so we have to do it manually. It doesn't need - // deps because it only needs to do it the first time. + /* + * This is necessary because Preact doesn't change the attributes on the + * hydration, so we have to do it manually. It only needs to do it the + * first time. After that, Preact will handle the changes. + */ useInit( () => { const el = element.ref.current; - // We set the value directly to the corresponding - // HTMLElement instance property excluding the following - // special cases. - // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 + /* + * We set the value directly to the corresponding HTMLElement instance + * property excluding the following special cases. We follow Preact's + * logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 + */ if ( attribute !== 'width' && attribute !== 'height' && attribute !== 'href' && attribute !== 'list' && attribute !== 'form' && - // Default value in browsers is `-1` and an empty string is - // cast to `0` instead + /* + * The value for `tabindex` follows the parsing rules for an + * integer. If that fails, or if the attribute isn't present, then + * the browsers should "follow platform conventions to determine if + * the element should be considered as a focusable area", + * practically meaning that most elements get a default of `-1` (not + * focusable), but several also get a default of `0` (focusable in + * order after all elements with a positive `tabindex` value). + * + * @see https://html.spec.whatwg.org/#tabindex-value + */ attribute !== 'tabIndex' && attribute !== 'download' && attribute !== 'rowSpan' && @@ -259,10 +275,12 @@ export default () => { return; } catch ( err ) {} } - // aria- and data- attributes have no boolean representation. - // A `false` value is different from the attribute not being - // present, so we can't remove it. - // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 + /* + * aria- and data- attributes have no boolean representation. + * A `false` value is different from the attribute not being + * present, so we can't remove it. + * We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 + */ if ( result !== null && result !== undefined && diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index 8463d1a0a51323..5177c72cfda462 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -36,12 +36,12 @@ const deepMerge = ( target: any, source: any ) => { const parseInitialState = () => { const storeTag = document.querySelector( - `script[type="application/json"]#wp-interactivity-initial-state` + `script[type="application/json"]#wp-interactivity-data` ); if ( ! storeTag?.textContent ) return {}; try { - const initialState = JSON.parse( storeTag.textContent ); - if ( isObject( initialState ) ) return initialState; + const { state } = JSON.parse( storeTag.textContent ); + if ( isObject( state ) ) return state; throw Error( 'Parsed state is not an object' ); } catch ( e ) { // eslint-disable-next-line no-console diff --git a/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php b/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php deleted file mode 100644 index 2b01cb6251c210..00000000000000 --- a/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php +++ /dev/null @@ -1,132 +0,0 @@ -outside
inside
'; - - public function test_next_balanced_closer_stays_on_void_tag() { - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'img' ); - $result = $tags->next_balanced_closer(); - $this->assertSame( 'IMG', $tags->get_tag() ); - $this->assertFalse( $result ); - } - - public function test_next_balanced_closer_proceeds_to_correct_tag() { - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'section' ); - $tags->next_balanced_closer(); - $this->assertSame( 'SECTION', $tags->get_tag() ); - $this->assertTrue( $tags->is_tag_closer() ); - } - - public function test_next_balanced_closer_proceeds_to_correct_tag_for_nested_tag() { - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'div' ); - $tags->next_tag( 'div' ); - $tags->next_balanced_closer(); - $this->assertSame( 'DIV', $tags->get_tag() ); - $this->assertTrue( $tags->is_tag_closer() ); - } - - public function test_get_inner_html_returns_correct_result() { - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'section' ); - $this->assertSame( '
inside
', $tags->get_inner_html() ); - } - - public function test_set_inner_html_on_void_element_has_no_effect() { - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'img' ); - $content = $tags->set_inner_html( 'This is the new img content' ); - $this->assertFalse( $content ); - $this->assertSame( self::HTML, $tags->get_updated_html() ); - } - - public function test_set_inner_html_sets_content_correctly() { - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'section' ); - $tags->set_inner_html( 'This is the new section content.' ); - $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); - } - - public function test_set_inner_html_updates_bookmarks_correctly() { - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'div' ); - $tags->set_bookmark( 'start' ); - $tags->next_tag( 'img' ); - $this->assertSame( 'IMG', $tags->get_tag() ); - $tags->set_bookmark( 'after' ); - $tags->seek( 'start' ); - - $tags->set_inner_html( 'This is the new div content.' ); - $this->assertSame( '
This is the new div content.
inside
', $tags->get_updated_html() ); - $tags->seek( 'after' ); - $this->assertSame( 'IMG', $tags->get_tag() ); - } - - public function test_set_inner_html_subsequent_updates_on_the_same_tag_work() { - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'section' ); - $tags->set_inner_html( 'This is the new section content.' ); - $tags->set_inner_html( 'This is the even newer section content.' ); - $this->assertSame( '
outside
This is the even newer section content.
', $tags->get_updated_html() ); - } - - public function test_set_inner_html_followed_by_set_attribute_works() { - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'section' ); - $tags->set_inner_html( 'This is the new section content.' ); - $tags->set_attribute( 'id', 'thesection' ); - $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); - } - - public function test_set_inner_html_preceded_by_set_attribute_works() { - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'section' ); - $tags->set_attribute( 'id', 'thesection' ); - $tags->set_inner_html( 'This is the new section content.' ); - $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); - } - - /** - * TODO: Review this, how that the code is in Gutenberg. - */ - public function test_set_inner_html_invalidates_bookmarks_that_point_to_replaced_content() { - $this->markTestSkipped( "This requires on bookmark invalidation, which is only in GB's WP 6.3 compat layer." ); - - $tags = new WP_Directive_Processor( self::HTML ); - - $tags->next_tag( 'section' ); - $tags->set_bookmark( 'start' ); - $tags->next_tag( 'img' ); - $tags->set_bookmark( 'replaced' ); - $tags->seek( 'start' ); - - $tags->set_inner_html( 'This is the new section content.' ); - $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); - - $this->expectExceptionMessage( 'Invalid bookmark name' ); - $successful_seek = $tags->seek( 'replaced' ); - $this->assertFalse( $successful_seek ); - } -} diff --git a/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php b/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php deleted file mode 100644 index a95c3482ec80d1..00000000000000 --- a/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php +++ /dev/null @@ -1,115 +0,0 @@ -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: ' . - '' . - '

Welcome

'; - - $interactive_parsed_block = parse_blocks( $block_content )[0]; - - $rendered_content = render_block( $interactive_parsed_block ); - $parsed_block_second = parse_blocks( $block_content )[1]; - $non_interactive_root_block = parse_blocks( $block_content )[2]; - - // Test that root block is intially empty. - $this->assertEmpty( WP_Directive_Processor::$interactive_root_block ); - - // Test that root block is not added if it is non interactive. - gutenberg_interactivity_mark_root_interactive_blocks( $non_interactive_root_block ); - $this->assertEmpty( WP_Directive_Processor::$interactive_root_block ); - - // Test that a non root block is added if it is interactive. - gutenberg_interactivity_mark_root_interactive_blocks( $interactive_parsed_block ); - $this->assertNotEmpty( WP_Directive_Processor::$interactive_root_block ); - - // Test that an interactive block is not added if it has in interactive ancestor. - $current_root_block = WP_Directive_Processor::$interactive_root_block; - gutenberg_interactivity_mark_root_interactive_blocks( $parsed_block_second ); - $this->assertSame( $current_root_block, WP_Directive_Processor::$interactive_root_block ); - - // Test that root block is removed after processing. - gutenberg_process_directives_in_root_blocks( $rendered_content, $interactive_parsed_block ); - $this->assertEmpty( WP_Directive_Processor::$interactive_root_block ); - } - - public function test_directive_processing_of_interactive_block() { - $post_content = ''; - $rendered_blocks = do_blocks( $post_content ); - $p = new WP_HTML_Tag_Processor( $rendered_blocks ); - $p->next_tag( array( 'class_name' => 'level-1-input-1' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - $p->next_tag( array( 'class_name' => 'level-1-input-2' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - } - - public function test_directive_processing_two_interactive_blocks_at_same_level() { - $post_content = '
'; - $rendered_blocks = do_blocks( $post_content ); - $p = new WP_HTML_Tag_Processor( $rendered_blocks ); - $p->next_tag( array( 'class_name' => 'level-1-input-1' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - $p->next_tag( array( 'class_name' => 'level-1-input-2' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - $p->next_tag( array( 'class_name' => 'level-2-input-1' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-2', $value ); - } - - public function test_directives_are_processed_at_tag_end() { - $post_content = ''; - $rendered_blocks = do_blocks( $post_content ); - $p = new WP_HTML_Tag_Processor( $rendered_blocks ); - $p->next_tag( array( 'class_name' => 'level-1-input-1' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - $p->next_tag( array( 'class_name' => 'level-2-input-1' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-2', $value ); - $p->next_tag( array( 'class_name' => 'read-only-input-1' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - $p->next_tag( array( 'class_name' => 'level-1-input-2' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - } - - public function test_non_interactive_children_of_interactive_is_rendered() { - $post_content = '

Welcome

'; - $rendered_blocks = do_blocks( $post_content ); - $p = new WP_HTML_Tag_Processor( $rendered_blocks ); - $p->next_tag( array( 'class_name' => 'level-1-input-1' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - $p->next_tag( array( 'class_name' => 'read-only-input-1' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - $p->next_tag(); - $this->assertSame( 'P', $p->get_tag() ); - $p->next_tag( array( 'class_name' => 'level-1-input-2' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - } - - public function test_non_interactive_blocks_are_not_processed() { - $post_content = ''; - $rendered_blocks = do_blocks( $post_content ); - $p = new WP_HTML_Tag_Processor( $rendered_blocks ); - $p->next_tag( array( 'class_name' => 'non-interactive-with-directive' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( null, $value ); - } - - public function test_non_interactive_blocks_with_interactive_ancestor_are_processed() { - $post_content = ''; - $rendered_blocks = do_blocks( $post_content ); - $p = new WP_HTML_Tag_Processor( $rendered_blocks ); - $p->next_tag( array( 'class_name' => 'non-interactive-with-directive' ) ); - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'level-1', $value ); - } - - public function test_directives_ordering() { - $post_content = ''; - $rendered_blocks = do_blocks( $post_content ); - $p = new WP_HTML_Tag_Processor( $rendered_blocks ); - $p->next_tag(); - - $value = $p->get_attribute( 'class' ); - $this->assertSame( 'other-class some-class', $value ); - - $value = $p->get_attribute( 'value' ); - $this->assertSame( 'some-value', $value ); - - $value = $p->get_attribute( 'style' ); - $this->assertSame( 'display: none;', $value ); - } - - public function test_evaluate_function_should_access_state() { - // Init a simple store. - wp_initial_state( - 'test', - array( - 'number' => 1, - 'bool' => true, - 'nested' => array( - 'string' => 'hi', - ), - ) - ); - - $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.number', 'test' ) ); - $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.bool', 'test' ) ); - $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.nested.string', 'test' ) ); - $this->assertFalse( gutenberg_interactivity_evaluate_reference( '!state.bool', 'test' ) ); - } - - public function test_evaluate_function_should_access_passed_context() { - wp_initial_state( - 'test', - array( - 'number' => 1, - 'bool' => true, - 'nested' => array( - 'string' => 'hi', - ), - ) - ); - - $context = array( - 'test' => array( - 'number' => 2, - 'bool' => false, - 'nested' => array( - 'string' => 'bye', - ), - ), - ); - - $this->assertSame( 2, gutenberg_interactivity_evaluate_reference( 'context.number', 'test', $context ) ); - $this->assertFalse( gutenberg_interactivity_evaluate_reference( 'context.bool', 'test', $context ) ); - $this->assertTrue( gutenberg_interactivity_evaluate_reference( '!context.bool', 'test', $context ) ); - $this->assertSame( 'bye', gutenberg_interactivity_evaluate_reference( 'context.nested.string', 'test', $context ) ); - - // Defined state is also accessible. - $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.number', 'test' ) ); - $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.bool', 'test' ) ); - $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.nested.string', 'test' ) ); - } - - public function test_evaluate_function_should_return_null_for_unresolved_paths() { - $this->assertNull( gutenberg_interactivity_evaluate_reference( 'this.property.doesnt.exist', 'myblock' ) ); - } - - public function test_evaluate_function_should_execute_anonymous_functions() { - $this->markTestSkipped( 'Derived state was supported for `wp_store()` but not for `wp_initial_state()` yet.' ); - - $context = new WP_Directive_Context( array( 'myblock' => array( 'count' => 2 ) ) ); - - wp_initial_state( - 'myblock', - array( - 'count' => 3, - 'anonymous_function' => function ( $store ) { - return $store['state']['count'] + $store['context']['count']; - }, - // Other types of callables should not be executed. - 'function_name' => 'gutenberg_test_process_directives_helper_increment', - 'class_method' => array( $this, 'increment' ), - 'class_static_method' => array( 'Tests_Process_Directives', 'static_increment' ), - ) - ); - - $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'state.anonymous_function', 'myblock', $context->get_context() ) ); - $this->assertSame( - 'gutenberg_test_process_directives_helper_increment', - gutenberg_interactivity_evaluate_reference( 'state.function_name', 'myblock', $context->get_context() ) - ); - $this->assertSame( - array( $this, 'increment' ), - gutenberg_interactivity_evaluate_reference( 'state.class_method', 'myblock', $context->get_context() ) - ); - $this->assertSame( - array( 'Tests_Process_Directives', 'static_increment' ), - gutenberg_interactivity_evaluate_reference( 'state.class_static_method', 'myblock', $context->get_context() ) - ); - } - - public function test_namespace_should_be_inherited_from_ancestor() { - /* - * This function call should be done inside block render functions. We - * run it here instead just for conveninence. - */ - wp_initial_state( 'test-1', array( 'text' => 'state' ) ); - - $post_content = ' - - - - - - - '; - - $html = do_blocks( $post_content ); - $tags = new WP_HTML_Tag_Processor( $html ); - - $tags->next_tag( array( 'class_name' => 'bind-state' ) ); - $this->assertSame( 'state', $tags->get_attribute( 'data-value' ) ); - - $tags->next_tag( array( 'class_name' => 'bind-context' ) ); - $this->assertSame( 'context', $tags->get_attribute( 'data-value' ) ); - } - - public function test_namespace_should_be_inherited_from_same_element() { - /* - * This function call should be done inside block render functions. We - * run it here instead just for conveninence. - */ - wp_initial_state( 'test-2', array( 'text' => 'state-2' ) ); - - $post_content = ' - - - - - - - '; - - $html = do_blocks( $post_content ); - $tags = new WP_HTML_Tag_Processor( $html ); - - $tags->next_tag( array( 'class_name' => 'bind-state' ) ); - $this->assertSame( 'state-2', $tags->get_attribute( 'data-value' ) ); - - $tags->next_tag( array( 'class_name' => 'bind-context' ) ); - $this->assertSame( 'context-2', $tags->get_attribute( 'data-value' ) ); - } - - public function test_namespace_should_not_leak_from_descendant() { - /* - * This function call should be done inside block render functions. We - * run it here instead just for conveninence. - */ - wp_initial_state( 'test-1', array( 'text' => 'state-1' ) ); - wp_initial_state( 'test-2', array( 'text' => 'state-2' ) ); - - $post_content = ' - - - - - - '; - - $html = do_blocks( $post_content ); - $tags = new WP_HTML_Tag_Processor( $html ); - - $tags->next_tag( array( 'class_name' => 'target' ) ); - $this->assertSame( 'state-1', $tags->get_attribute( 'data-state' ) ); - $this->assertSame( 'context-1', $tags->get_attribute( 'data-context' ) ); - } - - public function test_namespace_should_not_leak_from_sibling() { - /* - * This function call should be done inside block render functions. We - * run it here instead just for conveninence. - */ - wp_initial_state( 'test-1', array( 'text' => 'state-1' ) ); - wp_initial_state( 'test-2', array( 'text' => 'state-2' ) ); - - $post_content = ' - - - - - - - '; - - $html = do_blocks( $post_content ); - $tags = new WP_HTML_Tag_Processor( $html ); - - $tags->next_tag( array( 'class_name' => 'target' ) ); - $this->assertSame( 'state-1', $tags->get_attribute( 'data-from-state' ) ); - $this->assertSame( 'context-1', $tags->get_attribute( 'data-from-context' ) ); - } - - public function test_namespace_can_be_overwritten_in_directives() { - /* - * This function call should be done inside block render functions. We - * run it here instead just for conveninence. - */ - wp_initial_state( 'test-1', array( 'text' => 'state-1' ) ); - wp_initial_state( 'test-2', array( 'text' => 'state-2' ) ); - - $post_content = ' - - - - - - '; - - $html = do_blocks( $post_content ); - $tags = new WP_HTML_Tag_Processor( $html ); - - $tags->next_tag( array( 'class_name' => 'inherited-ns' ) ); - $this->assertSame( 'state-1', $tags->get_attribute( 'data-value' ) ); - - $tags->next_tag( array( 'class_name' => 'custom-ns' ) ); - $this->assertSame( 'state-2', $tags->get_attribute( 'data-value' ) ); - - $tags->next_tag( array( 'class_name' => 'mixed-ns' ) ); - $this->assertSame( 'state-1', $tags->get_attribute( 'data-inherited-ns' ) ); - $this->assertSame( 'state-2', $tags->get_attribute( 'data-custom-ns' ) ); - } -} diff --git a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php deleted file mode 100644 index 8fe212bb8ed93a..00000000000000 --- a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php +++ /dev/null @@ -1,48 +0,0 @@ -'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); - $context = $context_before; - $directive_ns = 'myblock'; - gutenberg_interactivity_process_wp_bind( $tags, $context, $directive_ns ); - - $this->assertSame( - '', - $tags->get_updated_html() - ); - $this->assertSame( './wordpress.png', $tags->get_attribute( 'src' ) ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' ); - } - - public function test_directive_ignores_empty_bound_attribute() { - $markup = ''; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); - $context = $context_before; - $directive_ns = 'myblock'; - gutenberg_interactivity_process_wp_bind( $tags, $context, $directive_ns ); - - $this->assertSame( $markup, $tags->get_updated_html() ); - $this->assertNull( $tags->get_attribute( 'src' ) ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' ); - } -} diff --git a/phpunit/experimental/interactivity-api/directives/wp-class-test.php b/phpunit/experimental/interactivity-api/directives/wp-class-test.php deleted file mode 100644 index f40486647ff8b8..00000000000000 --- a/phpunit/experimental/interactivity-api/directives/wp-class-test.php +++ /dev/null @@ -1,103 +0,0 @@ -Test'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); - $context = $context_before; - $directive_ns = 'myblock'; - gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns ); - - $this->assertSame( - '
Test
', - $tags->get_updated_html() - ); - $this->assertStringContainsString( 'red', $tags->get_attribute( 'class' ) ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); - } - - public function test_directive_removes_class() { - $markup = '
Test
'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); - $context = $context_before; - $directive_ns = 'myblock'; - gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns ); - - $this->assertSame( - '
Test
', - $tags->get_updated_html() - ); - $this->assertStringNotContainsString( 'blue', $tags->get_attribute( 'class' ) ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); - } - - public function test_directive_removes_empty_class_attribute() { - $markup = '
Test
'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); - $context = $context_before; - $directive_ns = 'myblock'; - gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns ); - - $this->assertSame( - // WP_HTML_Tag_Processor has a TODO note to prune whitespace after classname removal. - '
Test
', - $tags->get_updated_html() - ); - $this->assertNull( $tags->get_attribute( 'class' ) ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); - } - - public function test_directive_does_not_remove_non_existant_class() { - $markup = '
Test
'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); - $context = $context_before; - $directive_ns = 'myblock'; - gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns ); - - $this->assertSame( - '
Test
', - $tags->get_updated_html() - ); - $this->assertSame( 'green red', $tags->get_attribute( 'class' ) ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); - } - - public function test_directive_ignores_empty_class_name() { - $markup = '
Test
'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); - $context = $context_before; - $directive_ns = 'myblock'; - gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns ); - - $this->assertSame( $markup, $tags->get_updated_html() ); - $this->assertStringNotContainsString( 'red', $tags->get_attribute( 'class' ) ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); - } -} diff --git a/phpunit/experimental/interactivity-api/directives/wp-context-test.php b/phpunit/experimental/interactivity-api/directives/wp-context-test.php deleted file mode 100644 index 788feec95fe7c5..00000000000000 --- a/phpunit/experimental/interactivity-api/directives/wp-context-test.php +++ /dev/null @@ -1,230 +0,0 @@ - array( 'open' => false ), - 'otherblock' => array( 'somekey' => 'somevalue' ), - ) - ); - - $ns = 'myblock'; - $markup = '
'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - gutenberg_interactivity_process_wp_context( $tags, $context, $ns ); - - $this->assertSame( - array( - 'myblock' => array( 'open' => true ), - 'otherblock' => array( 'somekey' => 'somevalue' ), - ), - $context->get_context() - ); - } - - public function test_directive_resets_context_correctly_upon_closing_tag() { - $context = new WP_Directive_Context( - array( 'myblock' => array( 'my-key' => 'original-value' ) ) - ); - - $context->set_context( - array( 'myblock' => array( 'my-key' => 'new-value' ) ) - ); - - $markup = '
'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - $this->assertSame( - array( 'my-key' => 'original-value' ), - $context->get_context()['myblock'] - ); - } - - public function test_directive_doesnt_throw_on_malformed_context_objects() { - $context = new WP_Directive_Context( - array( 'myblock' => array( 'my-key' => 'some-value' ) ) - ); - - $markup = '
'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - $this->assertSame( - array( 'my-key' => 'some-value' ), - $context->get_context()['myblock'] - ); - } - - public function test_directive_keeps_working_after_malformed_context_objects() { - $context = new WP_Directive_Context(); - - $markup = ' -
-
-
-
- '; - $tags = new WP_HTML_Tag_Processor( $markup ); - - // Parent div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - $this->assertSame( - array( 'my-key' => 'some-value' ), - $context->get_context()['myblock'] - ); - - // Children div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - // Still the same context. - $this->assertSame( - array( 'my-key' => 'some-value' ), - $context->get_context()['myblock'] - ); - - // Closing children div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - // Still the same context. - $this->assertSame( - array( 'my-key' => 'some-value' ), - $context->get_context()['myblock'] - ); - - // Closing parent div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - // Now the context is empty. - $this->assertSame( - array(), - $context->get_context() - ); - } - - public function test_directive_keeps_working_with_a_directive_without_value() { - $context = new WP_Directive_Context(); - - $markup = ' -
-
-
-
- '; - $tags = new WP_HTML_Tag_Processor( $markup ); - - // Parent div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - $this->assertSame( - array( 'my-key' => 'some-value' ), - $context->get_context()['myblock'] - ); - - // Children div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - // Still the same context. - $this->assertSame( - array( 'my-key' => 'some-value' ), - $context->get_context()['myblock'] - ); - - // Closing children div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - // Still the same context. - $this->assertSame( - array( 'my-key' => 'some-value' ), - $context->get_context()['myblock'] - ); - - // Closing parent div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - // Now the context is empty. - $this->assertSame( - array(), - $context->get_context() - ); - } - - public function test_directive_keeps_working_with_an_empty_directive() { - $context = new WP_Directive_Context(); - - $markup = ' -
-
-
-
- '; - $tags = new WP_HTML_Tag_Processor( $markup ); - - // Parent div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - $this->assertSame( - array( 'my-key' => 'some-value' ), - $context->get_context()['myblock'] - ); - - // Children div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - // Still the same context. - $this->assertSame( - array( 'my-key' => 'some-value' ), - $context->get_context()['myblock'] - ); - - // Closing children div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - // Still the same context. - $this->assertSame( - array( 'my-key' => 'some-value' ), - $context->get_context()['myblock'] - ); - - // Closing parent div. - $tags->next_tag( array( 'tag_closers' => 'visit' ) ); - gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' ); - - // Now the context is empty. - $this->assertSame( - array(), - $context->get_context() - ); - } -} diff --git a/phpunit/experimental/interactivity-api/directives/wp-style-test.php b/phpunit/experimental/interactivity-api/directives/wp-style-test.php deleted file mode 100644 index 9625803ebca78f..00000000000000 --- a/phpunit/experimental/interactivity-api/directives/wp-style-test.php +++ /dev/null @@ -1,63 +0,0 @@ -Test
'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); - $context = $context_before; - gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' ); - - $this->assertSame( - '
Test
', - $tags->get_updated_html() - ); - $this->assertStringContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); - } - - public function test_directive_ignores_empty_style() { - $markup = '
Test
'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); - $context = $context_before; - gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' ); - - $this->assertSame( $markup, $tags->get_updated_html() ); - $this->assertStringNotContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); - } - - public function test_directive_works_without_style_attribute() { - $markup = '
Test
'; - $tags = new WP_HTML_Tag_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); - $context = $context_before; - gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' ); - - $this->assertSame( - '
Test
', - $tags->get_updated_html() - ); - $this->assertSame( 'color: green;', $tags->get_attribute( 'style' ) ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); - } -} diff --git a/phpunit/experimental/interactivity-api/directives/wp-text-test.php b/phpunit/experimental/interactivity-api/directives/wp-text-test.php deleted file mode 100644 index 9c889a3f0eb68f..00000000000000 --- a/phpunit/experimental/interactivity-api/directives/wp-text-test.php +++ /dev/null @@ -1,45 +0,0 @@ -'; - - $tags = new WP_Directive_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'The HTML tag
produces a line break.' ) ) ); - $context = clone $context_before; - gutenberg_interactivity_process_wp_text( $tags, $context, 'myblock' ); - - $expected_markup = '
The HTML tag <br> produces a line break.
'; - $this->assertSame( $expected_markup, $tags->get_updated_html() ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); - } - - public function test_directive_overwrites_inner_html_based_on_attribute_value() { - $markup = '
Lorem ipsum dolor sit.
'; - - $tags = new WP_Directive_Processor( $markup ); - $tags->next_tag(); - - $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'Honi soit qui mal y pense.' ) ) ); - $context = clone $context_before; - gutenberg_interactivity_process_wp_text( $tags, $context, 'myblock' ); - - $expected_markup = '
Honi soit qui mal y pense.
'; - $this->assertSame( $expected_markup, $tags->get_updated_html() ); - $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); - } -} 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 new file mode 100644 index 00000000000000..aa1ee999fd58f1 --- /dev/null +++ b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php @@ -0,0 +1,369 @@ +Text'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertEquals( 'Text', $p->get_content_between_balanced_tags() ); + + $content = '
Text
More text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertEquals( 'Text', $p->get_content_between_balanced_tags() ); + $p->next_tag(); + $this->assertEquals( 'More text', $p->get_content_between_balanced_tags() ); + } + + /** + * Tests the `get_content_between_balanced_tags` method on an empty tag. + * + * @covers ::get_content_between_balanced_tags + */ + public function test_get_content_between_balanced_tags_empty_tag() { + $content = '
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertEquals( '', $p->get_content_between_balanced_tags() ); + } + + /** + * Tests the `get_content_between_balanced_tags` method with a self-closing + * tag. + * + * @covers ::get_content_between_balanced_tags + */ + public function test_get_content_between_balanced_tags_self_closing_tag() { + $content = ''; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertNull( $p->get_content_between_balanced_tags() ); + } + + /** + * Tests the `get_content_between_balanced_tags` method with nested tags. + * + * @covers ::get_content_between_balanced_tags + */ + public function test_get_content_between_balanced_tags_nested_tags() { + $content = '
ContentMore Content
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertEquals( 'ContentMore Content', $p->get_content_between_balanced_tags() ); + + $content = '
Content
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertEquals( '
Content
', $p->get_content_between_balanced_tags() ); + } + + /** + * Tests the `get_content_between_balanced_tags` method when no tags are + * present. + * + * @covers ::get_content_between_balanced_tags + */ + public function test_get_content_between_balanced_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_tags() ); + } + + /** + * Tests the `get_content_between_balanced_tags` method with unbalanced tags. + * + * @covers ::get_content_between_balanced_tags + */ + public function test_get_content_between_balanced_tags_with_unbalanced_tags() { + $content = '
Missing closing div'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertNull( $p->get_content_between_balanced_tags() ); + + $content = '
Missing closing div
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertNull( $p->get_content_between_balanced_tags() ); + + $content = '
Missing closing div'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertNull( $p->get_content_between_balanced_tags() ); + + // It supports unbalanced tags inside the content. + $content = '
Missing opening span
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertEquals( 'Missing opening span', $p->get_content_between_balanced_tags() ); + } + + /** + * Tests the `get_content_between_balanced_tags` method when called on a + * closing tag. + * + * @covers ::get_content_between_balanced_tags + */ + public function test_get_content_between_balanced_tags_on_closing_tag() { + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag( array( 'tag_closers' => 'visit' ) ); + $p->next_tag( array( 'tag_closers' => 'visit' ) ); + $this->assertNull( $p->get_content_between_balanced_tags() ); + } + + /** + * Tests the `set_content_between_balanced_tags` method on standard tags. + * + * @covers ::set_content_between_balanced_tags + */ + public function test_set_content_between_balanced_tags_standard_tags() { + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( 'New text' ); + $this->assertTrue( $result ); + $this->assertEquals( '
New text
', $p ); + + $content = '
Text
More text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( 'New text' ); + $this->assertTrue( $result ); + $this->assertEquals( '
New text
More text
', $p ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( 'More new text' ); + $this->assertTrue( $result ); + $this->assertEquals( '
New text
More new text
', $p ); + } + + /** + * Tests the `set_content_between_balanced_tags` method when called on a + * closing tag. + * + * @covers ::set_content_between_balanced_tags + */ + public function test_set_content_between_balanced_tags_on_closing_tag() { + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag( array( 'tag_closers' => 'visit' ) ); + $p->next_tag( array( 'tag_closers' => 'visit' ) ); + $result = $p->set_content_between_balanced_tags( 'New text' ); + $this->assertFalse( $result ); + $this->assertEquals( '
Text
', $p ); + } + + /** + * Tests the `set_content_between_balanced_tags` method on multiple calls to + * the same tag. + * + * @covers ::set_content_between_balanced_tags + */ + public function test_set_content_between_balanced_tags_multiple_calls_in_same_tag() { + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( 'New text' ); + $this->assertTrue( $result ); + $this->assertEquals( '
New text
', $p ); + $result = $p->set_content_between_balanced_tags( 'More text' ); + $this->assertTrue( $result ); + $this->assertEquals( '
More text
', $p ); + } + + /** + * Tests the `set_content_between_balanced_tags` method on combinations with + * set_attribute calls. + * + * @covers ::set_content_between_balanced_tags + */ + public function test_set_content_between_balanced_tags_with_set_attribute() { + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $p->set_attribute( 'class', 'test' ); + $result = $p->set_content_between_balanced_tags( 'New text' ); + $this->assertTrue( $result ); + $this->assertEquals( '
New text
', $p ); + + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( 'New text' ); + $this->assertTrue( $result ); + $p->set_attribute( 'class', 'test' ); + $this->assertEquals( '
New text
', $p ); + } + + /** + * Tests the `set_content_between_balanced_tags` method where the existing + * content includes tags. + * + * @covers ::set_content_between_balanced_tags + */ + public function test_set_content_between_balanced_tags_with_existing_tags() { + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( 'New text' ); + $this->assertTrue( $result ); + $this->assertEquals( '
New text
', $p ); + } + + /** + * Tests the `set_content_between_balanced_tags` method where the new content + * includes tags. + * + * @covers ::set_content_between_balanced_tags + */ + public function test_set_content_between_balanced_tags_with_new_tags() { + $content = '
Text
'; + $new_content = 'New textLink'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $p->set_content_between_balanced_tags( $new_content ); + $this->assertEquals( '
<span>New text</span><a href="#">Link</a>
', $p ); + } + + /** + * Tests the `set_content_between_balanced_tags` method with an empty string. + * + * @covers ::set_content_between_balanced_tags + */ + public function test_set_content_between_balanced_tags_empty() { + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( '' ); + $this->assertTrue( $result ); + $this->assertEquals( '
', $p ); + + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( '' ); + $this->assertTrue( $result ); + $this->assertEquals( '
', $p ); + } + + /** + * Tests the `set_content_between_balanced_tags` method on self-closing tags. + * + * @covers ::set_content_between_balanced_tags + */ + public function test_set_content_between_balanced_tags_self_closing_tag() { + $content = ''; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( 'New text' ); + $this->assertFalse( $result ); + $this->assertEquals( $content, $p ); + } + + /** + * Tests the `set_content_between_balanced_tags` method on a non-existent tag. + * + * @covers ::set_content_between_balanced_tags + */ + public function test_set_content_between_balanced_tags_non_existent_tag() { + $content = 'Just a string with no tags.'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( 'New text' ); + $this->assertFalse( $result ); + $this->assertEquals( $content, $p ); + } + + /** + * Tests the `set_content_between_balanced_tags` method with unbalanced tags. + * + * @covers ::set_content_between_balanced_tags + */ + public function test_set_content_between_balanced_tags_with_unbalanced_tags() { + $new_content = 'New text'; + + $content = '
Missing closing div'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( $new_content ); + $this->assertFalse( $result ); + $this->assertEquals( '
Missing closing div', $p ); + + $content = '
Missing closing div
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( $new_content ); + $this->assertFalse( $result ); + $this->assertEquals( '
Missing closing div
', $p ); + + $content = '
Missing closing div'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( $new_content ); + $this->assertFalse( $result ); + $this->assertEquals( '
Missing closing div', $p ); + + // It supports unbalanced tags inside the content. + $content = '
Missing opening span
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->set_content_between_balanced_tags( $new_content ); + $this->assertTrue( $result ); + $this->assertEquals( '
New text
', $p ); + } + + /** + * Tests the is_void method. + * + * @covers ::is_void + */ + public function test_is_void_element() { + $void_elements = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr' ); + foreach ( $void_elements as $tag_name ) { + $content = "<{$tag_name} id={$tag_name}>"; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertTrue( $p->is_void() ); + } + + $non_void_elements = array( 'div', 'span', 'p', 'script', 'style' ); + foreach ( $non_void_elements as $tag_name ) { + $content = "<{$tag_name} id={$tag_name}>Some content"; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertFalse( $p->is_void() ); + } + + // Test an upercase tag. + $content = ''; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertTrue( $p->is_void() ); + + // Test an empty string. + $content = ''; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertFalse( $p->is_void() ); + + // Test on text nodes. + $content = 'This is just some text'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertFalse( $p->is_void() ); + } +} diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-test.php new file mode 100644 index 00000000000000..719f9592563c3b --- /dev/null +++ b/phpunit/interactivity-api/class-wp-interactivity-api-test.php @@ -0,0 +1,570 @@ +interactivity = new WP_Interactivity_API(); + } + + /** + * Tests that the state and config methods return an empty array at the + * beginning. + * + * @covers ::state + * @covers ::config + */ + public function test_state_and_config_should_be_empty() { + $this->assertEquals( array(), $this->interactivity->state( 'myPlugin' ) ); + $this->assertEquals( array(), $this->interactivity->config( 'myPlugin' ) ); + } + + /** + * Tests that the state and config methods can change the state and + * configuration. + * + * @covers ::state + * @covers ::config + */ + public function test_state_and_config_can_be_changed() { + $state = array( + 'a' => 1, + 'b' => 2, + 'nested' => array( 'c' => 3 ), + ); + $result = $this->interactivity->state( 'myPlugin', $state ); + $this->assertEquals( $state, $result ); + $result = $this->interactivity->config( 'myPlugin', $state ); + $this->assertEquals( $state, $result ); + } + + /** + * Tests that different initial states and configurations can be merged. + * + * @covers ::state + * @covers ::config + */ + public function test_state_and_config_can_be_merged() { + $this->interactivity->state( 'myPlugin', array( 'a' => 1 ) ); + $this->interactivity->state( 'myPlugin', array( 'b' => 2 ) ); + $this->interactivity->state( 'otherPlugin', array( 'c' => 3 ) ); + $this->assertEquals( + array( + 'a' => 1, + 'b' => 2, + ), + $this->interactivity->state( 'myPlugin' ) + ); + $this->assertEquals( + array( 'c' => 3 ), + $this->interactivity->state( 'otherPlugin' ) + ); + + $this->interactivity->config( 'myPlugin', array( 'a' => 1 ) ); + $this->interactivity->config( 'myPlugin', array( 'b' => 2 ) ); + $this->interactivity->config( 'otherPlugin', array( 'c' => 3 ) ); + $this->assertEquals( + array( + 'a' => 1, + 'b' => 2, + ), + $this->interactivity->config( 'myPlugin' ) + ); + $this->assertEquals( + array( 'c' => 3 ), + $this->interactivity->config( 'otherPlugin' ) + ); } + + /** + * Tests that existing keys in the initial state and configuration can be + * overwritten. + * + * @covers ::state + * @covers ::config + */ + public function test_state_and_config_existing_props_can_be_overwritten() { + $this->interactivity->state( 'myPlugin', array( 'a' => 1 ) ); + $this->interactivity->state( 'myPlugin', array( 'a' => 2 ) ); + $this->assertEquals( + array( 'a' => 2 ), + $this->interactivity->state( 'myPlugin' ) + ); + + $this->interactivity->config( 'myPlugin', array( 'a' => 1 ) ); + $this->interactivity->config( 'myPlugin', array( 'a' => 2 ) ); + $this->assertEquals( + array( 'a' => 2 ), + $this->interactivity->config( 'myPlugin' ) + ); + } + + /** + * Tests that existing indexed arrays in the initial state and configuration + * are replaced, not merged. + * + * @covers ::state + * @covers ::config + */ + public function test_state_and_config_existing_indexed_arrays_are_replaced() { + $this->interactivity->state( 'myPlugin', array( 'a' => array( 1, 2 ) ) ); + $this->interactivity->state( 'myPlugin', array( 'a' => array( 3, 4 ) ) ); + $this->assertEquals( + array( 'a' => array( 3, 4 ) ), + $this->interactivity->state( 'myPlugin' ) + ); + + $this->interactivity->config( 'myPlugin', array( 'a' => array( 1, 2 ) ) ); + $this->interactivity->config( 'myPlugin', array( 'a' => array( 3, 4 ) ) ); + $this->assertEquals( + array( 'a' => array( 3, 4 ) ), + $this->interactivity->config( 'myPlugin' ) + ); + } + + /** + * Invokes the private `print_client_interactivity` method of + * WP_Interactivity_API class. + * + * @return array|null The content of the JSON object printed on the client-side or null if nothings was printed. + */ + private function print_client_interactivity_data() { + $interactivity_data_markup = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) ); + preg_match( '/