diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php
new file mode 100644
index 00000000000000..b437bcefa67568
--- /dev/null
+++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php
@@ -0,0 +1,143 @@
+get_balanced_tag_bookmarks();
+ if ( ! $bookmarks ) {
+ return null;
+ }
+ list( $start_name, $end_name ) = $bookmarks;
+
+ $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1;
+ $end = $this->bookmarks[ $end_name ]->start;
+
+ $this->seek( $start_name );
+ $this->release_bookmark( $start_name );
+ $this->release_bookmark( $end_name );
+
+ return substr( $this->html, $start, $end - $start );
+ }
+
+ /**
+ * Sets the content between two balanced tags.
+ *
+ * @access private
+ *
+ * @param string $new_content The string to replace the content between the matching tags.
+ * @return bool Whether the content was successfully replaced.
+ */
+ public function set_content_between_balanced_tags( string $new_content ): bool {
+ $this->get_updated_html();
+
+ $bookmarks = $this->get_balanced_tag_bookmarks();
+ if ( ! $bookmarks ) {
+ return false;
+ }
+ list( $start_name, $end_name ) = $bookmarks;
+
+ $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1;
+ $end = $this->bookmarks[ $end_name ]->start;
+
+ $this->seek( $start_name );
+ $this->release_bookmark( $start_name );
+ $this->release_bookmark( $end_name );
+
+ $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $start, $end - $start, esc_html( $new_content ) );
+ return true;
+ }
+
+ /**
+ * Returns a pair of bookmarks for the current opening tag and the matching
+ * closing tag.
+ *
+ * @return array|null A pair of bookmarks, or null if there's no matching closing tag.
+ */
+ private function get_balanced_tag_bookmarks() {
+ static $i = 0;
+ $start_name = 'start_of_balanced_tag_' . ++$i;
+
+ $this->set_bookmark( $start_name );
+ if ( ! $this->next_balanced_closer() ) {
+ $this->release_bookmark( $start_name );
+ return null;
+ }
+
+ $end_name = 'end_of_balanced_tag_' . ++$i;
+ $this->set_bookmark( $end_name );
+
+ return array( $start_name, $end_name );
+ }
+
+ /**
+ * Finds the matching closing tag for an opening tag.
+ *
+ * When called while the processor is on an open tag, it traverses the HTML
+ * until it finds the matching closing tag, respecting any in-between content,
+ * including nested tags of the same name. Returns false when called on a
+ * closing or void tag, or if no matching closing tag was found.
+ *
+ * @return bool Whether a matching closing tag was found.
+ */
+ private function next_balanced_closer(): bool {
+ $depth = 0;
+ $tag_name = $this->get_tag();
+
+ if ( $this->is_void() ) {
+ return false;
+ }
+
+ while ( $this->next_tag(
+ array(
+ 'tag_name' => $tag_name,
+ 'tag_closers' => 'visit',
+ )
+ ) ) {
+ if ( ! $this->is_tag_closer() ) {
+ ++$depth;
+ continue;
+ }
+
+ if ( 0 === $depth ) {
+ return true;
+ }
+
+ --$depth;
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks whether the current tag is void.
+ *
+ * @access private
+ *
+ * @return bool Whether the current tag is void or not.
+ */
+ public function is_void(): bool {
+ $tag_name = $this->get_tag();
+ return Gutenberg_HTML_Processor_6_5::is_void( null !== $tag_name ? $tag_name : '' );
+ }
+ }
+}
diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php
new file mode 100644
index 00000000000000..de1d8b2a9e7890
--- /dev/null
+++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php
@@ -0,0 +1,671 @@
+ 'data_wp_interactive_processor',
+ 'data-wp-context' => 'data_wp_context_processor',
+ 'data-wp-bind' => 'data_wp_bind_processor',
+ 'data-wp-class' => 'data_wp_class_processor',
+ 'data-wp-style' => 'data_wp_style_processor',
+ 'data-wp-text' => 'data_wp_text_processor',
+ );
+
+ /**
+ * Holds the initial state of the different Interactivity API stores.
+ *
+ * This state is used during the server directive processing. Then, it is
+ * serialized and sent to the client as part of the interactivity data to be
+ * recovered during the hydration of the client interactivity stores.
+ *
+ * @since 6.5.0
+ * @var array
+ */
+ private $state_data = array();
+
+ /**
+ * Holds the configuration required by the different Interactivity API stores.
+ *
+ * This configuration is serialized and sent to the client as part of the
+ * interactivity data and can be accessed by the client interactivity stores.
+ *
+ * @since 6.5.0
+ * @var array
+ */
+ private $config_data = array();
+
+ /**
+ * Gets and/or sets the initial state of an Interactivity API store for a
+ * given namespace.
+ *
+ * If state for that store namespace already exists, it merges the new
+ * provided state with the existing one.
+ *
+ * @since 6.5.0
+ *
+ * @param string $store_namespace The unique store namespace identifier.
+ * @param array $state Optional. The array that will be merged with the existing state for the specified
+ * store namespace.
+ * @return array The current state for the specified store namespace.
+ */
+ public function state( string $store_namespace, array $state = null ): array {
+ if ( ! isset( $this->state_data[ $store_namespace ] ) ) {
+ $this->state_data[ $store_namespace ] = array();
+ }
+ if ( is_array( $state ) ) {
+ $this->state_data[ $store_namespace ] = array_replace_recursive(
+ $this->state_data[ $store_namespace ],
+ $state
+ );
+ }
+ return $this->state_data[ $store_namespace ];
+ }
+
+ /**
+ * Gets and/or sets the configuration of the Interactivity API for a given
+ * store namespace.
+ *
+ * If configuration for that store namespace exists, it merges the new
+ * provided configuration with the existing one.
+ *
+ * @since 6.5.0
+ *
+ * @param string $store_namespace The unique store namespace identifier.
+ * @param array $config Optional. The array that will be merged with the existing configuration for the
+ * specified store namespace.
+ * @return array The current configuration for the specified store namespace.
+ */
+ public function config( string $store_namespace, array $config = null ): array {
+ if ( ! isset( $this->config_data[ $store_namespace ] ) ) {
+ $this->config_data[ $store_namespace ] = array();
+ }
+ if ( is_array( $config ) ) {
+ $this->config_data[ $store_namespace ] = array_replace_recursive(
+ $this->config_data[ $store_namespace ],
+ $config
+ );
+ }
+ return $this->config_data[ $store_namespace ];
+ }
+
+ /**
+ * Prints the serialized client-side interactivity data.
+ *
+ * Encodes the config and initial state into JSON and prints them inside a
+ * script tag of type "application/json". Once in the browser, the state will
+ * be parsed and used to hydrate the client-side interactivity stores and the
+ * configuration will be available using a `getConfig` utility.
+ *
+ * @since 6.5.0
+ */
+ public function print_client_interactivity_data() {
+ $store = array();
+ $has_state = ! empty( $this->state_data );
+ $has_config = ! empty( $this->config_data );
+
+ if ( $has_state || $has_config ) {
+ if ( $has_config ) {
+ $store['config'] = $this->config_data;
+ }
+ if ( $has_state ) {
+ $store['state'] = $this->state_data;
+ }
+ wp_print_inline_script_tag(
+ wp_json_encode(
+ $store,
+ JSON_HEX_TAG | JSON_HEX_AMP
+ ),
+ array(
+ 'type' => 'application/json',
+ 'id' => 'wp-interactivity-data',
+ )
+ );
+ }
+ }
+
+ /**
+ * Registers the `@wordpress/interactivity` script modules.
+ *
+ * @since 6.5.0
+ */
+ public function register_script_modules() {
+ wp_register_script_module(
+ '@wordpress/interactivity',
+ gutenberg_url( '/build/interactivity/index.min.js' ),
+ array(),
+ defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' )
+ );
+ }
+
+ /**
+ * Adds the necessary hooks for the Interactivity API.
+ *
+ * @since 6.5.0
+ */
+ public function add_hooks() {
+ add_action( 'wp_footer', array( $this, 'print_client_interactivity_data' ) );
+ }
+
+ /**
+ * Processes the interactivity directives contained within the HTML content
+ * and updates the markup accordingly.
+ *
+ * @since 6.5.0
+ *
+ * @param string $html The HTML content to process.
+ * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags.
+ */
+ public function process_directives( string $html ): string {
+ $p = new WP_Interactivity_API_Directives_Processor( $html );
+ $tag_stack = array();
+ $namespace_stack = array();
+ $context_stack = array();
+ $unbalanced = false;
+
+ $directive_processor_prefixes = array_keys( self::$directive_processors );
+ $directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes );
+
+ while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) && false === $unbalanced ) {
+ $tag_name = $p->get_tag();
+
+ if ( $p->is_tag_closer() ) {
+ list( $opening_tag_name, $directives_prefixes ) = end( $tag_stack );
+
+ if ( 0 === count( $tag_stack ) || $opening_tag_name !== $tag_name ) {
+
+ /*
+ * If the tag stack is empty or the matching opening tag is not the
+ * same than the closing tag, it means the HTML is unbalanced and it
+ * stops processing it.
+ */
+ $unbalanced = true;
+ continue;
+ } else {
+
+ /*
+ * It removes the last tag from the stack.
+ */
+ array_pop( $tag_stack );
+
+ /*
+ * If the matching opening tag didn't have any directives, it can skip
+ * the processing.
+ */
+ if ( 0 === count( $directives_prefixes ) ) {
+ continue;
+ }
+ }
+ } else {
+ $directives_prefixes = array();
+
+ foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) {
+
+ /*
+ * Extracts the directive prefix to see if there is a server directive
+ * processor registered for that directive.
+ */
+ list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name );
+ if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
+ $directives_prefixes[] = $directive_prefix;
+ }
+ }
+
+ /*
+ * If this is not a void element, it adds it to the tag stack so it can
+ * process its closing tag and check for unbalanced tags.
+ */
+ if ( ! $p->is_void() ) {
+ $tag_stack[] = array( $tag_name, $directives_prefixes );
+ }
+ }
+
+ /*
+ * Sorts the attributes by the order of the `directives_processor` array
+ * and checks what directives are present in this element. The processing
+ * order is reversed for tag closers.
+ */
+ $directives_prefixes = array_intersect(
+ $p->is_tag_closer()
+ ? $directive_processor_prefixes_reversed
+ : $directive_processor_prefixes,
+ $directives_prefixes
+ );
+
+ // Executes the directive processors present in this element.
+ foreach ( $directives_prefixes as $directive_prefix ) {
+ $func = is_array( self::$directive_processors[ $directive_prefix ] )
+ ? self::$directive_processors[ $directive_prefix ]
+ : array( $this, self::$directive_processors[ $directive_prefix ] );
+ call_user_func_array(
+ $func,
+ array( $p, &$context_stack, &$namespace_stack )
+ );
+ }
+ }
+
+ /*
+ * It returns the original content if the HTML is unbalanced because
+ * unbalanced HTML is not safe to process. In that case, the Interactivity
+ * API runtime will update the HTML on the client side during the hydration.
+ */
+ return $unbalanced || 0 < count( $tag_stack ) ? $html : $p->get_updated_html();
+ }
+
+ /**
+ * Evaluates the reference path passed to a directive based on the current
+ * store namespace, state and context.
+ *
+ * @since 6.5.0
+ *
+ * @param string|true $directive_value The directive attribute value string or `true` when it's a boolean attribute.
+ * @param string $default_namespace The default namespace to use if none is explicitly defined in the directive
+ * value.
+ * @param array|false $context The current context for evaluating the directive or false if there is no
+ * context.
+ * @return mixed|null The result of the evaluation. Null if the reference path doesn't exist.
+ */
+ private function evaluate( $directive_value, string $default_namespace, $context = false ) {
+ list( $ns, $path ) = $this->extract_directive_value( $directive_value, $default_namespace );
+ if ( empty( $path ) ) {
+ return null;
+ }
+
+ $store = array(
+ 'state' => isset( $this->state_data[ $ns ] ) ? $this->state_data[ $ns ] : array(),
+ 'context' => isset( $context[ $ns ] ) ? $context[ $ns ] : array(),
+ );
+
+ // Checks if the reference path is preceded by a negator operator (!).
+ $should_negate_value = '!' === $path[0];
+ $path = $should_negate_value ? substr( $path, 1 ) : $path;
+
+ // Extracts the value from the store using the reference path.
+ $path_segments = explode( '.', $path );
+ $current = $store;
+ foreach ( $path_segments as $path_segment ) {
+ if ( isset( $current[ $path_segment ] ) ) {
+ $current = $current[ $path_segment ];
+ } else {
+ return null;
+ }
+ }
+
+ // Returns the opposite if it contains a negator operator (!).
+ return $should_negate_value ? ! $current : $current;
+ }
+
+ /**
+ * Extracts the directive attribute name to separate and return the directive
+ * prefix and an optional suffix.
+ *
+ * The suffix is the string after the first double hyphen and the prefix is
+ * everything that comes before the suffix.
+ *
+ * Example:
+ *
+ * extract_prefix_and_suffix( 'data-wp-interactive' ) => array( 'data-wp-interactive', null )
+ * extract_prefix_and_suffix( 'data-wp-bind--src' ) => array( 'data-wp-bind', 'src' )
+ * extract_prefix_and_suffix( 'data-wp-foo--and--bar' ) => array( 'data-wp-foo', 'and--bar' )
+ *
+ * @since 6.5.0
+ *
+ * @param string $directive_name The directive attribute name.
+ * @return array An array containing the directive prefix and optional suffix.
+ */
+ private function extract_prefix_and_suffix( string $directive_name ): array {
+ return explode( '--', $directive_name, 2 );
+ }
+
+ /**
+ * Parses and extracts the namespace and reference path from the given
+ * directive attribute value.
+ *
+ * If the value doesn't contain an explicit namespace, it returns the
+ * default one. If the value contains a JSON object instead of a reference
+ * path, the function tries to parse it and return the resulting array. If
+ * the value contains strings that reprenset booleans ("true" and "false"),
+ * numbers ("1" and "1.2") or "null", the function also transform them to
+ * regular booleans, numbers and `null`.
+ *
+ * Example:
+ *
+ * extract_directive_value( 'actions.foo', 'myPlugin' ) => array( 'myPlugin', 'actions.foo' )
+ * extract_directive_value( 'otherPlugin::actions.foo', 'myPlugin' ) => array( 'otherPlugin', 'actions.foo' )
+ * extract_directive_value( '{ "isOpen": false }', 'myPlugin' ) => array( 'myPlugin', array( 'isOpen' => false ) )
+ * extract_directive_value( 'otherPlugin::{ "isOpen": false }', 'myPlugin' ) => array( 'otherPlugin', array( 'isOpen' => false ) )
+ *
+ * @since 6.5.0
+ *
+ * @param string|true $directive_value The directive attribute value. It can be `true` when it's a boolean
+ * attribute.
+ * @param string|null $default_namespace Optional. The default namespace if none is explicitly defined.
+ * @return array An array containing the namespace in the first item and the JSON, the reference path, or null on the
+ * second item.
+ */
+ private function extract_directive_value( $directive_value, $default_namespace = null ): array {
+ if ( empty( $directive_value ) || is_bool( $directive_value ) ) {
+ return array( $default_namespace, null );
+ }
+
+ // Replaces the value and namespace if there is a namespace in the value.
+ if ( 1 === preg_match( '/^([\w\-_\/]+)::./', $directive_value ) ) {
+ list($default_namespace, $directive_value) = explode( '::', $directive_value, 2 );
+ }
+
+ /*
+ * Tries to decode the value as a JSON object. If it fails and the value
+ * isn't `null`, it returns the value as it is. Otherwise, it returns the
+ * decoded JSON or null for the string `null`.
+ */
+ $decoded_json = json_decode( $directive_value, true );
+ if ( null !== $decoded_json || 'null' === $directive_value ) {
+ $directive_value = $decoded_json;
+ }
+
+ return array( $default_namespace, $directive_value );
+ }
+
+
+ /**
+ * Processes the `data-wp-interactive` directive.
+ *
+ * It adds the default store namespace defined in the directive value to the
+ * stack so it's available for the nested interactivity elements.
+ *
+ * @since 6.5.0
+ *
+ * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
+ * @param array $context_stack The reference to the context stack.
+ * @param array $namespace_stack The reference to the store namespace stack.
+ */
+ private function data_wp_interactive_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ // In closing tags, it removes the last namespace from the stack.
+ if ( $p->is_tag_closer() ) {
+ return array_pop( $namespace_stack );
+ }
+
+ // Tries to decode the `data-wp-interactive` attribute value.
+ $attribute_value = $p->get_attribute( 'data-wp-interactive' );
+ $decoded_json = is_string( $attribute_value ) && ! empty( $attribute_value )
+ ? json_decode( $attribute_value, true )
+ : null;
+
+ /*
+ * Pushes the newly defined namespace or the current one if the
+ * `data-wp-interactive` definition was invalid or does not contain a
+ * namespace. It does so because the function pops out the current namespace
+ * from the stack whenever it finds a `data-wp-interactive`'s closing tag,
+ * independently of whether the previous `data-wp-interactive` definition
+ * contained a valid namespace.
+ */
+ $namespace_stack[] = isset( $decoded_json['namespace'] )
+ ? $decoded_json['namespace']
+ : end( $namespace_stack );
+ }
+
+ /**
+ * Processes the `data-wp-context` directive.
+ *
+ * It adds the context defined in the directive value to the stack so it's
+ * available for the nested interactivity elements.
+ *
+ * @since 6.5.0
+ *
+ * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
+ * @param array $context_stack The reference to the context stack.
+ * @param array $namespace_stack The reference to the store namespace stack.
+ */
+ private function data_wp_context_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ // In closing tags, it removes the last context from the stack.
+ if ( $p->is_tag_closer() ) {
+ return array_pop( $context_stack );
+ }
+
+ $attribute_value = $p->get_attribute( 'data-wp-context' );
+ $namespace_value = end( $namespace_stack );
+
+ // Separates the namespace from the context JSON object.
+ list( $namespace_value, $decoded_json ) = is_string( $attribute_value ) && ! empty( $attribute_value )
+ ? $this->extract_directive_value( $attribute_value, $namespace_value )
+ : array( $namespace_value, null );
+
+ /*
+ * If there is a namespace, it adds a new context to the stack merging the
+ * previous context with the new one.
+ */
+ if ( is_string( $namespace_value ) ) {
+ array_push(
+ $context_stack,
+ array_replace_recursive(
+ end( $context_stack ) !== false ? end( $context_stack ) : array(),
+ array( $namespace_value => is_array( $decoded_json ) ? $decoded_json : array() )
+ )
+ );
+ } else {
+ /*
+ * If there is no namespace, it pushes the current context to the stack.
+ * It needs to do so because the function pops out the current context
+ * from the stack whenever it finds a `data-wp-context`'s closing tag.
+ */
+ array_push( $context_stack, end( $context_stack ) );
+ }
+ }
+
+ /**
+ * Processes the `data-wp-bind` directive.
+ *
+ * It updates or removes the bound attributes based on the evaluation of its
+ * associated reference.
+ *
+ * @since 6.5.0
+ *
+ * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
+ * @param array $context_stack The reference to the context stack.
+ * @param array $namespace_stack The reference to the store namespace stack.
+ */
+ private function data_wp_bind_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ if ( ! $p->is_tag_closer() ) {
+ $all_bind_directives = $p->get_attribute_names_with_prefix( 'data-wp-bind--' );
+
+ foreach ( $all_bind_directives as $attribute_name ) {
+ list( , $bound_attribute ) = $this->extract_prefix_and_suffix( $attribute_name );
+ if ( empty( $bound_attribute ) ) {
+ return;
+ }
+
+ $attribute_value = $p->get_attribute( $attribute_name );
+ $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) );
+
+ if ( null !== $result && ( false !== $result || '-' === $bound_attribute[4] ) ) {
+ /*
+ * If the result of the evaluation is a boolean and the attribute is
+ * `aria-` or `data-, convert it to a string "true" or "false". It
+ * follows the exact same logic as Preact because it needs to
+ * replicate what Preact will later do in the client:
+ * https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
+ */
+ if ( is_bool( $result ) && '-' === $bound_attribute[4] ) {
+ $result = $result ? 'true' : 'false';
+ }
+ $p->set_attribute( $bound_attribute, $result );
+ } else {
+ $p->remove_attribute( $bound_attribute );
+ }
+ }
+ }
+ }
+
+
+ /**
+ * Processes the `data-wp-class` directive.
+ *
+ * It adds or removes CSS classes in the current HTML element based on the
+ * evaluation of its associated references.
+ *
+ * @since 6.5.0
+ *
+ * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
+ * @param array $context_stack The reference to the context stack.
+ * @param array $namespace_stack The reference to the store namespace stack.
+ */
+ private function data_wp_class_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ if ( ! $p->is_tag_closer() ) {
+ $all_class_directives = $p->get_attribute_names_with_prefix( 'data-wp-class--' );
+
+ foreach ( $all_class_directives as $attribute_name ) {
+ list( , $class_name ) = $this->extract_prefix_and_suffix( $attribute_name );
+ if ( empty( $class_name ) ) {
+ return;
+ }
+
+ $attribute_value = $p->get_attribute( $attribute_name );
+ $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) );
+
+ if ( $result ) {
+ $p->add_class( $class_name );
+ } else {
+ $p->remove_class( $class_name );
+ }
+ }
+ }
+ }
+
+ /**
+ * Processes the `data-wp-style` directive.
+ *
+ * It updates the style attribute value of the current HTML element based on
+ * the evaluation of its associated references.
+ *
+ * @since 6.5.0
+ *
+ * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
+ * @param array $context_stack The reference to the context stack.
+ * @param array $namespace_stack The reference to the store namespace stack.
+ */
+ private function data_wp_style_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ if ( ! $p->is_tag_closer() ) {
+ $all_style_attributes = $p->get_attribute_names_with_prefix( 'data-wp-style--' );
+
+ foreach ( $all_style_attributes as $attribute_name ) {
+ list( , $style_property ) = $this->extract_prefix_and_suffix( $attribute_name );
+ if ( empty( $style_property ) ) {
+ continue;
+ }
+
+ $directive_attribute_value = $p->get_attribute( $attribute_name );
+ $style_property_value = $this->evaluate( $directive_attribute_value, end( $namespace_stack ), end( $context_stack ) );
+ $style_attribute_value = $p->get_attribute( 'style' );
+ $style_attribute_value = ( $style_attribute_value && ! is_bool( $style_attribute_value ) ) ? $style_attribute_value : '';
+
+ /*
+ * Checks first if the style property is not falsy and the style
+ * attribute value is not empty because if it is, it doesn't need to
+ * update the attribute value.
+ */
+ if ( $style_property_value || ( ! $style_property_value && $style_attribute_value ) ) {
+ $style_attribute_value = $this->set_style_property( $style_attribute_value, $style_property, $style_property_value );
+ /*
+ * If the style attribute value is not empty, it sets it. Otherwise,
+ * it removes it.
+ */
+ if ( ! empty( $style_attribute_value ) ) {
+ $p->set_attribute( 'style', $style_attribute_value );
+ } else {
+ $p->remove_attribute( 'style' );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Sets an individual style property in the `style` attribute of an HTML
+ * element, updating or removing the property when necessary.
+ *
+ * If a property is modified, it is added at the end of the list to make sure
+ * that it overrides the previous ones.
+ *
+ * @since 6.5.0
+ *
+ * Example:
+ *
+ * set_style_property( 'color:green;', 'color', 'red' ) => 'color:red;'
+ * set_style_property( 'background:green;', 'color', 'red' ) => 'background:green;color:red;'
+ * set_style_property( 'color:green;', 'color', null ) => ''
+ *
+ * @param string $style_attribute_value The current style attribute value.
+ * @param string $style_property_name The style property name to set.
+ * @param string|false|null $style_property_value The value to set for the style property. With false, null or an
+ * empty string, it removes the style property.
+ * @return string The new style attribute value after the specified property has been added, updated or removed.
+ */
+ private function set_style_property( string $style_attribute_value, string $style_property_name, $style_property_value ): string {
+ $style_assignments = explode( ';', $style_attribute_value );
+ $result = array();
+ $style_property_value = ! empty( $style_property_value ) ? rtrim( trim( $style_property_value ), ';' ) : null;
+ $new_style_property = $style_property_value ? $style_property_name . ':' . $style_property_value . ';' : '';
+
+ // Generate an array with all the properties but the modified one.
+ foreach ( $style_assignments as $style_assignment ) {
+ if ( empty( trim( $style_assignment ) ) ) {
+ continue;
+ }
+ list( $name, $value ) = explode( ':', $style_assignment );
+ if ( trim( $name ) !== $style_property_name ) {
+ $result[] = trim( $name ) . ':' . trim( $value ) . ';';
+ }
+ }
+
+ // Add the new/modified property at the end of the list.
+ array_push( $result, $new_style_property );
+
+ return implode( '', $result );
+ }
+
+ /**
+ * Processes the `data-wp-text` directive.
+ *
+ * It updates the inner content of the current HTML element based on the
+ * evaluation of its associated reference.
+ *
+ * @since 6.5.0
+ *
+ * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance.
+ * @param array $context_stack The reference to the context stack.
+ * @param array $namespace_stack The reference to the store namespace stack.
+ */
+ private function data_wp_text_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack ) {
+ if ( ! $p->is_tag_closer() ) {
+ $attribute_value = $p->get_attribute( 'data-wp-text' );
+ $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) );
+
+ /*
+ * Follows the same logic as Preact in the client and only changes the
+ * content if the value is a string or a number. Otherwise, it removes the
+ * content.
+ */
+ if ( is_string( $result ) || is_numeric( $result ) ) {
+ $p->set_content_between_balanced_tags( esc_html( $result ) );
+ } else {
+ $p->set_content_between_balanced_tags( '' );
+ }
+ }
+ }
+ }
+
+}
diff --git a/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php
new file mode 100644
index 00000000000000..cd7ca7fb902870
--- /dev/null
+++ b/lib/compat/wordpress-6.5/interactivity-api/interactivity-api.php
@@ -0,0 +1,144 @@
+get_registered( $block_name );
+
+ if ( isset( $block_name ) && isset( $block_type->supports['interactivity'] ) && $block_type->supports['interactivity'] ) {
+ // Annotates the root interactive block for processing.
+ $root_interactive_block = array( $block_name, md5( serialize( $parsed_block ) ) );
+
+ /*
+ * Adds a filter to process the root interactive block once it has
+ * finished rendering.
+ */
+ $process_interactive_blocks = static function ( $content, $parsed_block ) use ( &$root_interactive_block, &$process_interactive_blocks ) {
+ // Checks whether the current block is the root interactive block.
+ list($root_block_name, $root_block_md5) = $root_interactive_block;
+ if ( $root_block_name === $parsed_block['blockName'] && md5( serialize( $parsed_block ) ) === $root_block_md5 ) {
+ // The root interactive blocks has finished rendering, process it.
+ $content = wp_interactivity_process_directives( $content );
+ // Removes the filter and reset the root interactive block.
+ remove_filter( 'render_block', $process_interactive_blocks );
+ $root_interactive_block = null;
+ }
+ return $content;
+ };
+
+ /*
+ * Uses a priority of 20 to ensure that other filters can add additional
+ * directives before the processing starts.
+ */
+ add_filter( 'render_block', $process_interactive_blocks, 20, 2 );
+ }
+ }
+
+ return $parsed_block;
+ }
+ add_filter( 'render_block_data', 'wp_interactivity_process_directives_of_interactive_blocks', 10, 1 );
+}
+
+if ( ! function_exists( 'wp_interactivity' ) ) {
+ /**
+ * Retrieves the main WP_Interactivity_API instance.
+ *
+ * It provides access to the WP_Interactivity_API instance, creating one if it
+ * doesn't exist yet. It also registers the hooks and necessary script
+ * modules.
+ *
+ * @since 6.5.0
+ *
+ * @return WP_Interactivity_API The main WP_Interactivity_API instance.
+ */
+ function wp_interactivity() {
+ static $instance = null;
+ if ( is_null( $instance ) ) {
+ $instance = new WP_Interactivity_API();
+ $instance->add_hooks();
+ $instance->register_script_modules();
+ }
+ return $instance;
+ }
+}
+
+if ( ! function_exists( 'wp_interactivity_process_directives' ) ) {
+ /**
+ * Processes the interactivity directives contained within the HTML content
+ * and updates the markup accordingly.
+ *
+ * @since 6.5.0
+ *
+ * @param string $html The HTML content to process.
+ * @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags.
+ */
+ function wp_interactivity_process_directives( $html ) {
+ return wp_interactivity()->process_directives( $html );
+ }
+}
+
+if ( ! function_exists( 'wp_interactivity_state' ) ) {
+ /**
+ * Gets and/or sets the initial state of an Interactivity API store for a
+ * given namespace.
+ *
+ * If state for that store namespace already exists, it merges the new
+ * provided state with the existing one.
+ *
+ * @since 6.5.0
+ *
+ * @param string $store_namespace The unique store namespace identifier.
+ * @param array $state Optional. The array that will be merged with the existing state for the specified
+ * store namespace.
+ * @return array The current state for the specified store namespace.
+ */
+ function wp_interactivity_state( $store_namespace, $state = null ) {
+ return wp_interactivity()->state( $store_namespace, $state );
+ }
+}
+
+if ( ! function_exists( 'wp_interactivity_config' ) ) {
+ /**
+ * Gets and/or sets the configuration of the Interactivity API for a given
+ * store namespace.
+ *
+ * If configuration for that store namespace exists, it merges the new
+ * provided configuration with the existing one.
+ *
+ * @since 6.5.0
+ *
+ * @param string $store_namespace The unique store namespace identifier.
+ * @param array $config Optional. The array that will be merged with the existing configuration for the
+ * specified store namespace.
+ * @return array The current configuration for the specified store namespace.
+ */
+ function wp_interactivity_config( $store_namespace, $initial_state = null ) {
+ return wp_interactivity()->config( $store_namespace, $initial_state );
+ }
+}
diff --git a/lib/experimental/interactivity-api/class-wp-directive-context.php b/lib/experimental/interactivity-api/class-wp-directive-context.php
deleted file mode 100644
index 4276eddca20acb..00000000000000
--- a/lib/experimental/interactivity-api/class-wp-directive-context.php
+++ /dev/null
@@ -1,82 +0,0 @@
-
- *
- *
- *
- *
- *
- *
- */
-class WP_Directive_Context {
- /**
- * The stack used to store contexts internally.
- *
- * @var array An array of contexts.
- */
- protected $stack = array( array() );
-
- /**
- * Constructor.
- *
- * Accepts a context as an argument to initialize this with.
- *
- * @param array $context A context.
- */
- public function __construct( $context = array() ) {
- $this->set_context( $context );
- }
-
- /**
- * Return the current context.
- *
- * @return array The current context.
- */
- public function get_context() {
- return end( $this->stack );
- }
-
- /**
- * Set the current context.
- *
- * @param array $context The context to be set.
- *
- * @return void
- */
- public function set_context( $context ) {
- array_push(
- $this->stack,
- array_replace_recursive( $this->get_context(), $context )
- );
- }
-
- /**
- * Reset the context to its previous state.
- *
- * @return void
- */
- public function rewind_context() {
- array_pop( $this->stack );
- }
-}
diff --git a/lib/experimental/interactivity-api/class-wp-directive-processor.php b/lib/experimental/interactivity-api/class-wp-directive-processor.php
deleted file mode 100644
index 723b36026ce2af..00000000000000
--- a/lib/experimental/interactivity-api/class-wp-directive-processor.php
+++ /dev/null
@@ -1,283 +0,0 @@
-get_tag();
-
- if ( self::is_html_void_element( $tag_name ) ) {
- return false;
- }
-
- while ( $this->next_tag(
- array(
- 'tag_name' => $tag_name,
- 'tag_closers' => 'visit',
- )
- ) ) {
- if ( ! $this->is_tag_closer() ) {
- ++$depth;
- continue;
- }
-
- if ( 0 === $depth ) {
- return true;
- }
-
- --$depth;
- }
-
- return false;
- }
-
- /**
- * Returns the content between two balanced tags.
- *
- * When called on an opening tag, return the HTML content found between that
- * opening tag and its matching closing tag.
- *
- * @return string The content between the current opening and its matching
- * closing tag.
- */
- public function get_inner_html() {
- $bookmarks = $this->get_balanced_tag_bookmarks();
- if ( ! $bookmarks ) {
- return false;
- }
- list( $start_name, $end_name ) = $bookmarks;
-
- $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1;
- $end = $this->bookmarks[ $end_name ]->start;
-
- $this->seek( $start_name ); // Return to original position.
- $this->release_bookmark( $start_name );
- $this->release_bookmark( $end_name );
-
- return substr( $this->html, $start, $end - $start );
- }
-
- /**
- * Sets the content between two balanced tags.
- *
- * When called on an opening tag, set the HTML content found between that
- * opening tag and its matching closing tag.
- *
- * @param string $new_html The string to replace the content between the
- * matching tags with.
- * @return bool Whether the content was successfully replaced.
- */
- public function set_inner_html( $new_html ) {
- $this->get_updated_html(); // Apply potential previous updates.
-
- $bookmarks = $this->get_balanced_tag_bookmarks();
- if ( ! $bookmarks ) {
- return false;
- }
- list( $start_name, $end_name ) = $bookmarks;
-
- $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1;
- $end = $this->bookmarks[ $end_name ]->start;
-
- $this->seek( $start_name ); // Return to original position.
- $this->release_bookmark( $start_name );
- $this->release_bookmark( $end_name );
-
- $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $start, $end - $start, $new_html );
- return true;
- }
-
- /**
- * Returns a pair of bookmarks for the current opening tag and the matching
- * closing tag.
- *
- * @return array|false A pair of bookmarks, or false if there's no matching
- * closing tag.
- */
- public function get_balanced_tag_bookmarks() {
- $i = 0;
- while ( array_key_exists( 'start' . $i, $this->bookmarks ) ) {
- ++$i;
- }
- $start_name = 'start' . $i;
-
- $this->set_bookmark( $start_name );
- if ( ! $this->next_balanced_closer() ) {
- $this->release_bookmark( $start_name );
- return false;
- }
-
- $i = 0;
- while ( array_key_exists( 'end' . $i, $this->bookmarks ) ) {
- ++$i;
- }
- $end_name = 'end' . $i;
- $this->set_bookmark( $end_name );
-
- return array( $start_name, $end_name );
- }
-
- /**
- * Checks whether a given HTML element is void (e.g.
).
- *
- * @see https://html.spec.whatwg.org/#elements-2
- *
- * @param string $tag_name The element in question.
- * @return bool True if the element is void.
- */
- public static function is_html_void_element( $tag_name ) {
- switch ( $tag_name ) {
- case 'AREA':
- case 'BASE':
- case 'BR':
- case 'COL':
- case 'EMBED':
- case 'HR':
- case 'IMG':
- case 'INPUT':
- case 'LINK':
- case 'META':
- case 'SOURCE':
- case 'TRACK':
- case 'WBR':
- return true;
-
- default:
- return false;
- }
- }
-
- /**
- * Extracts and return the directive type and the the part after the double
- * hyphen from an attribute name (if present), in an array format.
- *
- * Examples:
- *
- * 'wp-island' => array( 'wp-island', null )
- * 'wp-bind--src' => array( 'wp-bind', 'src' )
- * 'wp-thing--and--thang' => array( 'wp-thing', 'and--thang' )
- *
- * @param string $name The attribute name.
- * @return array The resulting array.
- */
- public static function parse_attribute_name( $name ) {
- return explode( '--', $name, 2 );
- }
-
- /**
- * Parse and extract the namespace and path from the given value.
- *
- * If the value contains a JSON instead of a path, the function parses it
- * and returns the resulting array.
- *
- * @param string $value Passed value.
- * @param string $ns Namespace fallback.
- * @return array The resulting array
- */
- public static function parse_attribute_value( $value, $ns = null ) {
- $matches = array();
- $has_ns = preg_match( '/^([\w\-_\/]+)::(.+)$/', $value, $matches );
-
- /*
- * Overwrite both `$ns` and `$value` variables if `$value` explicitly
- * contains a namespace.
- */
- if ( $has_ns ) {
- list( , $ns, $value ) = $matches;
- }
-
- /*
- * Try to decode `$value` as a JSON object. If it works, `$value` is
- * replaced with the resulting array. The original string is preserved
- * otherwise.
- *
- * Note that `json_decode` returns `null` both for an invalid JSON or
- * the `'null'` string (a valid JSON). In the latter case, `$value` is
- * replaced with `null`.
- */
- $data = json_decode( $value, true );
- if ( null !== $data || 'null' === trim( $value ) ) {
- $value = $data;
- }
-
- return array( $ns, $value );
- }
-}
diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php b/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php
deleted file mode 100644
index 15e57edfa4a6a2..00000000000000
--- a/lib/experimental/interactivity-api/class-wp-interactivity-initial-state.php
+++ /dev/null
@@ -1,82 +0,0 @@
-%s',
- wp_json_encode( self::$initial_state, JSON_HEX_TAG | JSON_HEX_AMP )
- );
- }
-}
diff --git a/lib/experimental/interactivity-api/directive-processing.php b/lib/experimental/interactivity-api/directive-processing.php
deleted file mode 100644
index 5a97166d6d22bf..00000000000000
--- a/lib/experimental/interactivity-api/directive-processing.php
+++ /dev/null
@@ -1,214 +0,0 @@
-get_registered( $parsed_block['blockName'] );
- $is_interactive = isset( $block_type->supports['interactivity'] ) && $block_type->supports['interactivity'];
- if ( $is_interactive ) {
- WP_Directive_Processor::mark_interactive_root_block( $parsed_block );
- }
- }
-
- return $parsed_block;
-}
-add_filter( 'render_block_data', 'gutenberg_interactivity_mark_root_interactive_blocks', 10, 1 );
-
-/**
- * Processes the directives in the root blocks.
- *
- * @param string $block_content The block content.
- * @param array $block The full block.
- *
- * @return string Filtered block content.
- */
-function gutenberg_process_directives_in_root_blocks( $block_content, $block ) {
- if ( WP_Directive_Processor::is_marked_as_interactive_root_block( $block ) ) {
- WP_Directive_Processor::unmark_interactive_root_block();
- $context = new WP_Directive_Context();
- $namespace_stack = array();
- return gutenberg_process_interactive_html( $block_content, $context, $namespace_stack );
- }
-
- return $block_content;
-}
-add_filter( 'render_block', 'gutenberg_process_directives_in_root_blocks', 10, 2 );
-
-/**
- * Processes interactive HTML by applying directives to the HTML tags.
- *
- * It uses the WP_Directive_Processor class to parse the HTML and apply the
- * directives. If a tag contains a 'WP-INNER-BLOCKS' string and there are inner
- * blocks to process, the function processes these inner blocks and replaces the
- * 'WP-INNER-BLOCKS' tag in the HTML with those blocks.
- *
- * @param string $html The HTML to process.
- * @param mixed $context The context to use when processing.
- * @param array $inner_blocks The inner blocks to process.
- * @param array $namespace_stack Stack of namespackes passed by reference.
- *
- * @return string The processed HTML.
- */
-function gutenberg_process_interactive_html( $html, $context, &$namespace_stack = array() ) {
- static $directives = array(
- 'data-wp-interactive' => 'gutenberg_interactivity_process_wp_interactive',
- 'data-wp-context' => 'gutenberg_interactivity_process_wp_context',
- 'data-wp-bind' => 'gutenberg_interactivity_process_wp_bind',
- 'data-wp-class' => 'gutenberg_interactivity_process_wp_class',
- 'data-wp-style' => 'gutenberg_interactivity_process_wp_style',
- 'data-wp-text' => 'gutenberg_interactivity_process_wp_text',
- );
-
- $tags = new WP_Directive_Processor( $html );
- $prefix = 'data-wp-';
- $tag_stack = array();
- while ( $tags->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
- $tag_name = $tags->get_tag();
-
- if ( $tags->is_tag_closer() ) {
- if ( 0 === count( $tag_stack ) ) {
- continue;
- }
- list( $latest_opening_tag_name, $attributes ) = end( $tag_stack );
- if ( $latest_opening_tag_name === $tag_name ) {
- array_pop( $tag_stack );
- // If the matching opening tag didn't have any directives, we move on.
- if ( 0 === count( $attributes ) ) {
- continue;
- }
- }
- } else {
- $attributes = array();
- foreach ( $tags->get_attribute_names_with_prefix( $prefix ) as $name ) {
- /*
- * Removes the part after the double hyphen before looking for
- * the directive processor inside `$directives`, e.g., "wp-bind"
- * from "wp-bind--src" and "wp-context" from "wp-context" etc...
- */
- list( $type ) = $tags::parse_attribute_name( $name );
- if ( array_key_exists( $type, $directives ) ) {
- $attributes[] = $type;
- }
- }
-
- /*
- * If this is an open tag, and if it either has directives, or if
- * we're inside a tag that does, take note of this tag and its
- * directives so we can call its directive processor once we
- * encounter the matching closing tag.
- */
- if (
- ! $tags::is_html_void_element( $tag_name ) &&
- ( 0 !== count( $attributes ) || 0 !== count( $tag_stack ) )
- ) {
- $tag_stack[] = array( $tag_name, $attributes );
- }
- }
-
- // Extract all directive names. They'll be used later on.
- $directive_names = array_keys( $directives );
- $directive_names_rev = array_reverse( $directive_names );
-
- /*
- * Sort attributes by the order they appear in the `$directives`
- * argument, considering it as the priority order in which
- * directives should be processed. Note that the order is reversed
- * for tag closers.
- */
- $sorted_attrs = array_intersect(
- $tags->is_tag_closer()
- ? $directive_names_rev
- : $directive_names,
- $attributes
- );
-
- foreach ( $sorted_attrs as $attribute ) {
- call_user_func_array(
- $directives[ $attribute ],
- array(
- $tags,
- $context,
- end( $namespace_stack ),
- &$namespace_stack,
- )
- );
- }
- }
-
- return $tags->get_updated_html();
-}
-
-/**
- * Resolves the passed reference from the store and the context under the given
- * namespace.
- *
- * A reference could be either a single path or a namespace followed by a path,
- * separated by two colons, i.e, `namespace::path.to.prop`. If the reference
- * contains a namespace, that namespace overrides the one passed as argument.
- *
- * @param string $reference Reference value.
- * @param string $ns Inherited namespace.
- * @param array $context Context data.
- * @return mixed Resolved value.
- */
-function gutenberg_interactivity_evaluate_reference( $reference, $ns, array $context = array() ) {
- // Extract the namespace from the reference (if present).
- list( $ns, $path ) = WP_Directive_Processor::parse_attribute_value( $reference, $ns );
-
- $store = array(
- 'state' => WP_Interactivity_Initial_State::get_state( $ns ),
- 'context' => $context[ $ns ] ?? array(),
- );
-
- /*
- * Checks first if the directive path is preceded by a negator operator (!),
- * indicating that the value obtained from the Interactivity Store (or the
- * passed context) using the subsequent path should be negated.
- */
- $should_negate_value = '!' === $path[0];
- $path = $should_negate_value ? substr( $path, 1 ) : $path;
- $path_segments = explode( '.', $path );
- $current = $store;
- foreach ( $path_segments as $p ) {
- if ( isset( $current[ $p ] ) ) {
- $current = $current[ $p ];
- } else {
- return null;
- }
- }
-
- /*
- * Checks if $current is an anonymous function or an arrow function, and if
- * so, call it passing the store. Other types of callables are ignored on
- * purpose, as arbitrary strings or arrays could be wrongly evaluated as
- * "callables".
- *
- * E.g., "file" is an string and a "callable" (the "file" function exists).
- */
- if ( $current instanceof Closure ) {
- /*
- * TODO: Figure out a way to implement derived state without having to
- * pass the store as argument:
- *
- * $current = call_user_func( $current );
- */
- }
-
- // Returns the opposite if it has a negator operator (!).
- return $should_negate_value ? ! $current : $current;
-}
diff --git a/lib/experimental/interactivity-api/directives/wp-bind.php b/lib/experimental/interactivity-api/directives/wp-bind.php
deleted file mode 100644
index 57d2e5deb23ab4..00000000000000
--- a/lib/experimental/interactivity-api/directives/wp-bind.php
+++ /dev/null
@@ -1,33 +0,0 @@
-is_tag_closer() ) {
- return;
- }
-
- $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-bind--' );
-
- foreach ( $prefixed_attributes as $attr ) {
- list( , $bound_attr ) = WP_Directive_Processor::parse_attribute_name( $attr );
- if ( empty( $bound_attr ) ) {
- continue;
- }
-
- $reference = $tags->get_attribute( $attr );
- $value = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() );
- $tags->set_attribute( $bound_attr, $value );
- }
-}
diff --git a/lib/experimental/interactivity-api/directives/wp-class.php b/lib/experimental/interactivity-api/directives/wp-class.php
deleted file mode 100644
index ef91835be86fc1..00000000000000
--- a/lib/experimental/interactivity-api/directives/wp-class.php
+++ /dev/null
@@ -1,37 +0,0 @@
-is_tag_closer() ) {
- return;
- }
-
- $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-class--' );
-
- foreach ( $prefixed_attributes as $attr ) {
- list( , $class_name ) = WP_Directive_Processor::parse_attribute_name( $attr );
- if ( empty( $class_name ) ) {
- continue;
- }
-
- $reference = $tags->get_attribute( $attr );
- $add_class = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() );
- if ( $add_class ) {
- $tags->add_class( $class_name );
- } else {
- $tags->remove_class( $class_name );
- }
- }
-}
diff --git a/lib/experimental/interactivity-api/directives/wp-context.php b/lib/experimental/interactivity-api/directives/wp-context.php
deleted file mode 100644
index b41b47c86c78c3..00000000000000
--- a/lib/experimental/interactivity-api/directives/wp-context.php
+++ /dev/null
@@ -1,30 +0,0 @@
-is_tag_closer() ) {
- $context->rewind_context();
- return;
- }
-
- $attr_value = $tags->get_attribute( 'data-wp-context' );
-
- //Separate namespace and value from the context directive attribute.
- list( $ns, $data ) = is_string( $attr_value ) && ! empty( $attr_value )
- ? WP_Directive_Processor::parse_attribute_value( $attr_value, $ns )
- : array( $ns, null );
-
- // Add parsed data to the context under the corresponding namespace.
- $context->set_context( array( $ns => is_array( $data ) ? $data : array() ) );
-}
diff --git a/lib/experimental/interactivity-api/directives/wp-interactive.php b/lib/experimental/interactivity-api/directives/wp-interactive.php
deleted file mode 100644
index 9f3471a8b4e6a9..00000000000000
--- a/lib/experimental/interactivity-api/directives/wp-interactive.php
+++ /dev/null
@@ -1,44 +0,0 @@
-is_tag_closer() ) {
- array_pop( $ns_stack );
- return;
- }
-
- /*
- * Decode the data-wp-interactive attribute. In the case it is not a valid
- * JSON string, NULL is stored in `$island_data`.
- */
- $island = $tags->get_attribute( 'data-wp-interactive' );
- $island_data = is_string( $island ) && ! empty( $island )
- ? json_decode( $island, true )
- : null;
-
- /*
- * Push the newly defined namespace, or the current one if the island
- * definition was invalid or does not contain a namespace.
- *
- * This is done because the function pops out the current namespace from the
- * stack whenever it finds an island's closing tag, independently of whether
- * the island definition was correct or it contained a valid namespace.
- */
- $ns_stack[] = isset( $island_data ) && $island_data['namespace']
- ? $island_data['namespace']
- : $ns;
-}
diff --git a/lib/experimental/interactivity-api/directives/wp-style.php b/lib/experimental/interactivity-api/directives/wp-style.php
deleted file mode 100644
index 16432e57282606..00000000000000
--- a/lib/experimental/interactivity-api/directives/wp-style.php
+++ /dev/null
@@ -1,73 +0,0 @@
-is_tag_closer() ) {
- return;
- }
-
- $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-style--' );
-
- foreach ( $prefixed_attributes as $attr ) {
- list( , $style_name ) = WP_Directive_Processor::parse_attribute_name( $attr );
- if ( empty( $style_name ) ) {
- continue;
- }
-
- $reference = $tags->get_attribute( $attr );
- $style_value = gutenberg_interactivity_evaluate_reference( $reference, $ns, $context->get_context() );
- if ( $style_value ) {
- $style_attr = $tags->get_attribute( 'style' ) ?? '';
- $style_attr = gutenberg_interactivity_set_style( $style_attr, $style_name, $style_value );
- $tags->set_attribute( 'style', $style_attr );
- } else {
- // TODO: Do we want to unset styles if they're null?
- }
- }
-}
-
-/**
- * Set style.
- *
- * @param string $style Existing style to amend.
- * @param string $name Style property name.
- * @param string $value Style property value.
- * @return string Amended styles.
- */
-function gutenberg_interactivity_set_style( $style, $name, $value ) {
- $style_assignments = explode( ';', $style );
- $modified = false;
- foreach ( $style_assignments as $style_assignment ) {
- list( $style_name ) = explode( ':', $style_assignment );
- if ( trim( $style_name ) === $name ) {
- // TODO: Retain surrounding whitespace from $style_value, if any.
- $style_assignment = $style_name . ': ' . $value;
- $modified = true;
- break;
- }
- }
-
- if ( ! $modified ) {
- $new_style_assignment = $name . ': ' . $value;
- // If the last element is empty or whitespace-only, we insert
- // the new "key: value" pair before it.
- if ( empty( trim( end( $style_assignments ) ) ) ) {
- array_splice( $style_assignments, - 1, 0, $new_style_assignment );
- } else {
- array_push( $style_assignments, $new_style_assignment );
- }
- }
- return implode( ';', $style_assignments );
-}
diff --git a/lib/experimental/interactivity-api/directives/wp-text.php b/lib/experimental/interactivity-api/directives/wp-text.php
deleted file mode 100644
index c4c5bb27a31e10..00000000000000
--- a/lib/experimental/interactivity-api/directives/wp-text.php
+++ /dev/null
@@ -1,28 +0,0 @@
-is_tag_closer() ) {
- return;
- }
-
- $value = $tags->get_attribute( 'data-wp-text' );
- if ( null === $value ) {
- return;
- }
-
- $text = gutenberg_interactivity_evaluate_reference( $value, $ns, $context->get_context() );
- $tags->set_inner_html( esc_html( $text ) );
-}
diff --git a/lib/experimental/interactivity-api/initial-state.php b/lib/experimental/interactivity-api/initial-state.php
deleted file mode 100644
index a38d0da631f3c4..00000000000000
--- a/lib/experimental/interactivity-api/initial-state.php
+++ /dev/null
@@ -1,29 +0,0 @@
-
+
HTML;
diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js
index c1879af1fe19a6..250d3bde6084c9 100644
--- a/packages/interactivity/src/directives.js
+++ b/packages/interactivity/src/directives.js
@@ -177,9 +177,11 @@ export default () => {
: name;
useInit( () => {
- // This seems necessary because Preact doesn't change the class
- // names on the hydration, so we have to do it manually. It doesn't
- // need deps because it only needs to do it the first time.
+ /*
+ * This seems necessary because Preact doesn't change the class
+ * names on the hydration, so we have to do it manually. It doesn't
+ * need deps because it only needs to do it the first time.
+ */
if ( ! result ) {
element.ref.current.classList.remove( name );
} else {
@@ -206,9 +208,11 @@ export default () => {
else element.props.style[ key ] = result;
useInit( () => {
- // This seems necessary because Preact doesn't change the styles on
- // the hydration, so we have to do it manually. It doesn't need deps
- // because it only needs to do it the first time.
+ /*
+ * This seems necessary because Preact doesn't change the styles on
+ * the hydration, so we have to do it manually. It doesn't need deps
+ * because it only needs to do it the first time.
+ */
if ( ! result ) {
element.ref.current.style.removeProperty( key );
} else {
@@ -226,24 +230,36 @@ export default () => {
const result = evaluate( entry );
element.props[ attribute ] = result;
- // This seems necessary because Preact doesn't change the attributes
- // on the hydration, so we have to do it manually. It doesn't need
- // deps because it only needs to do it the first time.
+ /*
+ * This is necessary because Preact doesn't change the attributes on the
+ * hydration, so we have to do it manually. It only needs to do it the
+ * first time. After that, Preact will handle the changes.
+ */
useInit( () => {
const el = element.ref.current;
- // We set the value directly to the corresponding
- // HTMLElement instance property excluding the following
- // special cases.
- // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129
+ /*
+ * We set the value directly to the corresponding HTMLElement instance
+ * property excluding the following special cases. We follow Preact's
+ * logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129
+ */
if (
attribute !== 'width' &&
attribute !== 'height' &&
attribute !== 'href' &&
attribute !== 'list' &&
attribute !== 'form' &&
- // Default value in browsers is `-1` and an empty string is
- // cast to `0` instead
+ /*
+ * The value for `tabindex` follows the parsing rules for an
+ * integer. If that fails, or if the attribute isn't present, then
+ * the browsers should "follow platform conventions to determine if
+ * the element should be considered as a focusable area",
+ * practically meaning that most elements get a default of `-1` (not
+ * focusable), but several also get a default of `0` (focusable in
+ * order after all elements with a positive `tabindex` value).
+ *
+ * @see https://html.spec.whatwg.org/#tabindex-value
+ */
attribute !== 'tabIndex' &&
attribute !== 'download' &&
attribute !== 'rowSpan' &&
@@ -259,10 +275,12 @@ export default () => {
return;
} catch ( err ) {}
}
- // aria- and data- attributes have no boolean representation.
- // A `false` value is different from the attribute not being
- // present, so we can't remove it.
- // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
+ /*
+ * aria- and data- attributes have no boolean representation.
+ * A `false` value is different from the attribute not being
+ * present, so we can't remove it.
+ * We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
+ */
if (
result !== null &&
result !== undefined &&
diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts
index 8463d1a0a51323..5177c72cfda462 100644
--- a/packages/interactivity/src/store.ts
+++ b/packages/interactivity/src/store.ts
@@ -36,12 +36,12 @@ const deepMerge = ( target: any, source: any ) => {
const parseInitialState = () => {
const storeTag = document.querySelector(
- `script[type="application/json"]#wp-interactivity-initial-state`
+ `script[type="application/json"]#wp-interactivity-data`
);
if ( ! storeTag?.textContent ) return {};
try {
- const initialState = JSON.parse( storeTag.textContent );
- if ( isObject( initialState ) ) return initialState;
+ const { state } = JSON.parse( storeTag.textContent );
+ if ( isObject( state ) ) return state;
throw Error( 'Parsed state is not an object' );
} catch ( e ) {
// eslint-disable-next-line no-console
diff --git a/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php b/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php
deleted file mode 100644
index 2b01cb6251c210..00000000000000
--- a/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php
+++ /dev/null
@@ -1,132 +0,0 @@
-outsideinside
';
-
- public function test_next_balanced_closer_stays_on_void_tag() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'img' );
- $result = $tags->next_balanced_closer();
- $this->assertSame( 'IMG', $tags->get_tag() );
- $this->assertFalse( $result );
- }
-
- public function test_next_balanced_closer_proceeds_to_correct_tag() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->next_balanced_closer();
- $this->assertSame( 'SECTION', $tags->get_tag() );
- $this->assertTrue( $tags->is_tag_closer() );
- }
-
- public function test_next_balanced_closer_proceeds_to_correct_tag_for_nested_tag() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'div' );
- $tags->next_tag( 'div' );
- $tags->next_balanced_closer();
- $this->assertSame( 'DIV', $tags->get_tag() );
- $this->assertTrue( $tags->is_tag_closer() );
- }
-
- public function test_get_inner_html_returns_correct_result() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $this->assertSame( 'inside
', $tags->get_inner_html() );
- }
-
- public function test_set_inner_html_on_void_element_has_no_effect() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'img' );
- $content = $tags->set_inner_html( 'This is the new img content' );
- $this->assertFalse( $content );
- $this->assertSame( self::HTML, $tags->get_updated_html() );
- }
-
- public function test_set_inner_html_sets_content_correctly() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->set_inner_html( 'This is the new section content.' );
- $this->assertSame( 'outside
This is the new section content.', $tags->get_updated_html() );
- }
-
- public function test_set_inner_html_updates_bookmarks_correctly() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'div' );
- $tags->set_bookmark( 'start' );
- $tags->next_tag( 'img' );
- $this->assertSame( 'IMG', $tags->get_tag() );
- $tags->set_bookmark( 'after' );
- $tags->seek( 'start' );
-
- $tags->set_inner_html( 'This is the new div content.' );
- $this->assertSame( 'This is the new div content.
inside
', $tags->get_updated_html() );
- $tags->seek( 'after' );
- $this->assertSame( 'IMG', $tags->get_tag() );
- }
-
- public function test_set_inner_html_subsequent_updates_on_the_same_tag_work() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->set_inner_html( 'This is the new section content.' );
- $tags->set_inner_html( 'This is the even newer section content.' );
- $this->assertSame( 'outside
This is the even newer section content.', $tags->get_updated_html() );
- }
-
- public function test_set_inner_html_followed_by_set_attribute_works() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->set_inner_html( 'This is the new section content.' );
- $tags->set_attribute( 'id', 'thesection' );
- $this->assertSame( 'outside
This is the new section content.', $tags->get_updated_html() );
- }
-
- public function test_set_inner_html_preceded_by_set_attribute_works() {
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->set_attribute( 'id', 'thesection' );
- $tags->set_inner_html( 'This is the new section content.' );
- $this->assertSame( 'outside
This is the new section content.', $tags->get_updated_html() );
- }
-
- /**
- * TODO: Review this, how that the code is in Gutenberg.
- */
- public function test_set_inner_html_invalidates_bookmarks_that_point_to_replaced_content() {
- $this->markTestSkipped( "This requires on bookmark invalidation, which is only in GB's WP 6.3 compat layer." );
-
- $tags = new WP_Directive_Processor( self::HTML );
-
- $tags->next_tag( 'section' );
- $tags->set_bookmark( 'start' );
- $tags->next_tag( 'img' );
- $tags->set_bookmark( 'replaced' );
- $tags->seek( 'start' );
-
- $tags->set_inner_html( 'This is the new section content.' );
- $this->assertSame( 'outside
This is the new section content.', $tags->get_updated_html() );
-
- $this->expectExceptionMessage( 'Invalid bookmark name' );
- $successful_seek = $tags->seek( 'replaced' );
- $this->assertFalse( $successful_seek );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php b/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php
deleted file mode 100644
index a95c3482ec80d1..00000000000000
--- a/phpunit/experimental/interactivity-api/class-wp-interactivity-initial-state-test.php
+++ /dev/null
@@ -1,115 +0,0 @@
-assertEmpty( WP_Interactivity_Initial_State::get_data() );
- }
-
- public function test_initial_state_can_be_merged() {
- $state = array(
- 'a' => 1,
- 'b' => 2,
- 'nested' => array(
- 'c' => 3,
- ),
- );
- WP_Interactivity_Initial_State::merge_state( 'core', $state );
- $this->assertSame( $state, WP_Interactivity_Initial_State::get_state( 'core' ) );
- }
-
- public function test_initial_state_can_be_extended() {
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) );
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'b' => 2 ) );
- WP_Interactivity_Initial_State::merge_state( 'custom', array( 'c' => 3 ) );
- $this->assertSame(
- array(
- 'core' => array(
- 'a' => 1,
- 'b' => 2,
- ),
- 'custom' => array(
- 'c' => 3,
- ),
- ),
- WP_Interactivity_Initial_State::get_data()
- );
- }
-
- public function test_initial_state_existing_props_should_be_overwritten() {
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) );
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 'overwritten' ) );
- $this->assertSame(
- array(
- 'core' => array(
- 'a' => 'overwritten',
- ),
- ),
- WP_Interactivity_Initial_State::get_data()
- );
- }
-
- public function test_initial_state_existing_indexed_arrays_should_be_replaced() {
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => array( 1, 2 ) ) );
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => array( 3, 4 ) ) );
- $this->assertSame(
- array(
- 'core' => array(
- 'a' => array( 3, 4 ),
- ),
- ),
- WP_Interactivity_Initial_State::get_data()
- );
- }
-
- public function test_initial_state_should_be_correctly_rendered() {
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'a' => 1 ) );
- WP_Interactivity_Initial_State::merge_state( 'core', array( 'b' => 2 ) );
- WP_Interactivity_Initial_State::merge_state( 'custom', array( 'c' => 3 ) );
-
- ob_start();
- WP_Interactivity_Initial_State::render();
- $rendered = ob_get_clean();
- $this->assertSame(
- '',
- $rendered
- );
- }
-
- public function test_initial_state_should_also_escape_tags_and_amps() {
- WP_Interactivity_Initial_State::merge_state(
- 'test',
- array(
- 'amps' => 'http://site.test/?foo=1&baz=2&bar=3',
- 'tags' => 'Do not do this: ' .
- '' .
- 'Welcome
';
-
- $interactive_parsed_block = parse_blocks( $block_content )[0];
-
- $rendered_content = render_block( $interactive_parsed_block );
- $parsed_block_second = parse_blocks( $block_content )[1];
- $non_interactive_root_block = parse_blocks( $block_content )[2];
-
- // Test that root block is intially empty.
- $this->assertEmpty( WP_Directive_Processor::$interactive_root_block );
-
- // Test that root block is not added if it is non interactive.
- gutenberg_interactivity_mark_root_interactive_blocks( $non_interactive_root_block );
- $this->assertEmpty( WP_Directive_Processor::$interactive_root_block );
-
- // Test that a non root block is added if it is interactive.
- gutenberg_interactivity_mark_root_interactive_blocks( $interactive_parsed_block );
- $this->assertNotEmpty( WP_Directive_Processor::$interactive_root_block );
-
- // Test that an interactive block is not added if it has in interactive ancestor.
- $current_root_block = WP_Directive_Processor::$interactive_root_block;
- gutenberg_interactivity_mark_root_interactive_blocks( $parsed_block_second );
- $this->assertSame( $current_root_block, WP_Directive_Processor::$interactive_root_block );
-
- // Test that root block is removed after processing.
- gutenberg_process_directives_in_root_blocks( $rendered_content, $interactive_parsed_block );
- $this->assertEmpty( WP_Directive_Processor::$interactive_root_block );
- }
-
- public function test_directive_processing_of_interactive_block() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'level-1-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'level-1-input-2' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- }
-
- public function test_directive_processing_two_interactive_blocks_at_same_level() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'level-1-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'level-1-input-2' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'level-2-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-2', $value );
- }
-
- public function test_directives_are_processed_at_tag_end() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'level-1-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'level-2-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-2', $value );
- $p->next_tag( array( 'class_name' => 'read-only-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'level-1-input-2' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- }
-
- public function test_non_interactive_children_of_interactive_is_rendered() {
- $post_content = 'Welcome
';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'level-1-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag( array( 'class_name' => 'read-only-input-1' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- $p->next_tag();
- $this->assertSame( 'P', $p->get_tag() );
- $p->next_tag( array( 'class_name' => 'level-1-input-2' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- }
-
- public function test_non_interactive_blocks_are_not_processed() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'non-interactive-with-directive' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( null, $value );
- }
-
- public function test_non_interactive_blocks_with_interactive_ancestor_are_processed() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag( array( 'class_name' => 'non-interactive-with-directive' ) );
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'level-1', $value );
- }
-
- public function test_directives_ordering() {
- $post_content = '';
- $rendered_blocks = do_blocks( $post_content );
- $p = new WP_HTML_Tag_Processor( $rendered_blocks );
- $p->next_tag();
-
- $value = $p->get_attribute( 'class' );
- $this->assertSame( 'other-class some-class', $value );
-
- $value = $p->get_attribute( 'value' );
- $this->assertSame( 'some-value', $value );
-
- $value = $p->get_attribute( 'style' );
- $this->assertSame( 'display: none;', $value );
- }
-
- public function test_evaluate_function_should_access_state() {
- // Init a simple store.
- wp_initial_state(
- 'test',
- array(
- 'number' => 1,
- 'bool' => true,
- 'nested' => array(
- 'string' => 'hi',
- ),
- )
- );
-
- $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.number', 'test' ) );
- $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.bool', 'test' ) );
- $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.nested.string', 'test' ) );
- $this->assertFalse( gutenberg_interactivity_evaluate_reference( '!state.bool', 'test' ) );
- }
-
- public function test_evaluate_function_should_access_passed_context() {
- wp_initial_state(
- 'test',
- array(
- 'number' => 1,
- 'bool' => true,
- 'nested' => array(
- 'string' => 'hi',
- ),
- )
- );
-
- $context = array(
- 'test' => array(
- 'number' => 2,
- 'bool' => false,
- 'nested' => array(
- 'string' => 'bye',
- ),
- ),
- );
-
- $this->assertSame( 2, gutenberg_interactivity_evaluate_reference( 'context.number', 'test', $context ) );
- $this->assertFalse( gutenberg_interactivity_evaluate_reference( 'context.bool', 'test', $context ) );
- $this->assertTrue( gutenberg_interactivity_evaluate_reference( '!context.bool', 'test', $context ) );
- $this->assertSame( 'bye', gutenberg_interactivity_evaluate_reference( 'context.nested.string', 'test', $context ) );
-
- // Defined state is also accessible.
- $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.number', 'test' ) );
- $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.bool', 'test' ) );
- $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.nested.string', 'test' ) );
- }
-
- public function test_evaluate_function_should_return_null_for_unresolved_paths() {
- $this->assertNull( gutenberg_interactivity_evaluate_reference( 'this.property.doesnt.exist', 'myblock' ) );
- }
-
- public function test_evaluate_function_should_execute_anonymous_functions() {
- $this->markTestSkipped( 'Derived state was supported for `wp_store()` but not for `wp_initial_state()` yet.' );
-
- $context = new WP_Directive_Context( array( 'myblock' => array( 'count' => 2 ) ) );
-
- wp_initial_state(
- 'myblock',
- array(
- 'count' => 3,
- 'anonymous_function' => function ( $store ) {
- return $store['state']['count'] + $store['context']['count'];
- },
- // Other types of callables should not be executed.
- 'function_name' => 'gutenberg_test_process_directives_helper_increment',
- 'class_method' => array( $this, 'increment' ),
- 'class_static_method' => array( 'Tests_Process_Directives', 'static_increment' ),
- )
- );
-
- $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'state.anonymous_function', 'myblock', $context->get_context() ) );
- $this->assertSame(
- 'gutenberg_test_process_directives_helper_increment',
- gutenberg_interactivity_evaluate_reference( 'state.function_name', 'myblock', $context->get_context() )
- );
- $this->assertSame(
- array( $this, 'increment' ),
- gutenberg_interactivity_evaluate_reference( 'state.class_method', 'myblock', $context->get_context() )
- );
- $this->assertSame(
- array( 'Tests_Process_Directives', 'static_increment' ),
- gutenberg_interactivity_evaluate_reference( 'state.class_static_method', 'myblock', $context->get_context() )
- );
- }
-
- public function test_namespace_should_be_inherited_from_ancestor() {
- /*
- * This function call should be done inside block render functions. We
- * run it here instead just for conveninence.
- */
- wp_initial_state( 'test-1', array( 'text' => 'state' ) );
-
- $post_content = '
-
-
-
-
-
-
- ';
-
- $html = do_blocks( $post_content );
- $tags = new WP_HTML_Tag_Processor( $html );
-
- $tags->next_tag( array( 'class_name' => 'bind-state' ) );
- $this->assertSame( 'state', $tags->get_attribute( 'data-value' ) );
-
- $tags->next_tag( array( 'class_name' => 'bind-context' ) );
- $this->assertSame( 'context', $tags->get_attribute( 'data-value' ) );
- }
-
- public function test_namespace_should_be_inherited_from_same_element() {
- /*
- * This function call should be done inside block render functions. We
- * run it here instead just for conveninence.
- */
- wp_initial_state( 'test-2', array( 'text' => 'state-2' ) );
-
- $post_content = '
-
-
-
-
-
-
- ';
-
- $html = do_blocks( $post_content );
- $tags = new WP_HTML_Tag_Processor( $html );
-
- $tags->next_tag( array( 'class_name' => 'bind-state' ) );
- $this->assertSame( 'state-2', $tags->get_attribute( 'data-value' ) );
-
- $tags->next_tag( array( 'class_name' => 'bind-context' ) );
- $this->assertSame( 'context-2', $tags->get_attribute( 'data-value' ) );
- }
-
- public function test_namespace_should_not_leak_from_descendant() {
- /*
- * This function call should be done inside block render functions. We
- * run it here instead just for conveninence.
- */
- wp_initial_state( 'test-1', array( 'text' => 'state-1' ) );
- wp_initial_state( 'test-2', array( 'text' => 'state-2' ) );
-
- $post_content = '
-
-
-
-
-
- ';
-
- $html = do_blocks( $post_content );
- $tags = new WP_HTML_Tag_Processor( $html );
-
- $tags->next_tag( array( 'class_name' => 'target' ) );
- $this->assertSame( 'state-1', $tags->get_attribute( 'data-state' ) );
- $this->assertSame( 'context-1', $tags->get_attribute( 'data-context' ) );
- }
-
- public function test_namespace_should_not_leak_from_sibling() {
- /*
- * This function call should be done inside block render functions. We
- * run it here instead just for conveninence.
- */
- wp_initial_state( 'test-1', array( 'text' => 'state-1' ) );
- wp_initial_state( 'test-2', array( 'text' => 'state-2' ) );
-
- $post_content = '
-
-
-
-
-
-
- ';
-
- $html = do_blocks( $post_content );
- $tags = new WP_HTML_Tag_Processor( $html );
-
- $tags->next_tag( array( 'class_name' => 'target' ) );
- $this->assertSame( 'state-1', $tags->get_attribute( 'data-from-state' ) );
- $this->assertSame( 'context-1', $tags->get_attribute( 'data-from-context' ) );
- }
-
- public function test_namespace_can_be_overwritten_in_directives() {
- /*
- * This function call should be done inside block render functions. We
- * run it here instead just for conveninence.
- */
- wp_initial_state( 'test-1', array( 'text' => 'state-1' ) );
- wp_initial_state( 'test-2', array( 'text' => 'state-2' ) );
-
- $post_content = '
-
-
-
-
-
- ';
-
- $html = do_blocks( $post_content );
- $tags = new WP_HTML_Tag_Processor( $html );
-
- $tags->next_tag( array( 'class_name' => 'inherited-ns' ) );
- $this->assertSame( 'state-1', $tags->get_attribute( 'data-value' ) );
-
- $tags->next_tag( array( 'class_name' => 'custom-ns' ) );
- $this->assertSame( 'state-2', $tags->get_attribute( 'data-value' ) );
-
- $tags->next_tag( array( 'class_name' => 'mixed-ns' ) );
- $this->assertSame( 'state-1', $tags->get_attribute( 'data-inherited-ns' ) );
- $this->assertSame( 'state-2', $tags->get_attribute( 'data-custom-ns' ) );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php
deleted file mode 100644
index 8fe212bb8ed93a..00000000000000
--- a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php
+++ /dev/null
@@ -1,48 +0,0 @@
-';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_bind( $tags, $context, $directive_ns );
-
- $this->assertSame(
- '',
- $tags->get_updated_html()
- );
- $this->assertSame( './wordpress.png', $tags->get_attribute( 'src' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' );
- }
-
- public function test_directive_ignores_empty_bound_attribute() {
- $markup = '';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_bind( $tags, $context, $directive_ns );
-
- $this->assertSame( $markup, $tags->get_updated_html() );
- $this->assertNull( $tags->get_attribute( 'src' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/directives/wp-class-test.php b/phpunit/experimental/interactivity-api/directives/wp-class-test.php
deleted file mode 100644
index f40486647ff8b8..00000000000000
--- a/phpunit/experimental/interactivity-api/directives/wp-class-test.php
+++ /dev/null
@@ -1,103 +0,0 @@
-Test';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns );
-
- $this->assertSame(
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertStringContainsString( 'red', $tags->get_attribute( 'class' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' );
- }
-
- public function test_directive_removes_class() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns );
-
- $this->assertSame(
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertStringNotContainsString( 'blue', $tags->get_attribute( 'class' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' );
- }
-
- public function test_directive_removes_empty_class_attribute() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns );
-
- $this->assertSame(
- // WP_HTML_Tag_Processor has a TODO note to prune whitespace after classname removal.
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertNull( $tags->get_attribute( 'class' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' );
- }
-
- public function test_directive_does_not_remove_non_existant_class() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns );
-
- $this->assertSame(
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertSame( 'green red', $tags->get_attribute( 'class' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' );
- }
-
- public function test_directive_ignores_empty_class_name() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) );
- $context = $context_before;
- $directive_ns = 'myblock';
- gutenberg_interactivity_process_wp_class( $tags, $context, $directive_ns );
-
- $this->assertSame( $markup, $tags->get_updated_html() );
- $this->assertStringNotContainsString( 'red', $tags->get_attribute( 'class' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/directives/wp-context-test.php b/phpunit/experimental/interactivity-api/directives/wp-context-test.php
deleted file mode 100644
index 788feec95fe7c5..00000000000000
--- a/phpunit/experimental/interactivity-api/directives/wp-context-test.php
+++ /dev/null
@@ -1,230 +0,0 @@
- array( 'open' => false ),
- 'otherblock' => array( 'somekey' => 'somevalue' ),
- )
- );
-
- $ns = 'myblock';
- $markup = '';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- gutenberg_interactivity_process_wp_context( $tags, $context, $ns );
-
- $this->assertSame(
- array(
- 'myblock' => array( 'open' => true ),
- 'otherblock' => array( 'somekey' => 'somevalue' ),
- ),
- $context->get_context()
- );
- }
-
- public function test_directive_resets_context_correctly_upon_closing_tag() {
- $context = new WP_Directive_Context(
- array( 'myblock' => array( 'my-key' => 'original-value' ) )
- );
-
- $context->set_context(
- array( 'myblock' => array( 'my-key' => 'new-value' ) )
- );
-
- $markup = '
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
-
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- $this->assertSame(
- array( 'my-key' => 'original-value' ),
- $context->get_context()['myblock']
- );
- }
-
- public function test_directive_doesnt_throw_on_malformed_context_objects() {
- $context = new WP_Directive_Context(
- array( 'myblock' => array( 'my-key' => 'some-value' ) )
- );
-
- $markup = '';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
- }
-
- public function test_directive_keeps_working_after_malformed_context_objects() {
- $context = new WP_Directive_Context();
-
- $markup = '
-
- ';
- $tags = new WP_HTML_Tag_Processor( $markup );
-
- // Parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Now the context is empty.
- $this->assertSame(
- array(),
- $context->get_context()
- );
- }
-
- public function test_directive_keeps_working_with_a_directive_without_value() {
- $context = new WP_Directive_Context();
-
- $markup = '
-
- ';
- $tags = new WP_HTML_Tag_Processor( $markup );
-
- // Parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Now the context is empty.
- $this->assertSame(
- array(),
- $context->get_context()
- );
- }
-
- public function test_directive_keeps_working_with_an_empty_directive() {
- $context = new WP_Directive_Context();
-
- $markup = '
-
- ';
- $tags = new WP_HTML_Tag_Processor( $markup );
-
- // Parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing children div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Still the same context.
- $this->assertSame(
- array( 'my-key' => 'some-value' ),
- $context->get_context()['myblock']
- );
-
- // Closing parent div.
- $tags->next_tag( array( 'tag_closers' => 'visit' ) );
- gutenberg_interactivity_process_wp_context( $tags, $context, 'myblock' );
-
- // Now the context is empty.
- $this->assertSame(
- array(),
- $context->get_context()
- );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/directives/wp-style-test.php b/phpunit/experimental/interactivity-api/directives/wp-style-test.php
deleted file mode 100644
index 9625803ebca78f..00000000000000
--- a/phpunit/experimental/interactivity-api/directives/wp-style-test.php
+++ /dev/null
@@ -1,63 +0,0 @@
-Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) );
- $context = $context_before;
- gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' );
-
- $this->assertSame(
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertStringContainsString( 'color: green;', $tags->get_attribute( 'style' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' );
- }
-
- public function test_directive_ignores_empty_style() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) );
- $context = $context_before;
- gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' );
-
- $this->assertSame( $markup, $tags->get_updated_html() );
- $this->assertStringNotContainsString( 'color: green;', $tags->get_attribute( 'style' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' );
- }
-
- public function test_directive_works_without_style_attribute() {
- $markup = 'Test
';
- $tags = new WP_HTML_Tag_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) );
- $context = $context_before;
- gutenberg_interactivity_process_wp_style( $tags, $context, 'myblock' );
-
- $this->assertSame(
- 'Test
',
- $tags->get_updated_html()
- );
- $this->assertSame( 'color: green;', $tags->get_attribute( 'style' ) );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' );
- }
-}
diff --git a/phpunit/experimental/interactivity-api/directives/wp-text-test.php b/phpunit/experimental/interactivity-api/directives/wp-text-test.php
deleted file mode 100644
index 9c889a3f0eb68f..00000000000000
--- a/phpunit/experimental/interactivity-api/directives/wp-text-test.php
+++ /dev/null
@@ -1,45 +0,0 @@
-';
-
- $tags = new WP_Directive_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'The HTML tag
produces a line break.' ) ) );
- $context = clone $context_before;
- gutenberg_interactivity_process_wp_text( $tags, $context, 'myblock' );
-
- $expected_markup = 'The HTML tag <br> produces a line break.
';
- $this->assertSame( $expected_markup, $tags->get_updated_html() );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' );
- }
-
- public function test_directive_overwrites_inner_html_based_on_attribute_value() {
- $markup = 'Lorem ipsum dolor sit.
';
-
- $tags = new WP_Directive_Processor( $markup );
- $tags->next_tag();
-
- $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'Honi soit qui mal y pense.' ) ) );
- $context = clone $context_before;
- gutenberg_interactivity_process_wp_text( $tags, $context, 'myblock' );
-
- $expected_markup = 'Honi soit qui mal y pense.
';
- $this->assertSame( $expected_markup, $tags->get_updated_html() );
- $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' );
- }
-}
diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php
new file mode 100644
index 00000000000000..aa1ee999fd58f1
--- /dev/null
+++ b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php
@@ -0,0 +1,369 @@
+Text';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertEquals( 'Text', $p->get_content_between_balanced_tags() );
+
+ $content = 'Text
More text
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertEquals( 'Text', $p->get_content_between_balanced_tags() );
+ $p->next_tag();
+ $this->assertEquals( 'More text', $p->get_content_between_balanced_tags() );
+ }
+
+ /**
+ * Tests the `get_content_between_balanced_tags` method on an empty tag.
+ *
+ * @covers ::get_content_between_balanced_tags
+ */
+ public function test_get_content_between_balanced_tags_empty_tag() {
+ $content = '';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertEquals( '', $p->get_content_between_balanced_tags() );
+ }
+
+ /**
+ * Tests the `get_content_between_balanced_tags` method with a self-closing
+ * tag.
+ *
+ * @covers ::get_content_between_balanced_tags
+ */
+ public function test_get_content_between_balanced_tags_self_closing_tag() {
+ $content = '';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertNull( $p->get_content_between_balanced_tags() );
+ }
+
+ /**
+ * Tests the `get_content_between_balanced_tags` method with nested tags.
+ *
+ * @covers ::get_content_between_balanced_tags
+ */
+ public function test_get_content_between_balanced_tags_nested_tags() {
+ $content = 'ContentMore Content
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertEquals( 'ContentMore Content', $p->get_content_between_balanced_tags() );
+
+ $content = 'Content
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertEquals( 'Content
', $p->get_content_between_balanced_tags() );
+ }
+
+ /**
+ * Tests the `get_content_between_balanced_tags` method when no tags are
+ * present.
+ *
+ * @covers ::get_content_between_balanced_tags
+ */
+ public function test_get_content_between_balanced_tags_no_tags() {
+ $content = 'Just a string with no tags.';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertNull( $p->get_content_between_balanced_tags() );
+ }
+
+ /**
+ * Tests the `get_content_between_balanced_tags` method with unbalanced tags.
+ *
+ * @covers ::get_content_between_balanced_tags
+ */
+ public function test_get_content_between_balanced_tags_with_unbalanced_tags() {
+ $content = 'Missing closing div';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertNull( $p->get_content_between_balanced_tags() );
+
+ $content = '
Missing closing div
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertNull( $p->get_content_between_balanced_tags() );
+
+ $content = '
Missing closing div';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertNull( $p->get_content_between_balanced_tags() );
+
+ // It supports unbalanced tags inside the content.
+ $content = '
Missing opening span
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertEquals( 'Missing opening span', $p->get_content_between_balanced_tags() );
+ }
+
+ /**
+ * Tests the `get_content_between_balanced_tags` method when called on a
+ * closing tag.
+ *
+ * @covers ::get_content_between_balanced_tags
+ */
+ public function test_get_content_between_balanced_tags_on_closing_tag() {
+ $content = '
Text
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag( array( 'tag_closers' => 'visit' ) );
+ $p->next_tag( array( 'tag_closers' => 'visit' ) );
+ $this->assertNull( $p->get_content_between_balanced_tags() );
+ }
+
+ /**
+ * Tests the `set_content_between_balanced_tags` method on standard tags.
+ *
+ * @covers ::set_content_between_balanced_tags
+ */
+ public function test_set_content_between_balanced_tags_standard_tags() {
+ $content = '
Text
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( 'New text' );
+ $this->assertTrue( $result );
+ $this->assertEquals( '
New text
', $p );
+
+ $content = '
Text
More text
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( 'New text' );
+ $this->assertTrue( $result );
+ $this->assertEquals( '
New text
More text
', $p );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( 'More new text' );
+ $this->assertTrue( $result );
+ $this->assertEquals( '
New text
More new text
', $p );
+ }
+
+ /**
+ * Tests the `set_content_between_balanced_tags` method when called on a
+ * closing tag.
+ *
+ * @covers ::set_content_between_balanced_tags
+ */
+ public function test_set_content_between_balanced_tags_on_closing_tag() {
+ $content = '
Text
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag( array( 'tag_closers' => 'visit' ) );
+ $p->next_tag( array( 'tag_closers' => 'visit' ) );
+ $result = $p->set_content_between_balanced_tags( 'New text' );
+ $this->assertFalse( $result );
+ $this->assertEquals( '
Text
', $p );
+ }
+
+ /**
+ * Tests the `set_content_between_balanced_tags` method on multiple calls to
+ * the same tag.
+ *
+ * @covers ::set_content_between_balanced_tags
+ */
+ public function test_set_content_between_balanced_tags_multiple_calls_in_same_tag() {
+ $content = '
Text
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( 'New text' );
+ $this->assertTrue( $result );
+ $this->assertEquals( '
New text
', $p );
+ $result = $p->set_content_between_balanced_tags( 'More text' );
+ $this->assertTrue( $result );
+ $this->assertEquals( '
More text
', $p );
+ }
+
+ /**
+ * Tests the `set_content_between_balanced_tags` method on combinations with
+ * set_attribute calls.
+ *
+ * @covers ::set_content_between_balanced_tags
+ */
+ public function test_set_content_between_balanced_tags_with_set_attribute() {
+ $content = '
Text
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $p->set_attribute( 'class', 'test' );
+ $result = $p->set_content_between_balanced_tags( 'New text' );
+ $this->assertTrue( $result );
+ $this->assertEquals( '
New text
', $p );
+
+ $content = '
Text
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( 'New text' );
+ $this->assertTrue( $result );
+ $p->set_attribute( 'class', 'test' );
+ $this->assertEquals( '
New text
', $p );
+ }
+
+ /**
+ * Tests the `set_content_between_balanced_tags` method where the existing
+ * content includes tags.
+ *
+ * @covers ::set_content_between_balanced_tags
+ */
+ public function test_set_content_between_balanced_tags_with_existing_tags() {
+ $content = '
Text
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( 'New text' );
+ $this->assertTrue( $result );
+ $this->assertEquals( '
New text
', $p );
+ }
+
+ /**
+ * Tests the `set_content_between_balanced_tags` method where the new content
+ * includes tags.
+ *
+ * @covers ::set_content_between_balanced_tags
+ */
+ public function test_set_content_between_balanced_tags_with_new_tags() {
+ $content = '
Text
';
+ $new_content = '
New textLink';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $p->set_content_between_balanced_tags( $new_content );
+ $this->assertEquals( '
<span>New text</span><a href="#">Link</a>
', $p );
+ }
+
+ /**
+ * Tests the `set_content_between_balanced_tags` method with an empty string.
+ *
+ * @covers ::set_content_between_balanced_tags
+ */
+ public function test_set_content_between_balanced_tags_empty() {
+ $content = '
Text
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( '' );
+ $this->assertTrue( $result );
+ $this->assertEquals( '
', $p );
+
+ $content = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( '' );
+ $this->assertTrue( $result );
+ $this->assertEquals( '
', $p );
+ }
+
+ /**
+ * Tests the `set_content_between_balanced_tags` method on self-closing tags.
+ *
+ * @covers ::set_content_between_balanced_tags
+ */
+ public function test_set_content_between_balanced_tags_self_closing_tag() {
+ $content = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( 'New text' );
+ $this->assertFalse( $result );
+ $this->assertEquals( $content, $p );
+ }
+
+ /**
+ * Tests the `set_content_between_balanced_tags` method on a non-existent tag.
+ *
+ * @covers ::set_content_between_balanced_tags
+ */
+ public function test_set_content_between_balanced_tags_non_existent_tag() {
+ $content = 'Just a string with no tags.';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( 'New text' );
+ $this->assertFalse( $result );
+ $this->assertEquals( $content, $p );
+ }
+
+ /**
+ * Tests the `set_content_between_balanced_tags` method with unbalanced tags.
+ *
+ * @covers ::set_content_between_balanced_tags
+ */
+ public function test_set_content_between_balanced_tags_with_unbalanced_tags() {
+ $new_content = 'New text';
+
+ $content = '
Missing closing div';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( $new_content );
+ $this->assertFalse( $result );
+ $this->assertEquals( '
Missing closing div', $p );
+
+ $content = '
Missing closing div
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( $new_content );
+ $this->assertFalse( $result );
+ $this->assertEquals( '
Missing closing div
', $p );
+
+ $content = '
Missing closing div';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( $new_content );
+ $this->assertFalse( $result );
+ $this->assertEquals( '
Missing closing div', $p );
+
+ // It supports unbalanced tags inside the content.
+ $content = '
Missing opening span
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $result = $p->set_content_between_balanced_tags( $new_content );
+ $this->assertTrue( $result );
+ $this->assertEquals( '
New text
', $p );
+ }
+
+ /**
+ * Tests the is_void method.
+ *
+ * @covers ::is_void
+ */
+ public function test_is_void_element() {
+ $void_elements = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr' );
+ foreach ( $void_elements as $tag_name ) {
+ $content = "<{$tag_name} id={$tag_name}>";
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertTrue( $p->is_void() );
+ }
+
+ $non_void_elements = array( 'div', 'span', 'p', 'script', 'style' );
+ foreach ( $non_void_elements as $tag_name ) {
+ $content = "<{$tag_name} id={$tag_name}>Some content{$tag_name}>";
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertFalse( $p->is_void() );
+ }
+
+ // Test an upercase tag.
+ $content = '
';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertTrue( $p->is_void() );
+
+ // Test an empty string.
+ $content = '';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertFalse( $p->is_void() );
+
+ // Test on text nodes.
+ $content = 'This is just some text';
+ $p = new WP_Interactivity_API_Directives_Processor( $content );
+ $p->next_tag();
+ $this->assertFalse( $p->is_void() );
+ }
+}
diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-test.php
new file mode 100644
index 00000000000000..719f9592563c3b
--- /dev/null
+++ b/phpunit/interactivity-api/class-wp-interactivity-api-test.php
@@ -0,0 +1,570 @@
+interactivity = new WP_Interactivity_API();
+ }
+
+ /**
+ * Tests that the state and config methods return an empty array at the
+ * beginning.
+ *
+ * @covers ::state
+ * @covers ::config
+ */
+ public function test_state_and_config_should_be_empty() {
+ $this->assertEquals( array(), $this->interactivity->state( 'myPlugin' ) );
+ $this->assertEquals( array(), $this->interactivity->config( 'myPlugin' ) );
+ }
+
+ /**
+ * Tests that the state and config methods can change the state and
+ * configuration.
+ *
+ * @covers ::state
+ * @covers ::config
+ */
+ public function test_state_and_config_can_be_changed() {
+ $state = array(
+ 'a' => 1,
+ 'b' => 2,
+ 'nested' => array( 'c' => 3 ),
+ );
+ $result = $this->interactivity->state( 'myPlugin', $state );
+ $this->assertEquals( $state, $result );
+ $result = $this->interactivity->config( 'myPlugin', $state );
+ $this->assertEquals( $state, $result );
+ }
+
+ /**
+ * Tests that different initial states and configurations can be merged.
+ *
+ * @covers ::state
+ * @covers ::config
+ */
+ public function test_state_and_config_can_be_merged() {
+ $this->interactivity->state( 'myPlugin', array( 'a' => 1 ) );
+ $this->interactivity->state( 'myPlugin', array( 'b' => 2 ) );
+ $this->interactivity->state( 'otherPlugin', array( 'c' => 3 ) );
+ $this->assertEquals(
+ array(
+ 'a' => 1,
+ 'b' => 2,
+ ),
+ $this->interactivity->state( 'myPlugin' )
+ );
+ $this->assertEquals(
+ array( 'c' => 3 ),
+ $this->interactivity->state( 'otherPlugin' )
+ );
+
+ $this->interactivity->config( 'myPlugin', array( 'a' => 1 ) );
+ $this->interactivity->config( 'myPlugin', array( 'b' => 2 ) );
+ $this->interactivity->config( 'otherPlugin', array( 'c' => 3 ) );
+ $this->assertEquals(
+ array(
+ 'a' => 1,
+ 'b' => 2,
+ ),
+ $this->interactivity->config( 'myPlugin' )
+ );
+ $this->assertEquals(
+ array( 'c' => 3 ),
+ $this->interactivity->config( 'otherPlugin' )
+ ); }
+
+ /**
+ * Tests that existing keys in the initial state and configuration can be
+ * overwritten.
+ *
+ * @covers ::state
+ * @covers ::config
+ */
+ public function test_state_and_config_existing_props_can_be_overwritten() {
+ $this->interactivity->state( 'myPlugin', array( 'a' => 1 ) );
+ $this->interactivity->state( 'myPlugin', array( 'a' => 2 ) );
+ $this->assertEquals(
+ array( 'a' => 2 ),
+ $this->interactivity->state( 'myPlugin' )
+ );
+
+ $this->interactivity->config( 'myPlugin', array( 'a' => 1 ) );
+ $this->interactivity->config( 'myPlugin', array( 'a' => 2 ) );
+ $this->assertEquals(
+ array( 'a' => 2 ),
+ $this->interactivity->config( 'myPlugin' )
+ );
+ }
+
+ /**
+ * Tests that existing indexed arrays in the initial state and configuration
+ * are replaced, not merged.
+ *
+ * @covers ::state
+ * @covers ::config
+ */
+ public function test_state_and_config_existing_indexed_arrays_are_replaced() {
+ $this->interactivity->state( 'myPlugin', array( 'a' => array( 1, 2 ) ) );
+ $this->interactivity->state( 'myPlugin', array( 'a' => array( 3, 4 ) ) );
+ $this->assertEquals(
+ array( 'a' => array( 3, 4 ) ),
+ $this->interactivity->state( 'myPlugin' )
+ );
+
+ $this->interactivity->config( 'myPlugin', array( 'a' => array( 1, 2 ) ) );
+ $this->interactivity->config( 'myPlugin', array( 'a' => array( 3, 4 ) ) );
+ $this->assertEquals(
+ array( 'a' => array( 3, 4 ) ),
+ $this->interactivity->config( 'myPlugin' )
+ );
+ }
+
+ /**
+ * Invokes the private `print_client_interactivity` method of
+ * WP_Interactivity_API class.
+ *
+ * @return array|null The content of the JSON object printed on the client-side or null if nothings was printed.
+ */
+ private function print_client_interactivity_data() {
+ $interactivity_data_markup = get_echo( array( $this->interactivity, 'print_client_interactivity_data' ) );
+ preg_match( '/