From bb547e4bbc281165e67219cfbcd8691b645b4b13 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 31 Jan 2024 17:30:26 +0100 Subject: [PATCH 01/56] Code from GB --- ...interactivity-api-directives-processor.php | 141 ++++ .../class-wp-interactivity-api.php | 672 ++++++++++++++++++ .../interactivity-api/interactivity-api.php | 134 ++++ src/wp-settings.php | 3 + .../interactivity-api/interactivity-api.php | 217 ++++++ .../wpInteractivityAPI-wp-bind.php | 338 +++++++++ .../wpInteractivityAPI-wp-class.php | 291 ++++++++ .../wpInteractivityAPI-wp-context.php | 476 +++++++++++++ .../wpInteractivityAPI-wp-interactive.php | 206 ++++++ .../wpInteractivityAPI-wp-style.php | 403 +++++++++++ .../wpInteractivityAPI-wp-text.php | 123 ++++ .../interactivity-api/wpInteractivityAPI.php | 570 +++++++++++++++ .../wpInteractivityAPIDirectivesProcessor.php | 369 ++++++++++ 13 files changed, 3943 insertions(+) create mode 100644 src/wp-includes/interactivity-api/class-wp-interactivity-api-directives-processor.php create mode 100644 src/wp-includes/interactivity-api/class-wp-interactivity-api.php create mode 100644 src/wp-includes/interactivity-api/interactivity-api.php create mode 100644 tests/phpunit/tests/interactivity-api/interactivity-api.php create mode 100644 tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php create mode 100644 tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-class.php create mode 100644 tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-context.php create mode 100644 tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-interactive.php create mode 100644 tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-style.php create mode 100644 tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-text.php create mode 100644 tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php create mode 100644 tests/phpunit/tests/interactivity-api/wpInteractivityAPIDirectivesProcessor.php diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api-directives-processor.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api-directives-processor.php new file mode 100644 index 0000000000000..a045e714711eb --- /dev/null +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api-directives-processor.php @@ -0,0 +1,141 @@ +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 WP_HTML_Text_Replacement( $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 WP_HTML_Processor::is_void( null !== $tag_name ? $tag_name : '' ); + } +} diff --git a/src/wp-includes/interactivity-api/class-wp-interactivity-api.php b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php new file mode 100644 index 0000000000000..cb481b0c65e40 --- /dev/null +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -0,0 +1,672 @@ + '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', + includes_url( 'js/dist/interactivity.min.js' ) + ); + + wp_register_script_module( + '@wordpress/interactivity-router', + includes_url( 'js/dist/interactivity-router.min.js' ), + array( '@wordpress/interactivity' ) + ); + } + + /** + * 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/src/wp-includes/interactivity-api/interactivity-api.php b/src/wp-includes/interactivity-api/interactivity-api.php new file mode 100644 index 0000000000000..7d9659e14b25d --- /dev/null +++ b/src/wp-includes/interactivity-api/interactivity-api.php @@ -0,0 +1,134 @@ +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 ); + +/** + * 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; +} + +/** + * 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 ); +} + +/** + * 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 ); +} + +/** + * 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/src/wp-settings.php b/src/wp-settings.php index 624b1d804acf0..6cddae7550e16 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -377,6 +377,9 @@ require ABSPATH . WPINC . '/class-wp-script-modules.php'; require ABSPATH . WPINC . '/script-modules.php'; require ABSPATH . WPINC . '/interactivity-api.php'; +require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api.php'; +require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api-directives-processor.php'; +require ABSPATH . WPINC . '/interactivity-api/interactivity-api.php'; $GLOBALS['wp_embed'] = new WP_Embed(); diff --git a/tests/phpunit/tests/interactivity-api/interactivity-api.php b/tests/phpunit/tests/interactivity-api/interactivity-api.php new file mode 100644 index 0000000000000..ba88737cd65d9 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/interactivity-api.php @@ -0,0 +1,217 @@ + function ( $attributes, $content ) { + return ' +
+ ' . + $content . + '
'; + }, + 'supports' => array( + 'interactivity' => true, + ), + ) + ); + + register_block_type( + 'test/non-interactive-block', + array( + 'render_callback' => function ( $attributes, $content ) { + $directive = isset( $attributes['hasDirective'] ) ? ' data-wp-bind--value="context.block"' : ''; + return ' +
+ ' . + $content . + '
'; + }, + ) + ); + } + + public function tear_down() { + unregister_block_type( 'test/interactive-block' ); + unregister_block_type( 'test/non-interactive-block' ); + parent::tear_down(); + } + + public function test_processs_directives_of_single_interactive_block() { + $post_content = ''; + $rendered_blocks = do_blocks( $post_content ); + $p = new WP_HTML_Tag_Processor( $rendered_blocks ); + $p->next_tag( array( 'class_name' => 'interactive/block-1' ) ); + $this->assertEquals( '1', $p->get_attribute( 'value' ) ); + } + + public function test_processs_directives_of_multiple_interactive_blocks_in_paralell() { + $post_content = ' + + + + '; + $rendered_blocks = do_blocks( $post_content ); + $p = new WP_HTML_Tag_Processor( $rendered_blocks ); + $p->next_tag( array( 'class_name' => 'interactive/block-1' ) ); + $this->assertEquals( '1', $p->get_attribute( 'value' ) ); + $p->next_tag( array( 'class_name' => 'interactive/block-2' ) ); + $this->assertEquals( '2', $p->get_attribute( 'value' ) ); + $p->next_tag( array( 'class_name' => 'non-interactive/block-3' ) ); + $this->assertNull( $p->get_attribute( 'value' ) ); + } + + public function test_processs_directives_of_interactive_block_inside_non_interactive_block() { + $post_content = ' + + + + '; + $rendered_blocks = do_blocks( $post_content ); + $p = new WP_HTML_Tag_Processor( $rendered_blocks ); + $p->next_tag( array( 'class_name' => 'interactive/block-2' ) ); + $this->assertEquals( '2', $p->get_attribute( 'value' ) ); + } + + public function test_processs_directives_of_multple_interactive_blocks_inside_non_interactive_block() { + $post_content = ' + + + + + '; + $rendered_blocks = do_blocks( $post_content ); + $p = new WP_HTML_Tag_Processor( $rendered_blocks ); + $p->next_tag( array( 'class_name' => 'interactive/block-2' ) ); + $this->assertEquals( '2', $p->get_attribute( 'value' ) ); + $p->next_tag( array( 'class_name' => 'interactive/block-3' ) ); + $this->assertEquals( '3', $p->get_attribute( 'value' ) ); + } + + public function test_processs_directives_of_interactive_block_inside_multple_non_interactive_block() { + $post_content = ' + + + + + + + '; + $rendered_blocks = do_blocks( $post_content ); + $p = new WP_HTML_Tag_Processor( $rendered_blocks ); + $p->next_tag( array( 'class_name' => 'interactive/block-2' ) ); + $this->assertEquals( '2', $p->get_attribute( 'value' ) ); + $p->next_tag( array( 'class_name' => 'interactive/block-4' ) ); + $this->assertEquals( '4', $p->get_attribute( 'value' ) ); + } + + public function test_processs_directives_of_interactive_block_containing_non_interactive_block_without_directives() { + $post_content = ' + + + + '; + $rendered_blocks = do_blocks( $post_content ); + $p = new WP_HTML_Tag_Processor( $rendered_blocks ); + $p->next_tag( array( 'class_name' => 'interactive/block-1' ) ); + $this->assertEquals( '1', $p->get_attribute( 'value' ) ); + $p->next_tag( array( 'class_name' => 'non-interactive/block-2' ) ); + $this->assertNull( $p->get_attribute( 'value' ) ); + } + + public function test_processs_directives_of_interactive_block_containing_non_interactive_block_with_directives() { + $post_content = ' + + + + '; + $rendered_blocks = do_blocks( $post_content ); + $p = new WP_HTML_Tag_Processor( $rendered_blocks ); + $p->next_tag( array( 'class_name' => 'interactive/block-1' ) ); + $this->assertEquals( '1', $p->get_attribute( 'value' ) ); + $p->next_tag( array( 'class_name' => 'non-interactive/block-2' ) ); + $this->assertEquals( '1', $p->get_attribute( 'value' ) ); + } + + public function test_processs_directives_of_interactive_block_containing_nested_interactive_and_non_interactive_blocks() { + $post_content = ' + + + + + + + '; + $rendered_blocks = do_blocks( $post_content ); + $p = new WP_HTML_Tag_Processor( $rendered_blocks ); + $p->next_tag( array( 'class_name' => 'interactive/block-1' ) ); + $this->assertEquals( '1', $p->get_attribute( 'value' ) ); + $p->next_tag( array( 'class_name' => 'interactive/block-2' ) ); + $this->assertEquals( '2', $p->get_attribute( 'value' ) ); + $p->next_tag( array( 'class_name' => 'non-interactive/block-3' ) ); + $this->assertEquals( '2', $p->get_attribute( 'value' ) ); + $p->next_tag( array( 'class_name' => 'non-interactive/block-4' ) ); + $this->assertEquals( '1', $p->get_attribute( 'value' ) ); + } + + private $data_wp_test_processor_count = 0; + + public function data_wp_test_processor( $p ) { + if ( ! $p->is_tag_closer() ) { + $this->data_wp_test_processor_count = $this->data_wp_test_processor_count + 1; + } + } + + public function test_process_directives_only_process_the_root_interactive_blocks() { + $class = new ReflectionClass( 'WP_Interactivity_API' ); + $directive_processors = $class->getProperty( 'directive_processors' ); + $directive_processors->setAccessible( true ); + $directive_processors->setValue( null, array( 'data-wp-test' => array( $this, 'data_wp_test_processor' ) ) ); + $html = '
'; + $this->data_wp_test_processor_count = 0; + wp_interactivity_process_directives( $html ); + $this->assertEquals( 1, $this->data_wp_test_processor_count ); + + register_block_type( + 'test/custom-directive-block', + array( + 'render_callback' => function ( $attributes, $content ) { + return '
' . $content . '
'; + }, + 'supports' => array( + 'interactivity' => true, + ), + ) + ); + $post_content = ' + + + + '; + $this->data_wp_test_processor_count = 0; + do_blocks( $post_content ); + $this->assertEquals( 2, $this->data_wp_test_processor_count ); + unregister_block_type( 'test/custom-directive-block' ); + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php new file mode 100644 index 0000000000000..7b82d728f0382 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -0,0 +1,338 @@ +interactivity = new WP_Interactivity_API(); + $this->interactivity->state( + 'myPlugin', + array( + 'id' => 'some-id', + 'width' => 100, + 'isOpen' => false, + 'null' => null, + 'trueString' => 'true', + 'falseString' => 'false', + ) + ); + } + + /** + * Invokes the `process_directives` method of WP_Interactivity_API class. + * + * @param string $html The HTML that needs to be processed. + * @return array An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. + */ + private function process_directives( $html ) { + $new_html = $this->interactivity->process_directives( $html ); + $p = new WP_HTML_Tag_Processor( $new_html ); + $p->next_tag(); + return array( $p, $new_html ); + } + + /** + * Tests setting an attribute via `data-wp-bind`. + * + * @covers ::process_directives + */ + public function test_wp_bind_sets_attribute() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests replacing an existing attribute via `data-wp-bind`. + * + * @covers ::process_directives + */ + public function test_wp_bind_replaces_attribute() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests setting a numerical value as an attribute via `data-wp-bind`. + * + * @covers ::process_directives + */ + public function test_wp_bind_sets_number_value() { + $html = ''; + list($p) = $this->process_directives( $html ); + $this->assertEquals( '100', $p->get_attribute( 'width' ) ); + } + + /** + * Tests that true strings are set properly as attribute values. + * + * @covers ::process_directives + */ + public function test_wp_bind_sets_true_string() { + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertEquals( 'true', $p->get_attribute( 'id' ) ); + $this->assertEquals( '
Text
', $new_html ); + } + + /** + * Tests that false strings are set properly as attribute values. + * + * @covers ::process_directives + */ + public function test_wp_bind_sets_false_string() { + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertEquals( 'false', $p->get_attribute( 'id' ) ); + $this->assertEquals( '
Text
', $new_html ); + } + + /** + * Tests that `data-wp-bind` ignores directives with no suffix. + * + * @covers ::process_directives + */ + public function test_wp_bind_ignores_empty_bound_attribute() { + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( $html, $new_html ); + } + + /** + * Tests that `data-wp-bind` does nothing when referencing non-existent + * references. + * + * @covers ::process_directives + */ + public function test_wp_bind_doesnt_do_anything_on_non_existent_references() { + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( $html, $new_html ); + } + + /** + * Tests that `data-wp-bind` ignores directives with empty values. + * + * @covers ::process_directives + */ + public function test_wp_bind_ignores_empty_value() { + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( $html, $new_html ); + } + + /** + * Tests that `data-wp-bind` ignores directives without values. + * + * @covers ::process_directives + */ + public function test_wp_bind_ignores_without_value() { + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( $html, $new_html ); + } + + /** + * Tests that `data-wp-bind` works with multiple instances of the same + * directive on a tag. + * + * @covers ::process_directives + */ + public function test_wp_bind_works_with_multiple_same_directives() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that `data-wp-bind` works with multiple instances of different + * directives on a tag. + * + * @covers ::process_directives + */ + public function test_wp_bind_works_with_multiple_different_directives() { + $html = ''; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + $this->assertEquals( '100', $p->get_attribute( 'width' ) ); + } + + /** + * Tests adding boolean attributes to a tag using `data-wp-bind`. + * + * @covers ::process_directives + */ + public function test_wp_bind_adds_boolean_attribute_if_true() { + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertTrue( $p->get_attribute( 'hidden' ) ); + $this->assertEquals( '', $new_html ); + } + + /** + * Tests replacing a pre-existing boolean attribute on a tag using + * `data-wp-bind`. + * + * @covers ::process_directives + */ + public function test_wp_bind_replaces_existing_attribute_if_true() { + $html = ''; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertTrue( $p->get_attribute( 'hidden' ) ); + $this->assertEquals( '', $new_html ); + } + + /** + * Tests that boolean attributes are not added when bound to false or null + * values. + * + * @covers ::process_directives + */ + public function test_wp_bind_doesnt_add_boolean_attribute_if_false_or_null() { + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'hidden' ) ); + $this->assertEquals( $html, $new_html ); + + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'hidden' ) ); + $this->assertEquals( $html, $new_html ); + } + + /** + * Tests removing boolean attributes from a tag using `data-wp-bind` and a + * false or null value. + * + * @covers ::process_directives + */ + public function test_wp_bind_removes_boolean_attribute_if_false_or_null() { + $html = ''; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'hidden' ) ); + + $html = ''; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'hidden' ) ); + } + + /** + * Tests adding values to aria or data attributes when the condition evaluates + * to true. + * + * @covers ::process_directives + */ + public function test_wp_bind_adds_value_if_true_in_aria_or_data_attributes() { + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertEquals( 'true', $p->get_attribute( 'aria-hidden' ) ); + $this->assertEquals( '', $new_html ); + + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertEquals( 'true', $p->get_attribute( 'data-is-closed' ) ); + $this->assertEquals( '
Text
', $new_html ); + } + + /** + * Tests replacing values in aria or data attributes when the condition + * evaluates to true. + * + * @covers ::process_directives + */ + public function test_wp_bind_replaces_value_if_true_in_aria_or_data_attributes() { + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertEquals( 'true', $p->get_attribute( 'aria-hidden' ) ); + $this->assertEquals( '', $new_html ); + + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $p = new WP_HTML_Tag_Processor( $new_html ); + $p->next_tag(); + $this->assertEquals( 'true', $p->get_attribute( 'data-is-closed' ) ); + $this->assertEquals( '
Text
', $new_html ); + } + + /** + * Tests adding the value 'false' to aria or data attributes when the + * condition evaluates to false. + * + * @covers ::process_directives + */ + public function test_wp_bind_adds_value_if_false_in_aria_or_data_attributes() { + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertEquals( 'false', $p->get_attribute( 'aria-hidden' ) ); + $this->assertEquals( '
Text
', $new_html ); + + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertEquals( 'false', $p->get_attribute( 'data-is-closed' ) ); + $this->assertEquals( '
Text
', $new_html ); + } + + /** + * Tests replacing values in aria or data attributes when the condition + * evaluates to false. + * + * @covers ::process_directives + */ + public function test_wp_bind_replaces_value_if_false_in_aria_or_data_attributes() { + $html = ''; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertEquals( 'false', $p->get_attribute( 'aria-hidden' ) ); + $this->assertEquals( '
Text
', $new_html ); + + $html = '
Text
'; + list($p, $new_html) = $this->process_directives( $html ); + $this->assertEquals( 'false', $p->get_attribute( 'data-is-closed' ) ); + $this->assertEquals( '
Text
', $new_html ); + } + + /** + * Tests removing values from aria or data attributes when the value is null. + * + * @covers ::process_directives + */ + public function test_wp_bind_removes_value_if_null_in_aria_or_data_attributes() { + $html = ''; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'aria-hidden' ) ); + + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'data-is-closed' ) ); + } + + /** + * Tests handling of bindings within nested tags. + * + * @covers ::process_directives + */ + public function test_wp_bind_handles_nested_bindings() { + $html = '
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + $p->next_tag(); + $this->assertEquals( '100', $p->get_attribute( 'width' ) ); + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-class.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-class.php new file mode 100644 index 0000000000000..7484d7ea50ccc --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-class.php @@ -0,0 +1,291 @@ +interactivity = new WP_Interactivity_API(); + $this->interactivity->state( + 'myPlugin', + array( + 'true' => true, + 'false' => false, + ) + ); + } + + /** + * Invokes the `process_directives` method of WP_Interactivity_API class. + * + * @param string $html The HTML that needs to be processed. + * @return array An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. + */ + private function process_directives( $html ) { + $new_html = $this->interactivity->process_directives( $html ); + $p = new WP_HTML_Tag_Processor( $new_html ); + $p->next_tag(); + return array( $p, $new_html ); + } + + /** + * Tests that `data-wp-class` adds a class when the condition is true. + * + * @covers ::process_directives + */ + public function test_wp_class_sets_class_name() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests that `data-wp-class` can add multiple classes based on true + * conditions. + * + * @covers ::process_directives + */ + public function test_wp_class_sets_multiple_class_names() { + $html = ' +
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-class other-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests handling of adding one and not adding another class based on + * different boolean values. + * + * @covers ::process_directives + */ + public function test_wp_class_handles_multiple_class_names_with_different_values() { + $html = ' +
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-class', $p->get_attribute( 'class' ) ); + + $html = ' +
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests that `data-wp-class` adds new classes alongside existing ones. + * + * @covers ::process_directives + */ + public function test_wp_class_sets_class_name_when_class_attribute_exists() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'other-class some-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests that no class is added when the associated state is false. + * + * @covers ::process_directives + */ + public function test_wp_class_doesnt_add_class_attribute_on_false() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'class' ) ); + } + + /** + * Tests that existing class names are preserved when the directive condition + * is false. + * + * @covers ::process_directives + */ + public function test_wp_class_doesnt_add_class_name_on_false() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'other-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests that existing class names remain intact when they should be re-added + * as per their directive. + * + * @covers ::process_directives + */ + public function test_wp_class_keeps_class_name_when_class_name_exists() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests preservation of existing class names, even when one is repeated in a + * directive that evaluates to true. + * + * @covers ::process_directives + */ + public function test_wp_class_keeps_class_name_when_class_name_exists_and_is_not_the_only_one() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'other-class some-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests that a class attribute with only one class name is removed when the + * directive evaluates to false. + * + * @covers ::process_directives + */ + public function test_wp_class_removes_class_attribute_when_class_name_exists_and_is_the_only_one() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'class' ) ); + } + + /** + * Tests that one of several class names is removed when its directive + * evaluates to false. + * + * @covers ::process_directives + */ + public function test_wp_class_removes_class_name_when_class_name_exists_and_is_not_the_only_one() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'other-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests that an empty class attribute is not removed even if a directive + * evaluates to false. + * + * @covers ::process_directives + */ + public function test_wp_class_doesnt_remove_empty_class_attribute() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertTrue( $p->get_attribute( 'class' ) ); + } + + /** + * Tests that the class attribute remains unchanged if the data-wp-class + * suffix is empty. + * + * @covers ::process_directives + */ + public function test_wp_class_doesnt_change_class_attribute_with_empty_directive_suffix() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'other-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests that the class attribute is not altered if the value of the + * `data-wp-class` directive is empty. + * + * @covers ::process_directives + */ + public function test_wp_class_doesnt_change_class_attribute_with_empty_value() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'other-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests that an existing class attribute is not affected by a `data-wp-class` + * directive without a value. + * + * @covers ::process_directives + */ + public function test_wp_class_doesnt_change_class_attribute_without_value() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'other-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests that multiple directives for the same class yield the correct result + * when the condition is true. + * + * @covers ::process_directives + */ + public function test_wp_class_works_with_multiple_directives() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests setting class names based on truthy values other than just true + * booleans. + * + * @covers ::process_directives + */ + public function test_wp_class_sets_class_name_on_truthy_values() { + $this->interactivity->state( 'myPlugin', array( 'text' => 'some text' ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-class', $p->get_attribute( 'class' ) ); + + $this->interactivity->state( 'myPlugin', array( 'array' => array( 1, 2 ) ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-class', $p->get_attribute( 'class' ) ); + + $this->interactivity->state( 'myPlugin', array( 'number' => 1 ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-class', $p->get_attribute( 'class' ) ); + } + + /** + * Tests that class attributes aren't set for falsy values other than just + * false booleans. + * + * @covers ::process_directives + */ + public function test_wp_class_sets_class_name_on_falsy_values() { + $this->interactivity->state( 'myPlugin', array( 'text' => '' ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'class' ) ); + + $this->interactivity->state( 'myPlugin', array( 'array' => array() ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'class' ) ); + + $this->interactivity->state( 'myPlugin', array( 'number' => 0 ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'class' ) ); + + $this->interactivity->state( 'myPlugin', array( 'null' => null ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'class' ) ); + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-context.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-context.php new file mode 100644 index 0000000000000..0e0ef3c35f016 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-context.php @@ -0,0 +1,476 @@ +interactivity = new WP_Interactivity_API(); + } + + /** + * Invokes the `process_directives` method of WP_Interactivity_API class. + * + * @param string $html The HTML that needs to be processed. + * @return array An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. + */ + private function process_directives( $html ) { + $new_html = $this->interactivity->process_directives( $html ); + $p = new WP_HTML_Tag_Processor( $new_html ); + $p->next_tag( array( 'class_name' => 'test' ) ); + return array( $p, $new_html ); + } + + /** + * Tests that the `data-wp-context` directive can set a context in a custom + * namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_sets_a_context_in_a_custom_namespace() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive can set a context in the same + * tag. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_can_set_a_context_in_the_same_tag() { + $html = ' +
+ Text +
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive merges context in the same + * custom namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_merges_context_in_the_same_custom_namespace() { + $html = ' +
+
+
Text
+
Text
+
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id-2', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive overwrites context in the same + * custom namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_overwrites_context_in_the_same_custom_namespace() { + $html = ' +
+
+
Text
+
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id-2', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive replaces the old context after a + * closing tag in the same custom namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_replaces_old_context_after_closing_tag_in_the_same_custom_namespace() { + $html = ' +
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id-2', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive merges context in different + * custom namespaces. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_merges_context_in_different_custom_namespaces() { + $html = ' +
+
+
Text
+
Text
+
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id-2', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive doesn't throw on malformed + * context objects. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_doesnt_throw_on_malformed_context_objects() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive doesn't overwrite context on + * malformed context objects. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_doesnt_overwrite_context_on_malformed_context_objects() { + $html = ' +
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive doesn't throw on an empty + * context object. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_doesnt_throw_on_empty_context() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive doesn't overwrite the context on + * empty context directive. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_doesnt_overwrite_context_on_empty_context() { + $html = ' +
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive doesn't throw on context without + * value. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_doesnt_throw_on_context_without_value() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive doesn't overwrite context on + * context without value. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_doesnt_overwrite_context_on_context_without_value() { + $html = ' +
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive works with multiple directives. + * + * @covers ::process_directives + */ + public function test_wp_context_works_with_multiple_directives() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive doesn't work without any + * namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_doesnt_work_without_any_namespace() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive works with a default namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_works_with_default_namespace() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive overrides a default namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_overrides_default_namespace() { + $html = ' +
+
+
Text
+
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive overrides the default namespace + * with the same namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_overrides_default_namespace_with_same_namespace() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive works with nested default + * namespaces. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_works_with_nested_default_namespaces() { + $html = ' +
+
+
+
+
Text
+
Text
+
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'other-id', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertNull( $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive works with a default namespace + * in the same tag. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_works_with_default_namespace_in_the_same_tag() { + $html = ' +
+ Text +
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive merges the context in the same + * default namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_merges_context_in_the_same_default_namespace() { + $html = ' +
+
+
Text
+
Text
+
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id-2', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive overwrites context in the same + * default namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_overwrites_context_in_the_same_default_namespace() { + $html = ' +
+
+
Text
+
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id-2', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-context` directive replaces the old context after + * the closing tag in the same default namespace. + * + * @covers ::process_directives + */ + public function test_wp_context_directive_replaces_old_context_after_closing_tag_in_the_same_default_namespace() { + $html = ' +
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id-2', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id-1', $p->get_attribute( 'id' ) ); + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-interactive.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-interactive.php new file mode 100644 index 0000000000000..d821c4504a513 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-interactive.php @@ -0,0 +1,206 @@ +interactivity = new WP_Interactivity_API(); + $this->interactivity->state( 'myPlugin', array( 'id' => 'some-id' ) ); + $this->interactivity->state( 'otherPlugin', array( 'id' => 'other-id' ) ); + } + + /** + * Invokes the `process_directives` method of WP_Interactivity_API class. + * + * @param string $html The HTML that needs to be processed. + * @return array An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. + */ + private function process_directives( $html ) { + $new_html = $this->interactivity->process_directives( $html ); + $p = new WP_HTML_Tag_Processor( $new_html ); + $p->next_tag( array( 'class_name' => 'test' ) ); + return array( $p, $new_html ); + } + + /** + * Tests that a default namespace is applied when using the + * `data-wp-interactive` directive. + * + * @covers ::process_directives + */ + public function test_wp_interactive_sets_a_default_namespace() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the most recent `data-wp-interactive` directive replaces the + * previous default namespace. + * + * @covers ::process_directives + */ + public function test_wp_interactive_replaces_the_previous_default_namespace() { + $html = ' +
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'other-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that a `data-wp-interactive` directive without a namespace does not + * replace the previously established default namespace. + * + * @covers ::process_directives + */ + public function test_wp_interactive_without_namespace_doesnt_replace_the_previous_default_namespace() { + $html = ' +
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that an empty value for `data-wp-interactive` does not replace the + * previously established default namespace. + * + * @covers ::process_directives + */ + public function test_wp_interactive_with_empty_value_doesnt_replace_the_previous_default_namespace() { + $html = ' +
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that a `data-wp-interactive` directive with no assigned value does + * not replace the previously established default namespace. + * + * @covers ::process_directives + */ + public function test_wp_interactive_without_value_doesnt_replace_the_previous_default_namespace() { + $html = ' +
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that multiple `data-wp-interactive` directives work correctly. + * + * @covers ::process_directives + */ + public function test_wp_interactive_works_with_multiple_directives() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that a custom namespace can override the default one provided by a + * `data-wp-interactive` directive. + * + * @covers ::process_directives + */ + public function test_wp_interactive_namespace_can_be_override_by_custom_one() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'other-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-interactive` setting is reset appropriately after a + * closing HTML tag. + * + * @covers ::process_directives + */ + public function test_wp_interactive_set_is_unset_on_closing_tag() { + $html = ' +
+
Text
+
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'other-id', $p->get_attribute( 'id' ) ); + + $html = ' +
+
+
Text
+
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'other-id', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-style.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-style.php new file mode 100644 index 0000000000000..ce224787d8753 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-style.php @@ -0,0 +1,403 @@ +interactivity = new WP_Interactivity_API(); + $this->interactivity->state( + 'myPlugin', + array( + 'green' => 'green', + 'false' => false, + ) + ); + } + + /** + * Invokes the private `set_style_property` method of WP_Interactivity_API + * class. + * + * @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( $style_attribute_value, $style_property_name, $style_property_value ) { + $evaluate = new ReflectionMethod( $this->interactivity, 'set_style_property' ); + $evaluate->setAccessible( true ); + return $evaluate->invokeArgs( $this->interactivity, array( $style_attribute_value, $style_property_name, $style_property_value ) ); + } + + /** + * Tests that `set_style_property` correctly sets style properties. + * + * @covers ::set_style_property + */ + public function test_set_style_property_sets_properties() { + // Adds property on empty style attribute. + $result = $this->set_style_property( '', 'color', 'green' ); + $this->assertEquals( 'color:green;', $result ); + + // Changes style property when there is an existing property. + $result = $this->set_style_property( 'color:red;', 'color', 'green' ); + $this->assertEquals( 'color:green;', $result ); + + // Adds a new property when the existing one does not match. + $result = $this->set_style_property( 'color:red;', 'background', 'blue' ); + $this->assertEquals( 'color:red;background:blue;', $result ); + + // Handles multiple existing properties. + $result = $this->set_style_property( 'color:red;margin:5px;', 'color', 'green' ); + $this->assertEquals( 'margin:5px;color:green;', $result ); + + // Adds a new property when multiple existing properties do not match. + $result = $this->set_style_property( 'color:red;margin:5px;', 'padding', '10px' ); + $this->assertEquals( 'color:red;margin:5px;padding:10px;', $result ); + + // Removes whitespaces in all properties. + $result = $this->set_style_property( ' color : red; margin : 5px; ', 'padding', ' 10px ' ); + $this->assertEquals( 'color:red;margin:5px;padding:10px;', $result ); + + // Updates a property when it's not the first one in the value. + $result = $this->set_style_property( 'color:red;margin:5px;', 'margin', '15px' ); + $this->assertEquals( 'color:red;margin:15px;', $result ); + + // Adds missing trailing semicolon. + $result = $this->set_style_property( 'color:red;margin:5px', 'padding', '10px' ); + $this->assertEquals( 'color:red;margin:5px;padding:10px;', $result ); + + // Doesn't add double semicolons. + $result = $this->set_style_property( 'color:red;margin:5px;', 'padding', '10px;' ); + $this->assertEquals( 'color:red;margin:5px;padding:10px;', $result ); + + // Handles empty properties in the input. + $result = $this->set_style_property( 'color:red;;margin:5px;;', 'padding', '10px' ); + $this->assertEquals( 'color:red;margin:5px;padding:10px;', $result ); + + // Moves the modified property to the end. + $result = $this->set_style_property( 'border-style: dashed; border: 3px solid red;', 'border-style', 'inset' ); + $this->assertEquals( 'border:3px solid red;border-style:inset;', $result ); + } + + /** + * Tests that `set_style_property` works correctly with falsy values, removing + * or ignoring them as appropriate. + * + * @covers ::set_style_property + */ + public function test_set_style_property_with_falsy_values() { + // Removes a property with an empty string. + $result = $this->set_style_property( 'color:red;margin:5px;', 'color', '' ); + $this->assertEquals( 'margin:5px;', $result ); + + // Removes a property with null. + $result = $this->set_style_property( 'color:red;margin:5px;', 'color', null ); + $this->assertEquals( 'margin:5px;', $result ); + + // Removes a property with false. + $result = $this->set_style_property( 'color:red;margin:5px;', 'color', false ); + $this->assertEquals( 'margin:5px;', $result ); + + // Removes a property with 0. + $result = $this->set_style_property( 'color:red;margin:5px;', 'color', 0 ); + $this->assertEquals( 'margin:5px;', $result ); + + // It doesn't add a new property with an empty string. + $result = $this->set_style_property( 'color:red;', 'padding', '' ); + $this->assertEquals( 'color:red;', $result ); + + // It doesn't add a new property with null. + $result = $this->set_style_property( 'color:red;', 'padding', null ); + $this->assertEquals( 'color:red;', $result ); + + // It doesn't add a new property with false. + $result = $this->set_style_property( 'color:red;', 'padding', false ); + $this->assertEquals( 'color:red;', $result ); + + // It doesn't add a new property with 0. + $result = $this->set_style_property( 'color:red;', 'padding', 0 ); + $this->assertEquals( 'color:red;', $result ); + } + + /** + * Invokes the `process_directives` method of WP_Interactivity_API class. + * + * @param string $html The HTML that needs to be processed. + * @return array An array containing an instance of the WP_HTML_Tag_Processor and the processed HTML. + */ + private function process_directives( $html ) { + $new_html = $this->interactivity->process_directives( $html ); + $p = new WP_HTML_Tag_Processor( $new_html ); + $p->next_tag(); + return array( $p, $new_html ); + } + + /** + * Tests that the `data-wp-style` directive sets a style attribute with + * correct property and value. + * + * @covers ::process_directives + */ + public function test_wp_style_sets_style_attribute() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'color:green;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive applies multiple style properties + * correctly. + * + * @covers ::process_directives + */ + public function test_wp_style_sets_multiple_style_properties() { + $html = ' +
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'color:green;background:green;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive correctly handles different style + * property values. + * + * @covers ::process_directives + */ + public function test_wp_style_sets_multiple_style_properties_with_different_values() { + $html = ' +
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'color:green;', $p->get_attribute( 'style' ) ); + + $html = ' +
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'color:green;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive sets a new style property when + * another already exists. + * + * @covers ::process_directives + */ + public function test_wp_style_sets_style_property_when_style_attribute_exists() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'padding:10px;color:green;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive overwrites an existing style + * property with a new value. + * + * @covers ::process_directives + */ + public function test_wp_style_overwrites_style_property_when_style_property_exists() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'color:green;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive doesn't add a style property when + * the directive value is false. + * + * @covers ::process_directives + */ + public function test_wp_style_doesnt_add_style_attribute_on_false() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive doesn't modify existing style + * properties when directive value is false. + * + * @covers ::process_directives + */ + public function test_wp_style_doesnt_add_style_property_on_false() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'padding:10px;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive keeps an existing style property + * with a matching value. + * + * @covers ::process_directives + */ + public function test_wp_style_keeps_style_property_when_style_property_exists() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'color:green;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive maintains style properties even + * when they aren't the only ones present. + * + * @covers ::process_directives + */ + public function test_wp_style_keeps_style_property_when_style_property_exists_and_is_not_the_only_one() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'padding:10px;color:green;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive removes the style attribute when + * it contains only one property which is being removed. + * + * @covers ::process_directives + */ + public function test_wp_style_removes_style_attribute_when_style_property_exists_and_is_the_only_one() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive removes a style property when it's + * not the only one present and the directive value is false. + * + * @covers ::process_directives + */ + public function test_wp_style_removes_style_property_when_style_property_exists_and_is_not_the_only_one() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'padding:10px;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive does not remove an empty style + * attribute. + * + * @covers ::process_directives + */ + public function test_wp_style_doesnt_remove_empty_style_attribute() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertTrue( $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive does not change the style + * attribute when the directive suffix is empty. + * + * @covers ::process_directives + */ + public function test_wp_style_doesnt_change_style_attribute_with_empty_directive_suffix() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'padding:10px;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive does not change the style + * attribute when the value of the directive is empty. + * + * @covers ::process_directives + */ + public function test_wp_style_doesnt_change_style_attribute_with_empty_value() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'padding:10px;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive doesn't apply changes if no value + * is provided for the style property. + * + * @covers ::process_directives + */ + public function test_wp_style_doesnt_change_style_attribute_without_value() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'padding:10px;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive functions correctly with multiple + * identical directives. + * + * @covers ::process_directives + */ + public function test_wp_style_works_with_multiple_directives() { + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'color:green;', $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive doesn't apply any changes when the + * state value is true. + * + * @covers ::process_directives + */ + public function test_wp_style_doesnt_do_anything_on_true_values() { + $this->interactivity->state( 'myPlugin', array( 'true' => true ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'style' ) ); + } + + /** + * Tests that the `data-wp-style` directive doesn't add a style property for + * various falsy values in the state. + * + * @covers ::process_directives + */ + public function test_wp_style_doesnt_add_style_property_on_falsy_values() { + $this->interactivity->state( 'myPlugin', array( 'text' => '' ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'style' ) ); + + $this->interactivity->state( 'myPlugin', array( 'array' => array() ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'style' ) ); + + $this->interactivity->state( 'myPlugin', array( 'number' => 0 ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'style' ) ); + + $this->interactivity->state( 'myPlugin', array( 'null' => null ) ); + $html = '
Text
'; + list($p) = $this->process_directives( $html ); + $this->assertNull( $p->get_attribute( 'style' ) ); + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-text.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-text.php new file mode 100644 index 0000000000000..f5b570a197a29 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-text.php @@ -0,0 +1,123 @@ +interactivity = new WP_Interactivity_API(); + $this->interactivity->state( 'myPlugin', array( 'text' => 'Updated' ) ); + } + + /** + * Tests that the `data-wp-text` directive sets inner text content. + */ + public function test_wp_text_sets_inner_content() { + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
Updated
', $new_html ); + } + + /** + * Tests that the `data-wp-text` directive works with numerical values. + */ + public function test_wp_text_sets_inner_content_numbers() { + $this->interactivity->state( 'myPlugin', array( 'number' => 100 ) ); + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
100
', $new_html ); + } + + /** + * Tests that the `data-wp-text` directive removes inner text content when the + * state is not a string or number. + */ + public function test_wp_text_removes_inner_content_on_types_that_are_not_strings_or_numbers() { + $this->interactivity->state( + 'myPlugin', + array( + 'true' => true, + 'false' => false, + 'null' => null, + 'array' => array(), + 'func' => function () {}, + ) + ); + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
', $new_html ); + + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
', $new_html ); + + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
', $new_html ); + + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
', $new_html ); + + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
', $new_html ); + } + + /** + * Tests that the `data-wp-text` directive overwrites entire inner content, + * including nested tags. + */ + public function test_wp_text_sets_inner_content_with_nested_tags() { + $html = '
Text
Another text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
Updated
', $new_html ); + } + + /** + * Tests that the `data-wp-text` directive works even with unbalanced tags + * when they are different tags (div -> unbalanced span). + */ + public function test_wp_text_sets_inner_content_even_with_unbalanced_but_different_tags_inside_content() { + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
Updated
', $new_html ); + } + + /** + * Tests that the `data-wp-text` fails to overwrite inner content if there are + * unbalanced when they are the same tags (div -> unbalanced div). + */ + public function test_wp_text_fails_with_unbalanced_and_same_tags_inside_content() { + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
Text
', $new_html ); + } + + /** + * Tests that the `data-wp-text` directive cannot set inner HTML content and + * it will be encoded as text. + */ + public function test_wp_text_cant_set_inner_html_in_the_content() { + $this->interactivity->state( 'myPlugin', array( 'text' => 'Updated' ) ); + $html = '
Text
'; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( '
<span>Updated</span>
', $new_html ); + } +} diff --git a/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php new file mode 100644 index 0000000000000..719f9592563c3 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.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( '/