diff --git a/lib/compat/wordpress-6.5/compat.php b/lib/compat/wordpress-6.5/compat.php new file mode 100644 index 0000000000000..7844792712589 --- /dev/null +++ b/lib/compat/wordpress-6.5/compat.php @@ -0,0 +1,38 @@ + $arr The array being evaluated. + * @return bool True if array is a list, false otherwise. + */ + function array_is_list( $arr ) { + if ( ( array() === $arr ) || ( array_values( $arr ) === $arr ) ) { + return true; + } + + $next_key = -1; + + foreach ( $arr as $k => $v ) { + if ( ++$next_key !== $k ) { + return false; + } + } + + return true; + } +} diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php index b437bcefa6756..b4cfa5a499872 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api-directives-processor.php @@ -15,14 +15,24 @@ */ class WP_Interactivity_API_Directives_Processor extends Gutenberg_HTML_Tag_Processor_6_5 { /** - * Returns the content between two balanced tags. + * Returns the content between two balanced template tags. + * + * It positions the cursor in the closer tag of the balanced template tag, + * if it exists. * * @access private * - * @return string|null The content between the current opening and its matching closing tag or null if it doesn't - * find the matching closing tag. + * @return string|null The content between the current opener template tag and its matching closer tag or null if it + * doesn't find the matching closing tag. */ - public function get_content_between_balanced_tags() { + public function get_content_between_balanced_template_tags() { + if ( 'TEMPLATE' !== $this->get_tag() || $this->is_tag_closer() ) { + return null; + } + + // Flushes any changes. + $this->get_updated_html(); + $bookmarks = $this->get_balanced_tag_bookmarks(); if ( ! $bookmarks ) { return null; @@ -32,7 +42,6 @@ public function get_content_between_balanced_tags() { $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 ); @@ -48,6 +57,7 @@ public function get_content_between_balanced_tags() { * @return bool Whether the content was successfully replaced. */ public function set_content_between_balanced_tags( string $new_content ): bool { + // Flushes any changes. $this->get_updated_html(); $bookmarks = $this->get_balanced_tag_bookmarks(); @@ -67,6 +77,37 @@ public function set_content_between_balanced_tags( string $new_content ): bool { return true; } + /** + * Appends content after the closing tag of a template tag. + * + * This method positions the processor in the last tag of the appended + * content, if it exists. + * + * @access private + * + * @param string $new_content The string to append after the closing template tag. + * @return bool Whether the content was successfully appended. + */ + public function append_content_after_template_tag_closer( string $new_content ): bool { + // Refuses to process if the content is empty or this is not a closer template tag. + if ( empty( $new_content ) || 'TEMPLATE' !== $this->get_tag() || ! $this->is_tag_closer() ) { + return false; + } + + // Flushes any changes. + $this->get_updated_html(); + + $bookmark = 'append_content_after_template_tag_closer'; + $this->set_bookmark( $bookmark ); + $end = $this->bookmarks[ $bookmark ]->start + $this->bookmarks[ $bookmark ]->length + 1; + $this->release_bookmark( $bookmark ); + + // Appends the new content. + $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $end, 0, $new_content ); + + return true; + } + /** * Returns a pair of bookmarks for the current opening tag and the matching * closing tag. @@ -78,7 +119,7 @@ private function get_balanced_tag_bookmarks() { $start_name = 'start_of_balanced_tag_' . ++$i; $this->set_bookmark( $start_name ); - if ( ! $this->next_balanced_closer() ) { + if ( ! $this->next_balanced_tag_closer_tag() ) { $this->release_bookmark( $start_name ); return null; } @@ -93,13 +134,15 @@ private function get_balanced_tag_bookmarks() { * Finds the matching closing tag for an opening tag. * * When called while the processor is on an open tag, it traverses the HTML - * until it finds the matching closing tag, respecting any in-between content, - * including nested tags of the same name. Returns false when called on a - * closing or void tag, or if no matching closing tag was found. + * until it finds the matching 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. + * + * @access private * * @return bool Whether a matching closing tag was found. */ - private function next_balanced_closer(): bool { + public function next_balanced_tag_closer_tag(): bool { $depth = 0; $tag_name = $this->get_tag(); diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php index 9cbbfb1d6b654..be9203198d3f2 100644 --- a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php +++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php @@ -25,6 +25,12 @@ class WP_Interactivity_API { 'data-wp-class' => 'data_wp_class_processor', 'data-wp-style' => 'data_wp_style_processor', 'data-wp-text' => 'data_wp_text_processor', + /* + * `data-wp-each` needs to be processed in the last place because it moves + * the cursor to the end of the processed items to prevent them to be + * processed twice. + */ + 'data-wp-each' => 'data_wp_each_processor', ); /** @@ -194,11 +200,30 @@ public function add_hooks() { * @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; + $namespace_stack = array(); + $result = $this->process_directives_args( $html, $context_stack, $namespace_stack ); + return null === $result ? $html : $result; + } + + /** + * Processes the interactivity directives contained within the HTML content + * and updates the markup accordingly. + * + * It needs the context and namespace stacks to be passed by reference and + * it returns null if the HTML contains unbalanced tags. + * + * @since 6.5.0 + * + * @param string $html The HTML content to process. + * @param array $context_stack The reference to the array used to keep track of contexts during processing. + * @param array $namespace_stack The reference to the array used to manage namespaces during processing. + * @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags. + */ + private function process_directives_args( string $html, array &$context_stack, array &$namespace_stack ) { + $p = new WP_Interactivity_API_Directives_Processor( $html ); + $tag_stack = array(); + $unbalanced = false; $directive_processor_prefixes = array_keys( self::$directive_processors ); $directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes ); @@ -234,26 +259,28 @@ public function process_directives( string $html ): string { } } } else { - $directives_prefixes = array(); - - foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { + if ( 0 === count( $p->get_attribute_names_with_prefix( 'data-wp-each-child' ) ) ) { + $directives_prefixes = array(); + + // Checks if there is is a server directive processor registered for each directive. + foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) { + list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name ); + if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) { + $directives_prefixes[] = $directive_prefix; + } + } /* - * 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 ); } - } - - /* - * 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 ); + } else { + // Jumps to the tag closer if the tag has a `data-wp-each-child` directive. + $p->next_balanced_tag_closer_tag(); + continue; } } @@ -276,17 +303,17 @@ public function process_directives( string $html ): string { : array( $this, self::$directive_processors[ $directive_prefix ] ); call_user_func_array( $func, - array( $p, &$context_stack, &$namespace_stack ) + array( $p, &$context_stack, &$namespace_stack, &$tag_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. + * It returns null if the HTML is unbalanced because unbalanced HTML is + * not safe to process. In that case, the Interactivity API runtime will + * update the HTML on the client side during the hydration. */ - return $unbalanced || 0 < count( $tag_stack ) ? $html : $p->get_updated_html(); + return $unbalanced || 0 < count( $tag_stack ) ? null : $p->get_updated_html(); } /** @@ -403,6 +430,23 @@ private function extract_directive_value( $directive_value, $default_namespace = return array( $default_namespace, $directive_value ); } + /** + * Transforms a kebab-case string to camelCase. + * + * @param string $str The kebab-case string to transform to camelCase. + * @return string The transformed camelCase string. + */ + private function kebab_to_camel_case( string $str ): string { + return lcfirst( + preg_replace_callback( + '/(-)([a-z])/', + function ( $matches ) { + return strtoupper( $matches[2] ); + }, + strtolower( preg_replace( '/-+$/', '', $str ) ) + ) + ); + } /** * Processes the `data-wp-interactive` directive. @@ -768,9 +812,109 @@ class="screen-reader-text" > HTML; }; + add_action( 'wp_footer', $callback ); } } - } + /** + * Processes the `data-wp-each` directive. + * + * This directive gets an array passed as reference and iterates over it + * generating new content for each item based on the inner markup of the + * `template` tag. + * + * @since 6.5.0 + * + * @param WP_Interactivity_API_Directives_Processor $p The directives processor instance. + * @param array $context_stack The reference to the context stack. + * @param array $namespace_stack The reference to the store namespace stack. + * @param array $tag_stack The reference to the tag stack. + */ + private function data_wp_each_processor( WP_Interactivity_API_Directives_Processor $p, array &$context_stack, array &$namespace_stack, array &$tag_stack ) { + if ( ! $p->is_tag_closer() && 'TEMPLATE' === $p->get_tag() ) { + $attribute_name = $p->get_attribute_names_with_prefix( 'data-wp-each' )[0]; + $extracted_suffix = $this->extract_prefix_and_suffix( $attribute_name ); + $item_name = isset( $extracted_suffix[1] ) ? $this->kebab_to_camel_case( $extracted_suffix[1] ) : 'item'; + $attribute_value = $p->get_attribute( $attribute_name ); + $result = $this->evaluate( $attribute_value, end( $namespace_stack ), end( $context_stack ) ); + + // Gets the content between the template tags and leaves the cursor in the closer tag. + $inner_content = $p->get_content_between_balanced_template_tags(); + + // Checks if there is a manual server-side directive processing. + $template_end = 'data-wp-each: template end'; + $p->set_bookmark( $template_end ); + $p->next_tag(); + $manual_sdp = $p->get_attribute( 'data-wp-each-child' ); + $p->seek( $template_end ); // Rewinds to the template closer tag. + $p->release_bookmark( $template_end ); + + /* + * It doesn't process in these situations: + * - Manual server-side directive processing. + * - Empty or non-array values. + * - Associative arrays because those are deserialized as objects in JS. + * - Templates that contain top-level texts because those texts can't be + * identified and removed in the client. + */ + if ( + $manual_sdp || + empty( $result ) || + ! is_array( $result ) || + ! array_is_list( $result ) || + ! str_starts_with( trim( $inner_content ), '<' ) || + ! str_ends_with( trim( $inner_content ), '>' ) + ) { + array_pop( $tag_stack ); + return; + } + + // Extracts the namespace from the directive attribute value. + $namespace_value = end( $namespace_stack ); + list( $namespace_value ) = is_string( $attribute_value ) && ! empty( $attribute_value ) + ? $this->extract_directive_value( $attribute_value, $namespace_value ) + : array( $namespace_value, null ); + + // Processes the inner content for each item of the array. + $processed_content = ''; + foreach ( $result as $item ) { + // Creates a new context that includes the current item of the array. + array_push( + $context_stack, + array_replace_recursive( + end( $context_stack ) !== false ? end( $context_stack ) : array(), + array( $namespace_value => array( $item_name => $item ) ) + ) + ); + + // Processes the inner content with the new context. + $processed_item = $this->process_directives_args( $inner_content, $context_stack, $namespace_stack ); + + if ( null === $processed_item ) { + // If the HTML is unbalanced, stop processing it. + array_pop( $context_stack ); + return; + } + + // Adds the `data-wp-each-child` to each top-level tag. + $i = new WP_Interactivity_API_Directives_Processor( $processed_item ); + while ( $i->next_tag() ) { + $i->set_attribute( 'data-wp-each-child', true ); + $i->next_balanced_tag_closer_tag(); + } + $processed_content .= $i->get_updated_html(); + + // Removes the current context from the stack. + array_pop( $context_stack ); + } + + // Appends the processed content after the tag closer of the template. + $p->append_content_after_template_tag_closer( $processed_content ); + + // Pops the last tag because it skipped the closing tag of the template tag. + array_pop( $tag_stack ); + } + } + } } diff --git a/lib/experimental/interactivity-api.php b/lib/experimental/interactivity-api.php new file mode 100644 index 0000000000000..aff57bf0bce80 --- /dev/null +++ b/lib/experimental/interactivity-api.php @@ -0,0 +1,22 @@ + +
+ + +

A

+

B

+

C

+
+ +
+