diff --git a/composer.json b/composer.json index 3aa7efe420ec0..632ebd9f63136 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "issues": "https://core.trac.wordpress.org/" }, "require": { + "ext-json": "*", "php": ">=7.0" }, "suggest": { diff --git a/src/wp-includes/interactivity-api.php b/src/wp-includes/interactivity-api.php deleted file mode 100644 index de9e877c9a761..0000000000000 --- a/src/wp-includes/interactivity-api.php +++ /dev/null @@ -1,26 +0,0 @@ -get_tag() ) { + return null; + } + + $positions = $this->get_after_opener_tag_and_before_closer_tag_positions(); + if ( ! $positions ) { + return null; + } + list( $after_opener_tag, $before_closer_tag ) = $positions; + + return substr( $this->html, $after_opener_tag, $before_closer_tag - $after_opener_tag ); + } + + /** + * Sets the content between two balanced tags. + * + * @since 6.5.0 + * + * @access private + * + * @param string $new_content The string to replace the content between the matching tags. + * @return bool Whether the content was successfully replaced. + */ + public function set_content_between_balanced_tags( string $new_content ): bool { + $positions = $this->get_after_opener_tag_and_before_closer_tag_positions( true ); + if ( ! $positions ) { + return false; + } + list( $after_opener_tag, $before_closer_tag ) = $positions; + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $after_opener_tag, + $before_closer_tag - $after_opener_tag, + esc_html( $new_content ) + ); + + return true; + } + + /** + * Appends content after the closing tag of a template tag. + * + * It positions the cursor in the closer tag of the balanced template tag, + * if it exists. + * + * @access private + * + * @param string $new_content The string to append after the closing template tag. + * @return bool Whether the content was successfully appended. + */ + public function append_content_after_template_tag_closer( string $new_content ): bool { + if ( empty( $new_content ) || 'TEMPLATE' !== $this->get_tag() || ! $this->is_tag_closer() ) { + return false; + } + + // Flushes any changes. + $this->get_updated_html(); + + $bookmark = 'append_content_after_template_tag_closer'; + $this->set_bookmark( $bookmark ); + $after_closing_tag = $this->bookmarks[ $bookmark ]->start + $this->bookmarks[ $bookmark ]->length + 1; + $this->release_bookmark( $bookmark ); + + // Appends the new content. + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $after_closing_tag, 0, $new_content ); + + return true; + } + + /** + * Gets the positions right after the opener tag and right before the closer + * tag in a balanced tag. + * + * By default, it positions the cursor in the closer tag of the balanced tag. + * If $rewind is true, it seeks back to the opener tag. + * + * @since 6.5.0 + * + * @access private + * + * @param bool $rewind Optional. Whether to seek back to the opener tag after finding the positions. Defaults to false. + * @return array|null Start and end byte position, or null when no balanced tag bookmarks. + */ + private function get_after_opener_tag_and_before_closer_tag_positions( bool $rewind = false ) { + // Flushes any changes. + $this->get_updated_html(); + + $bookmarks = $this->get_balanced_tag_bookmarks(); + if ( ! $bookmarks ) { + return null; + } + list( $opener_tag, $closer_tag ) = $bookmarks; + + $after_opener_tag = $this->bookmarks[ $opener_tag ]->start + $this->bookmarks[ $opener_tag ]->length + 1; + $before_closer_tag = $this->bookmarks[ $closer_tag ]->start; + + if ( $rewind ) { + $this->seek( $opener_tag ); + } + + $this->release_bookmark( $opener_tag ); + $this->release_bookmark( $closer_tag ); + + return array( $after_opener_tag, $before_closer_tag ); + } + + /** + * Returns a pair of bookmarks for the current opener tag and the matching + * closer tag. + * + * It positions the cursor in the closer tag of the balanced tag, if it + * exists. + * + * @since 6.5.0 + * + * @return array|null A pair of bookmarks, or null if there's no matching closing tag. + */ + private function get_balanced_tag_bookmarks() { + static $i = 0; + $opener_tag = 'opener_tag_of_balanced_tag_' . ++$i; + + $this->set_bookmark( $opener_tag ); + if ( ! $this->next_balanced_tag_closer_tag() ) { + $this->release_bookmark( $opener_tag ); + return null; + } + + $closer_tag = 'closer_tag_of_balanced_tag_' . ++$i; + $this->set_bookmark( $closer_tag ); + + return array( $opener_tag, $closer_tag ); + } + + /** + * Finds the matching closing tag for an opening tag. + * + * When called while the processor is on an open tag, it traverses the HTML + * until it finds the matching closer tag, respecting any in-between content, + * including nested tags of the same name. Returns false when called on a + * closer tag, a tag that doesn't have a closer tag (void), a tag that + * doesn't visit the closer tag, or if no matching closing tag was found. + * + * @since 6.5.0 + * + * @access private + * + * @return bool Whether a matching closing tag was found. + */ + public function next_balanced_tag_closer_tag(): bool { + $depth = 0; + $tag_name = $this->get_tag(); + + if ( ! $this->has_and_visits_its_closer_tag() ) { + 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 has and will visit its matching closer tag. + * + * @since 6.5.0 + * + * @access private + * + * @return bool Whether the current tag has a closer tag. + */ + public function has_and_visits_its_closer_tag(): bool { + $tag_name = $this->get_tag(); + + return null !== $tag_name && ( + ! WP_HTML_Processor::is_void( $tag_name ) && + ! in_array( $tag_name, self::TAGS_THAT_DONT_VISIT_CLOSER_TAG, true ) + ); + } +} diff --git a/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..bb491a54f4ee2 --- /dev/null +++ b/src/wp-includes/interactivity-api/class-wp-interactivity-api.php @@ -0,0 +1,950 @@ + 'data_wp_interactive_processor', + 'data-wp-router-region' => 'data_wp_router_region_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', + /* + * `data-wp-each` needs to be processed in the last place because it moves + * the cursor to the end of the processed items to prevent them to be + * processed twice. + */ + 'data-wp-each' => 'data_wp_each_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(); + + /** + * Flag that indicates whether the `data-wp-router-region` directive has + * been found in the HTML and processed. + * + * The value is saved in a private property of the WP_Interactivity_API + * instance instead of using a static variable inside the processor + * function, which would hold the same value for all instances + * independently of whether they have processed any + * `data-wp-router-region` directive or not. + * + * @since 6.5.0 + * @var bool + */ + private $has_processed_router_region = false; + + /** + * 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. This will be the updated state if a $state + * argument was provided. + */ + public function state( string $store_namespace, array $state = array() ): 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 configuration for the specified store namespace. This will be the updated configuration if a + * $config argument was provided. + */ + public function config( string $store_namespace, array $config = array() ): 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() { + $suffix = wp_scripts_get_suffix(); + + wp_register_script_module( + '@wordpress/interactivity', + includes_url( "js/dist/interactivity$suffix.js" ) + ); + + wp_register_script_module( + '@wordpress/interactivity-router', + includes_url( "js/dist/interactivity-router$suffix.js" ), + array( '@wordpress/interactivity' ) + ); + } + + /** + * Adds the necessary hooks for the Interactivity API. + * + * @since 6.5.0 + */ + public function add_hooks() { + add_action( 'wp_enqueue_scripts', array( $this, 'register_script_modules' ) ); + add_action( 'wp_footer', array( $this, 'print_client_interactivity_data' ) ); + } + + /** + * 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 { + $context_stack = array(); + $namespace_stack = array(); + $result = $this->process_directives_args( $html, $context_stack, $namespace_stack ); + return null === $result ? $html : $result; + } + + /** + * Processes the interactivity directives contained within the HTML content + * and updates the markup accordingly. + * + * It needs the context and namespace stacks to be passed by reference, and + * it returns null if the HTML contains unbalanced tags. + * + * @since 6.5.0 + * + * @param string $html The HTML content to process. + * @param array $context_stack The reference to the array used to keep track of contexts during processing. + * @param array $namespace_stack The reference to the array used to manage namespaces during processing. + * @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags. + */ + private function process_directives_args( string $html, array &$context_stack, array &$namespace_stack ) { + $p = new WP_Interactivity_API_Directives_Processor( $html ); + $tag_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' ) ) ) { + $tag_name = $p->get_tag(); + + if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) { + $unbalanced = true; + break; + } + + if ( $p->is_tag_closer() ) { + list( $opening_tag_name, $directives_prefixes ) = end( $tag_stack ); + + 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; + break; + } else { + // Remove the last tag from the stack. + array_pop( $tag_stack ); + } + } else { + if ( 0 !== count( $p->get_attribute_names_with_prefix( 'data-wp-each-child' ) ) ) { + /* + * If the tag has a `data-wp-each-child` directive, jump to its closer + * tag because those tags have already been processed. + */ + $p->next_balanced_tag_closer_tag(); + continue; + } else { + $directives_prefixes = array(); + + // Checks if there is a server directive processor registered for each directive. + foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { + list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { + $directives_prefixes[] = $directive_prefix; + } + } + + /* + * If this tag will visit its closer tag, it adds it to the tag stack + * so it can process its closing tag and check for unbalanced tags. + */ + if ( $p->has_and_visits_its_closer_tag() ) { + $tag_stack[] = array( $tag_name, $directives_prefixes ); + } + } + } + /* + * If the matching opener tag didn't have any directives, it can skip the + * processing. + */ + if ( 0 === count( $directives_prefixes ) ) { + continue; + } + + /* + * Sorts the attributes by the order of the `directives_processor` array + * 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, &$tag_stack ) + ); + } + } + + /* + * It returns null 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 ) ? null : $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' => $this->state_data[ $ns ] ?? array(), + 'context' => $context[ $ns ] ?? array(), + ); + + // Checks if the reference path is preceded by a negation 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 negation 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 represent 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 ); + } + + /** + * Transforms a kebab-case string to camelCase. + * + * @param string $str The kebab-case string to transform to camelCase. + * @return string The transformed camelCase string. + */ + private function kebab_to_camel_case( string $str ): string { + return lcfirst( + preg_replace_callback( + '/(-)([a-z])/', + function ( $matches ) { + return strtoupper( $matches[2] ); + }, + strtolower( rtrim( $str, '-' ) ) + ) + ); + } + + /** + * Processes the `data-wp-interactive` directive. + * + * It adds the default store namespace defined in the directive value to the + * stack so that 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() ) { + array_pop( $namespace_stack ); + return; + } + + // Tries to decode the `data-wp-interactive` attribute value. + $attribute_value = $p->get_attribute( 'data-wp-interactive' ); + + /* + * 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. + */ + $new_namespace = null; + if ( is_string( $attribute_value ) && ! empty( $attribute_value ) ) { + $decoded_json = json_decode( $attribute_value, true ); + if ( is_array( $decoded_json ) ) { + $new_namespace = $decoded_json['namespace'] ?? null; + } else { + $new_namespace = $attribute_value; + } + } + $namespace_stack[] = ( $new_namespace && 1 === preg_match( '/^([\w\-_\/]+)/', $new_namespace ) ) + ? $new_namespace + : end( $namespace_stack ); + } + + /** + * Processes the `data-wp-context` directive. + * + * It adds the context defined in the directive value to the stack so that + * 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() ) { + array_pop( $context_stack ); + return; + } + + $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 ) ) { + $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. + */ + $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_attribute_value ) { + $style_attribute_value = $this->merge_style_property( $style_attribute_value, $style_property, $style_property_value ); + /* + * If the style attribute value is not empty, it sets it. Otherwise, + * it removes it. + */ + if ( ! empty( $style_attribute_value ) ) { + $p->set_attribute( 'style', $style_attribute_value ); + } else { + $p->remove_attribute( 'style' ); + } + } + } + } + } + + /** + * Merges an individual style property in the `style` attribute of an HTML + * element, updating or removing the property when necessary. + * + * If a property is modified, the old one is removed and the new one is added + * at the end of the list. + * + * @since 6.5.0 + * + * Example: + * + * merge_style_property( 'color:green;', 'color', 'red' ) => 'color:red;' + * merge_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;' + * merge_style_property( 'color:green;', 'color', null ) => '' + * + * @param string $style_attribute_value The current style attribute value. + * @param string $style_property_name The style property name to set. + * @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 merge_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string { + $style_assignments = explode( ';', $style_attribute_value ); + $result = array(); + $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null; + $new_style_property = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : ''; + + // Generates 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 ) . ';'; + } + } + + // Adds the new/modified property at the end of the list. + $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( '' ); + } + } + } + + /** + * Returns the CSS styles for animating the top loading bar in the router. + * + * @since 6.5.0 + * + * @return string The CSS styles for the router's top loading bar animation. + */ + private function get_router_animation_styles(): string { + return << +
+HTML; + } + + /** + * Processes the `data-wp-router-region` directive. + * + * It renders in the footer a set of HTML elements to notify users about + * client-side navigations. More concretely, the elements added are 1) a + * top loading bar to visually inform that a navigation is in progress + * and 2) an `aria-live` region for accessible navigation announcements. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + */ + private function data_wp_router_region_processor( WP_Interactivity_API_Directives_Processor $p ) { + if ( ! $p->is_tag_closer() && ! $this->has_processed_router_region ) { + $this->has_processed_router_region = true; + + // Initialize the `core/router` store. + $this->state( + 'core/router', + array( + 'navigation' => array( + 'texts' => array( + 'loading' => __( 'Loading page, please wait.' ), + 'loaded' => __( 'Page Loaded.' ), + ), + ), + ) + ); + + // Enqueues as an inline style. + wp_register_style( 'wp-interactivity-router-animations', false ); + wp_add_inline_style( 'wp-interactivity-router-animations', $this->get_router_animation_styles() ); + wp_enqueue_style( 'wp-interactivity-router-animations' ); + + // Adds the necessary markup to the footer. + add_action( 'wp_footer', array( $this, 'print_router_loading_and_screen_reader_markup' ) ); + } + } + + /** + * Processes the `data-wp-each` directive. + * + * This directive gets an array passed as reference and iterates over it + * generating new content for each item based on the inner markup of the + * `template` tag. + * + * @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. + * @param array $tag_stack The reference to the tag stack. + */ + private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack, array &$tag_stack ) { + if ( ! $p->is_tag_closer() && 'TEMPLATE' === $p->get_tag() ) { + $attribute_name = $p->get_attribute_names_with_prefix( 'data-wp-each' )[0]; + $extracted_suffix = $this->extract_prefix_and_suffix( $attribute_name ); + $item_name = isset( $extracted_suffix[1] ) ? $this->kebab_to_camel_case( $extracted_suffix[1] ) : 'item'; + $attribute_value = $p->get_attribute( $attribute_name ); + $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) ); + + // Gets the content between the template tags and leaves the cursor in the closer tag. + $inner_content = $p->get_content_between_balanced_template_tags(); + + // Checks if there is a manual server-side directive processing. + $template_end = 'data-wp-each: template end'; + $p->set_bookmark( $template_end ); + $p->next_tag(); + $manual_sdp = $p->get_attribute( 'data-wp-each-child' ); + $p->seek( $template_end ); // Rewinds to the template closer tag. + $p->release_bookmark( $template_end ); + + /* + * It doesn't process in these situations: + * - Manual server-side directive processing. + * - Empty or non-array values. + * - Associative arrays because those are deserialized as objects in JS. + * - Templates that contain top-level texts because those texts can't be + * identified and removed in the client. + */ + if ( + $manual_sdp || + empty( $result ) || + ! is_array( $result ) || + ! array_is_list( $result ) || + ! str_starts_with( trim( $inner_content ), '<' ) || + ! str_ends_with( trim( $inner_content ), '>' ) + ) { + array_pop( $tag_stack ); + return; + } + + // Extracts the namespace from the directive attribute value. + $namespace_value = end( $namespace_stack ); + list( $namespace_value ) = is_string( $attribute_value ) && ! empty( $attribute_value ) + ? $this->extract_directive_value( $attribute_value, $namespace_value ) + : array( $namespace_value, null ); + + // Processes the inner content for each item of the array. + $processed_content = ''; + foreach ( $result as $item ) { + // Creates a new context that includes the current item of the array. + $context_stack[] = array_replace_recursive( + end( $context_stack ) !== false ? end( $context_stack ) : array(), + array( $namespace_value => array( $item_name => $item ) ) + ); + + // Processes the inner content with the new context. + $processed_item = $this->process_directives_args( $inner_content, $context_stack, $namespace_stack ); + + if ( null === $processed_item ) { + // If the HTML is unbalanced, stop processing it. + array_pop( $context_stack ); + return; + } + + // Adds the `data-wp-each-child` to each top-level tag. + $i = new WP_Interactivity_API_Directives_Processor( $processed_item ); + while ( $i->next_tag() ) { + $i->set_attribute( 'data-wp-each-child', true ); + $i->next_balanced_tag_closer_tag(); + } + $processed_content .= $i->get_updated_html(); + + // Removes the current context from the stack. + array_pop( $context_stack ); + } + + // Appends the processed content after the tag closer of the template. + $p->append_content_after_template_tag_closer( $processed_content ); + + // Pops the last tag because it skipped the closing tag of the template tag. + array_pop( $tag_stack ); + } + } +} 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..548fcc3638fb7 --- /dev/null +++ b/src/wp-includes/interactivity-api/interactivity-api.php @@ -0,0 +1,166 @@ +get_registered( $block_name ); + + if ( + isset( $block_name ) && + ( ( isset( $block_type->supports['interactivity'] ) && true === $block_type->supports['interactivity'] ) || + ( isset( $block_type->supports['interactivity']['interactive'] ) && true === $block_type->supports['interactivity']['interactive'] ) ) + ) { + // Annotates the root interactive block for processing. + $root_interactive_block = array( $block_name, $parsed_block ); + + /* + * Adds a filter to process the root interactive block once it has + * finished rendering. + */ + $process_interactive_blocks = static function ( string $content, array $parsed_block ) use ( &$root_interactive_block, &$process_interactive_blocks ): string { + // Checks whether the current block is the root interactive block. + list($root_block_name, $root_parsed_block) = $root_interactive_block; + if ( $root_block_name === $parsed_block['blockName'] && $parsed_block === $root_parsed_block ) { + // The root interactive blocks has finished rendering, process it. + $content = wp_interactivity_process_directives( $content ); + // Removes the filter and reset the root interactive block. + remove_filter( 'render_block_' . $parsed_block['blockName'], $process_interactive_blocks ); + $root_interactive_block = null; + } + return $content; + }; + + /* + * Uses a priority of 20 to ensure that other filters can add additional + * directives before the processing starts. + */ + add_filter( 'render_block_' . $block_name, $process_interactive_blocks, 20, 2 ); + } + } + + return $parsed_block; +} +add_filter( 'render_block_data', 'wp_interactivity_process_directives_of_interactive_blocks' ); + +/** + * Retrieves the main WP_Interactivity_API instance. + * + * It provides access to the WP_Interactivity_API instance, creating one if it + * doesn't exist yet. + * + * @global WP_Interactivity_API $wp_interactivity + * + * @since 6.5.0 + * + * @return WP_Interactivity_API The main WP_Interactivity_API instance. + */ +function wp_interactivity(): WP_Interactivity_API { + global $wp_interactivity; + if ( ! ( $wp_interactivity instanceof WP_Interactivity_API ) ) { + $wp_interactivity = new WP_Interactivity_API(); + } + return $wp_interactivity; +} + +/** + * 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( string $html ): string { + 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 state for the specified store namespace. This will be the updated state if a $state argument was + * provided. + */ +function wp_interactivity_state( string $store_namespace, array $state = array() ): array { + 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 configuration for the specified store namespace. This will be the updated configuration if a + * $config argument was provided. + */ +function wp_interactivity_config( string $store_namespace, array $config = array() ): array { + return wp_interactivity()->config( $store_namespace, $config ); +} + +/** + * Generates a `data-wp-context` directive attribute by encoding a context + * array. + * + * This helper function simplifies the creation of `data-wp-context` directives + * by providing a way to pass an array of data, which encodes into a JSON string + * safe for direct use as a HTML attribute value. + * + * Example: + * + *
true, 'count' => 0 ) ); ?>> + * + * @since 6.5.0 + * + * @param array $context The array of context data to encode. + * @param string $store_namespace Optional. The unique store namespace identifier. + * @return string A complete `data-wp-context` directive with a JSON encoded value representing the context array and + * the store namespace if specified. + */ +function data_wp_context( array $context, string $store_namespace = '' ): string { + return 'data-wp-context=\'' . + ( $store_namespace ? $store_namespace . '::' : '' ) . + ( empty( $context ) ? '{}' : wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ) . + '\''; +} diff --git a/src/wp-settings.php b/src/wp-settings.php index a80c661d52a65..541f1092a3d31 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -378,9 +378,12 @@ require ABSPATH . WPINC . '/fonts.php'; 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'; wp_script_modules()->add_hooks(); +wp_interactivity()->add_hooks(); $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..c98d74f415350 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/interactivity-api.php @@ -0,0 +1,393 @@ + function ( $attributes, $content ) { + return ' +
+ ' . + $content . + '
'; + }, + 'supports' => array( + 'interactivity' => true, + ), + ); + + register_block_type( 'test/interactive-block', $interactive_block ); + register_block_type( 'test/interactive-block-2', $interactive_block ); + + 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 . + '
'; + }, + ) + ); + } + + /** + * Tear down. + */ + public function tear_down() { + unregister_block_type( 'test/interactive-block' ); + unregister_block_type( 'test/interactive-block-2' ); + unregister_block_type( 'test/non-interactive-block' ); + parent::tear_down(); + } + + /** + * Tests processing of a single interactive block. + * + * @ticket 60356 + * + * @covers ::wp_interactivity_process_directives_of_interactive_blocks + */ + 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' ) ); + } + + /** + * Tests processing of multiple interactive blocks in parallel along with a + * non-interactive block. + * + * @ticket 60356 + * + * @covers ::wp_interactivity_process_directives_of_interactive_blocks + */ + 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' ) ); + $p->next_tag( array( 'class_name' => 'interactive/block-4' ) ); + $this->assertEquals( '4', $p->get_attribute( 'value' ) ); + } + + /** + * Tests processing of an interactive block inside a non-interactive block. + * + * @ticket 60356 + * + * @covers ::wp_interactivity_process_directives_of_interactive_blocks + */ + 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' ) ); + } + + /** + * Tests processing of multiple interactive blocks nested inside a + * non-interactive block. + * + * @ticket 60356 + * + * @covers ::wp_interactivity_process_directives_of_interactive_blocks + */ + 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' ) ); + } + + /** + * Tests processing of a single interactive block directive nested inside + * multiple non-interactive blocks. + * + * @ticket 60356 + * + * @covers ::wp_interactivity_process_directives_of_interactive_blocks + */ + 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' ) ); + } + + /** + * Tests processing of directives for an interactive block containing a + * non-interactive block without directives. + * + * @ticket 60356 + * + * @covers ::wp_interactivity_process_directives_of_interactive_blocks + */ + 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' ) ); + } + + /** + * Tests processing of directives for an interactive block containing a + * non-interactive block with directives. + * + * @ticket 60356 + * + * @covers ::wp_interactivity_process_directives_of_interactive_blocks + */ + 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' ) ); + } + + /** + * Tests processing of directives for an interactive block containing nested + * interactive and non-interactive blocks, checking proper propagation of + * context. + * + * @ticket 60356 + * + * @covers ::wp_interactivity_process_directives_of_interactive_blocks + */ + 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' ) ); + } + + /** + * Counter for the number of times the test directive processor is called. + * + * @var int + */ + private $data_wp_test_processor_count = 0; + + /** + * Test directive processor callback. + * + * Increments the $data_wp_test_processor_count every time a tag that is not a + * tag closer is processed. + * + * @param WP_HTML_Tag_Processor $p Instance of the processor handling the current HTML tag. + */ + 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; + } + } + + /** + * Tests that directives are only processed once for the root interactive + * blocks. + * + * This ensures that nested blocks do not trigger additional processing of the + * same directives, leading to incorrect behavior or performance issues. + * + * @ticket 60356 + * + * @covers ::wp_interactivity_process_directives_of_interactive_blocks + */ + 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 ); + $old_directive_processors = $directive_processors->getValue(); + $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' ); + $directive_processors->setValue( null, $old_directive_processors ); + } + + /** + * Tests that data_wp_context function correctly converts different array + * structures to a JSON string. + * + * @ticket 60356 + * + * @covers ::data_wp_context + */ + public function test_data_wp_context_with_different_arrays() { + $this->assertEquals( 'data-wp-context=\'{}\'', data_wp_context( array() ) ); + $this->assertEquals( + 'data-wp-context=\'{"a":1,"b":"2","c":true}\'', + data_wp_context( + array( + 'a' => 1, + 'b' => '2', + 'c' => true, + ) + ) + ); + $this->assertEquals( + 'data-wp-context=\'{"a":[1,2]}\'', + data_wp_context( array( 'a' => array( 1, 2 ) ) ) + ); + $this->assertEquals( + 'data-wp-context=\'[1,2]\'', + data_wp_context( array( 1, 2 ) ) + ); + } + + /** + * Tests that data_wp_context function correctly converts different array + * structures to a JSON string and adds a namespace. + * + * @ticket 60356 + * + * @covers ::data_wp_context + */ + public function test_data_wp_context_with_different_arrays_and_a_namespace() { + $this->assertEquals( 'data-wp-context=\'myPlugin::{}\'', data_wp_context( array(), 'myPlugin' ) ); + $this->assertEquals( + 'data-wp-context=\'myPlugin::{"a":1,"b":"2","c":true}\'', + data_wp_context( + array( + 'a' => 1, + 'b' => '2', + 'c' => true, + ), + 'myPlugin' + ) + ); + $this->assertEquals( + 'data-wp-context=\'myPlugin::{"a":[1,2]}\'', + data_wp_context( array( 'a' => array( 1, 2 ) ), 'myPlugin' ) + ); + $this->assertEquals( + 'data-wp-context=\'myPlugin::[1,2]\'', + data_wp_context( array( 1, 2 ), 'myPlugin' ) + ); + } + + /** + * Tests that data_wp_context function correctly applies the JSON encoding + * flags. This ensures that characters like `<`, `>`, `'`, or `&` are + * properly escaped in the JSON-encoded string to prevent potential XSS + * attacks. + * + * @ticket 60356 + * + * @covers ::data_wp_context + */ + public function test_data_wp_context_with_json_flags() { + $this->assertEquals( 'data-wp-context=\'{"tag":"\u003Cfoo\u003E"}\'', data_wp_context( array( 'tag' => '' ) ) ); + $this->assertEquals( 'data-wp-context=\'{"apos":"\u0027bar\u0027"}\'', data_wp_context( array( 'apos' => "'bar'" ) ) ); + $this->assertEquals( 'data-wp-context=\'{"quot":"\u0022baz\u0022"}\'', data_wp_context( array( 'quot' => '"baz"' ) ) ); + $this->assertEquals( 'data-wp-context=\'{"amp":"T\u0026T"}\'', data_wp_context( array( 'amp' => 'T&T' ) ) ); + } +} 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..2c530e4faf817 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-bind.php @@ -0,0 +1,382 @@ +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`. + * + * @ticket 60356 + * + * @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`. + * + * @ticket 60356 + * + * @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`. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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`. + * + * @ticket 60356 + * + * @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`. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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..54c9ee5af5e42 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-class.php @@ -0,0 +1,327 @@ +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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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..b56d38d05ec9b --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-context.php @@ -0,0 +1,522 @@ +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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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-each.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php new file mode 100644 index 0000000000000..eea7f3f9fe8e7 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-each.php @@ -0,0 +1,681 @@ +interactivity = new WP_Interactivity_API(); + $this->interactivity->state( 'myPlugin', array( 'list' => array( 1, 2 ) ) ); + $this->interactivity->state( 'myPlugin', array( 'after' => 'after-wp-each' ) ); + } + + /** + * Tests that the `data-wp-each` directive doesn't do anything if it's not on + * a template tag. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_doesnt_do_anything_on_non_template_tags() { + $original = ' +
+ +
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't do anything if the array + * is associative instead of indexed. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_doesnt_do_anything_on_associative_arrays() { + $this->interactivity->state( + 'myPlugin', + array( + 'assoc' => array( + 'one' => 1, + 'two' => 2, + ), + ) + ); + $original = ' + '; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with simple tags. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_simple_tags() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't do anything if the array is + * empty. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_empty_array() { + $this->interactivity->state( 'myPlugin', array( 'empty' => array() ) ); + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive merges the item with the previous + * context correctly. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_merges_context_correctly() { + $original = '' . + '
' . + '' . + '
Text
' . + '
'; + $expected = '' . + '
' . + '' . + '1' . + '2' . + '
New text
' . + '
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with arrays from the context. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_gets_arrays_from_context() { + $original = '' . + '
' . + '' . + '
Text
' . + '
'; + $expected = '' . + '
' . + '' . + '1' . + '2' . + '
Text
' . + '
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with the default namespace. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_default_namespace() { + $original = '' . + '
' . + '' . + '
Text
' . + '
'; + $expected = '' . + '
' . + '' . + '1' . + '2' . + '
Text
' . + '
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with multiple tags per item. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_multiple_tags_per_item() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + '1' . + '2' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with void tags. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_void_tags() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '' . + '' . + '' . + '' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with combinations of void and + * non-void tags. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_void_and_non_void_tags() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '' . + '1' . + '' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with nested tags. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_nested_tags() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '
' . + 'id: 1' . + '
' . + '
' . + 'id: 2' . + '
' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with nested item properties. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_nested_item_properties() { + $this->interactivity->state( + 'myPlugin', + array( + 'list' => array( + array( + 'id' => 1, + 'name' => 'one', + ), + array( + 'id' => 2, + 'name' => 'two', + ), + ), + ) + ); + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + 'one' . + '2' . + 'two' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with different item names. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_different_item_names() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive transforms kebab-case into + * camelCase. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_different_item_names_transforms_camelcase() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't work with top-level texts. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_doesnt_work_with_top_level_text() { + $original = '' . + ''; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + + $original = '' . + ''; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + + // But it should work fine with spaces and linebreaks. + $original = ' + '; + $new = $this->interactivity->process_directives( $original ); + $p = new WP_HTML_Tag_Processor( $new ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( '1', $p->get_attribute( 'id' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( '2', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that the `data-wp-each` directive works with nested template tags. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_nested_template_tags() { + $this->interactivity->state( 'myPlugin', array( 'list2' => array( 3, 4 ) ) ); + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + '' . + '3' . + '4' . + '2' . + '' . + '3' . + '4' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with directly nested template + * tags. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_directly_nested_template_tags() { + $this->interactivity->state( 'myPlugin', array( 'list2' => array( 3, 4 ) ) ); + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '' . + '1' . + '3' . + '1' . + '4' . + '' . + '2' . + '3' . + '2' . + '4' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with nestded template tags + * that use a previous item as a list. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_nested_template_tags_using_previous_item_as_list() { + $this->interactivity->state( 'myPlugin', array( 'list2' => array( array( 1, 2 ), array( 3, 4 ) ) ) ); + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '' . + '1' . + '2' . + '' . + '3' . + '4' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't process unbalanced tags. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_unbalanced_tags() { + $original = '' . + '' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't process unbalanced tags in + * nested templates. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_unbalanced_tags_in_nested_template_tags() { + $this->interactivity->state( 'myPlugin', array( 'list2' => array( 3, 4 ) ) ); + $original = '' . + '' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't process if it doesn't get + * an array. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_doesnt_process_if_not_array() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '
Text
'; + + $this->interactivity->state( 'myPlugin', array( 'list' => null ) ); + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + + $this->interactivity->state( 'myPlugin', array( 'list' => 'Text' ) ); + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + + $this->interactivity->state( 'myPlugin', array( 'list' => 100 ) ); + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + + $this->interactivity->state( 'myPlugin', array( 'list' => false ) ); + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + + $this->interactivity->state( 'myPlugin', array( 'list' => true ) ); + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't process anything if it + * detects manual server-side processing. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_each_doesnt_process_with_manual_server_directive_processing() { + $original = '' . + '' . + '1' . + '2' . + '
Text
'; + $expected = '' . + '' . + '1' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } +} 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..277287a553ba3 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-interactive.php @@ -0,0 +1,266 @@ +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 with a json object. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_interactive_sets_a_default_namespace_with_object() { + $html = ' +
+
Text
+
+ '; + list($p) = $this->process_directives( $html ); + $this->assertEquals( 'some-id', $p->get_attribute( 'id' ) ); + } + + /** + * Tests that a default namespace is applied when using the + * `data-wp-interactive` directive with a string. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_interactive_sets_a_default_namespace_with_string() { + $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. + * + * @ticket 60356 + * + * @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 with a json object that + * doesn't have a namespace property does not replace the previously + * established default namespace. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_interactive_json_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. + * + * @ticket 60356 + * + * @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 an invalid value for `data-wp-interactive` does not replace the + * previously established default namespace. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_interactive_with_invalid_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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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-router-region.php b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-router-region.php new file mode 100644 index 0000000000000..a02ad6920b5a4 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-router-region.php @@ -0,0 +1,131 @@ +interactivity = new WP_Interactivity_API(); + + // Removes all hooks set for `wp_footer`. + global $wp_filter; + $this->original_wp_footer = $wp_filter['wp_footer']; + $wp_filter['wp_footer'] = new WP_Hook(); + + // Removes all registered styles. + $this->original_wp_styles = isset( $GLOBALS['wp_styles'] ) ? $GLOBALS['wp_styles'] : null; + $GLOBALS['wp_styles'] = new WP_Styles(); + remove_action( 'wp_default_styles', 'wp_default_styles' ); + remove_action( 'wp_print_styles', 'print_emoji_styles' ); + } + + /** + * Tear down. + */ + public function tear_down() { + // Restores all previous hooks set for `wp_footer`. + global $wp_filter; + $wp_filter['wp_footer'] = $this->original_wp_footer; + + // Restores all previous registered styles. + $GLOBALS['wp_styles'] = $this->original_wp_styles; + add_action( 'wp_default_styles', 'wp_default_styles' ); + add_action( 'wp_print_styles', 'print_emoji_styles' ); + + parent::tear_down(); + } + + /** + * Executes the hooks associated to `wp_footer`. + */ + protected function render_wp_footer() { + ob_start(); + do_action( 'wp_footer' ); + return ob_get_clean(); + } + + /** + * Tests that no elements are added if the `data-wp-router-region` is + * missing. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_router_region_missing() { + $html = '
Nothing here
'; + $new_html = $this->interactivity->process_directives( $html ); + $footer = $this->render_wp_footer(); + $this->assertEquals( $html, $new_html ); + $this->assertEquals( '', $footer ); + $this->assertEquals( '', get_echo( 'wp_print_styles' ) ); + } + + /** + * Tests that the `data-wp-router-region` directive adds a loading bar and a + * region for screen reader announcements in the footer, and styles for the + * loading bar. Also checks that the markup and styles are only added once. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + public function test_wp_router_region_adds_loading_bar_aria_live_region_only_once() { + $html = ' +
Interactive region
+
Another interactive region
+ '; + $new_html = $this->interactivity->process_directives( $html ); + $this->assertEquals( $html, $new_html ); + + // Check that the style is loaded, but only once. + $styles = get_echo( 'wp_print_styles' ); + $query = array( 'tag_name' => 'style' ); + $p = new WP_HTML_Tag_Processor( $styles ); + $this->assertTrue( $p->next_tag( $query ) ); + $this->assertEquals( 'wp-interactivity-router-animations-inline-css', $p->get_attribute( 'id' ) ); + $this->assertStringContainsString( '.wp-interactivity-router-loading-bar', $styles ); + $this->assertFalse( $p->next_tag( $query ) ); + + // Check that the markup is loaded, but only once. + $footer = $this->render_wp_footer(); + $query = array( 'class_name' => 'wp-interactivity-router-loading-bar' ); + $p = new WP_HTML_Tag_Processor( $footer ); + $this->assertTrue( $p->next_tag( $query ) ); + $this->assertFalse( $p->next_tag( $query ) ); + $query = array( 'class_name' => 'screen-reader-text' ); + $p = new WP_HTML_Tag_Processor( $footer ); + $this->assertTrue( $p->next_tag( $query ) ); + $this->assertFalse( $p->next_tag( $query ) ); + } +} 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..46fa541fb79cc --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-style.php @@ -0,0 +1,445 @@ +interactivity = new WP_Interactivity_API(); + $this->interactivity->state( + 'myPlugin', + array( + 'green' => 'green', + 'false' => false, + ) + ); + } + + /** + * Invokes the private `merge_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 merge_style_property( $style_attribute_value, $style_property_name, $style_property_value ) { + $evaluate = new ReflectionMethod( $this->interactivity, 'merge_style_property' ); + $evaluate->setAccessible( true ); + return $evaluate->invokeArgs( $this->interactivity, array( $style_attribute_value, $style_property_name, $style_property_value ) ); + } + + /** + * Tests that `merge_style_property` correctly sets style properties. + * + * @ticket 60356 + * + * @covers ::merge_style_property + */ + public function test_merge_style_property_sets_properties() { + // Adds property on empty style attribute. + $result = $this->merge_style_property( '', 'color', 'green' ); + $this->assertEquals( 'color:green;', $result ); + + // Changes style property when there is an existing property. + $result = $this->merge_style_property( 'color:red;', 'color', 'green' ); + $this->assertEquals( 'color:green;', $result ); + + // Adds a new property when the existing one does not match. + $result = $this->merge_style_property( 'color:red;', 'background', 'blue' ); + $this->assertEquals( 'color:red;background:blue;', $result ); + + // Handles multiple existing properties. + $result = $this->merge_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->merge_style_property( 'color:red;margin:5px;', 'padding', '10px' ); + $this->assertEquals( 'color:red;margin:5px;padding:10px;', $result ); + + // Removes whitespaces in all properties. + $result = $this->merge_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->merge_style_property( 'color:red;margin:5px;', 'margin', '15px' ); + $this->assertEquals( 'color:red;margin:15px;', $result ); + + // Adds missing trailing semicolon. + $result = $this->merge_style_property( 'color:red;margin:5px', 'padding', '10px' ); + $this->assertEquals( 'color:red;margin:5px;padding:10px;', $result ); + + // Doesn't add double semicolons. + $result = $this->merge_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->merge_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->merge_style_property( 'border-style: dashed; border: 3px solid red;', 'border-style', 'inset' ); + $this->assertEquals( 'border:3px solid red;border-style:inset;', $result ); + } + + /** + * Tests that `merge_style_property` works correctly with falsy values, + * removing or ignoring them as appropriate. + * + * @ticket 60356 + * + * @covers ::merge_style_property + */ + public function test_merge_style_property_with_falsy_values() { + // Removes a property with an empty string. + $result = $this->merge_style_property( 'color:red;margin:5px;', 'color', '' ); + $this->assertEquals( 'margin:5px;', $result ); + + // Removes a property with null. + $result = $this->merge_style_property( 'color:red;margin:5px;', 'color', null ); + $this->assertEquals( 'margin:5px;', $result ); + + // Removes a property with false. + $result = $this->merge_style_property( 'color:red;margin:5px;', 'color', false ); + $this->assertEquals( 'margin:5px;', $result ); + + // Removes a property with 0. + $result = $this->merge_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->merge_style_property( 'color:red;', 'padding', '' ); + $this->assertEquals( 'color:red;', $result ); + + // It doesn't add a new property with null. + $result = $this->merge_style_property( 'color:red;', 'padding', null ); + $this->assertEquals( 'color:red;', $result ); + + // It doesn't add a new property with false. + $result = $this->merge_style_property( 'color:red;', 'padding', false ); + $this->assertEquals( 'color:red;', $result ); + + // It doesn't add a new property with 0. + $result = $this->merge_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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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..b33ad7c82761f --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-text.php @@ -0,0 +1,153 @@ +interactivity = new WP_Interactivity_API(); + $this->interactivity->state( 'myPlugin', array( 'text' => 'Updated' ) ); + } + + /** + * Tests that the `data-wp-text` directive sets inner text content. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + 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. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + 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. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + 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. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + 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). + * + * @ticket 60356 + * + * @covers ::process_directives + */ + 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). + * + * @ticket 60356 + * + * @covers ::process_directives + */ + 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. + * + * @ticket 60356 + * + * @covers ::process_directives + */ + 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..5e8298a77e449 --- /dev/null +++ b/tests/phpunit/tests/interactivity-api/wpInteractivityAPI.php @@ -0,0 +1,694 @@ +interactivity = new WP_Interactivity_API(); + } + + /** + * Tests that the state and config methods return an empty array at the + * beginning. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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. + * + * @ticket 60356 + * + * @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( '/