Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interactivity API: Server Directive Processor for data-wp-each #58498

Merged
merged 27 commits into from
Feb 4, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0f8008e
Add append_content_after_closing_tag_on_balanced_or_void_tags method
luisherranz Jan 29, 2024
2c70fb8
Make next_balanced_tag_closer_tag public and add tests
luisherranz Jan 30, 2024
093bb36
Add compat for array_is_list added in WP 6.5
luisherranz Jan 30, 2024
e378aad
Add missing covers in wp-text tests
luisherranz Jan 30, 2024
767b9aa
Minor fixes to the Interactivity API directive processor
luisherranz Jan 30, 2024
106eee6
Create internal method process_directives_args to call it from wp-each
luisherranz Jan 30, 2024
58abeb0
Add kebab-case to camelCase method
luisherranz Jan 30, 2024
4a62bb6
Add wp-each processor
luisherranz Jan 31, 2024
29f10fa
Make sure it doesn't process non-array values
luisherranz Jan 31, 2024
527a828
Merge branch 'trunk' into add/data-wp-each-server-directive-processor
luisherranz Jan 31, 2024
3b67d47
Trim before checking that starts and ends with tags
luisherranz Jan 31, 2024
72a2f19
Fix typo and some extra tabs
luisherranz Jan 31, 2024
5f3082c
Fix PHPCS
luisherranz Jan 31, 2024
a20240d
Add kebal to camel case conversion in the JS runtime
luisherranz Jan 31, 2024
fa33e6e
Add extra nested test
luisherranz Feb 2, 2024
ed4319e
Restrict appending to template tags
luisherranz Feb 3, 2024
b25a04c
Merge branch 'trunk' into add/data-wp-each-server-directive-processor
luisherranz Feb 3, 2024
f347057
Override WP Core script modules
luisherranz Feb 3, 2024
f434acc
Switch to `data-wp-remove` directive
luisherranz Feb 3, 2024
42d6404
Rename back to data-wp-each-child
luisherranz Feb 4, 2024
b1fd904
Merge branch 'trunk' into add/data-wp-each-server-directive-processor
luisherranz Feb 4, 2024
56f373e
Add missing continue in foreach loop
luisherranz Feb 4, 2024
62d19c4
Don't return a value
luisherranz Feb 4, 2024
f9a2731
Merge branch 'trunk' into add/data-wp-each-server-directive-processor
luisherranz Feb 4, 2024
8fda776
Transform interactivity index to TS
luisherranz Feb 4, 2024
bd858e6
Fix includes_url logic
luisherranz Feb 4, 2024
628509a
Also move vdom to TS
luisherranz Feb 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions lib/compat/wordpress-6.5/compat.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
/**
* WordPress 6.5 compatibility functions.
*
* @package WordPress
*/

if ( ! function_exists( 'array_is_list' ) ) {
/**
* Polyfill for `array_is_list()` function added in PHP 8.1.
*
* Determines if the given array is a list.
*
* An array is considered a list if its keys consist of consecutive numbers from 0 to count($array)-1.
*
* @see https://github.com/symfony/polyfill-php81/tree/main
*
* @since 6.5.0
*
* @param array<mixed> $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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,51 @@ public function set_content_between_balanced_tags( string $new_content ): bool {
return true;
}

/**
* Appends content after the closing tag of a balanced tag or after a void
* 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 tag.
* @return bool Whether the content was successfully appended.
*/
public function append_content_after_closing_tag_on_balanced_or_void_tags( string $new_content ): bool {
if ( empty( $new_content ) ) {
return false;
}

$this->get_updated_html();

if ( $this->is_void() ) {
$bookmark = 'start_of_void_tag';
$this->set_bookmark( $bookmark );
$end = $this->bookmarks[ $bookmark ]->start + $this->bookmarks[ $bookmark ]->length + 1;
$this->release_bookmark( $bookmark );
} else {
$bookmarks = $this->get_balanced_tag_bookmarks();
if ( ! $bookmarks ) {
return false;
}
list( $start_name, $end_name ) = $bookmarks;

$end = $this->bookmarks[ $end_name ]->start + $this->bookmarks[ $end_name ]->length + 1;

$this->release_bookmark( $start_name );
$this->release_bookmark( $end_name );
}

$this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $end, 0, $new_content );

// Move the processor to the opening tag of the appended content.
$this->next_tag();

return true;
}

/**
* Returns a pair of bookmarks for the current opening tag and the matching
* closing tag.
Expand All @@ -78,7 +123,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;
}
Expand All @@ -93,13 +138,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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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',
);

/**
Expand Down Expand Up @@ -178,11 +184,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 );
Expand Down Expand Up @@ -260,17 +285,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();
}

/**
Expand Down Expand Up @@ -387,6 +412,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.
Expand Down Expand Up @@ -673,6 +715,104 @@ private function data_wp_text_processor( WP_Interactivity_API_Directives_Process
}
}
}
}

/**
* 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 ) );
$inner_content = $p->get_content_between_balanced_tags();

/*
* It doesn't process associative arrays because those will be deserialized
* as objects in JS.
*
* It doesn't process templates that contain top-level texts because
* those texts can't be identified to be removed in the client.
* Note: there might be top-level texts in between balanced tags, but
* those cannot be identified at this moment.
*/
if ( ! array_is_list( $result ) || ! str_starts_with( $inner_content, '<' ) || ! str_ends_with( $inner_content, '>' ) ) {
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 );

$processed_content = '';
$number_of_top_level_tags = 0;
// Processes the inner content for each item of the array.
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.
return array_pop( $context_stack );
}

// Adds the `data-wp-each-child` to each top-level tag.
$i = new WP_Interactivity_API_Directives_Processor( $processed_item );
while ( $i->next_tag() ) {
$number_of_top_level_tags += 1;
$i->set_attribute( 'data-wp-each-child', true );
/*
* Moves to the tag closer of the current top-level tag so the next
* call to `next_tag()` moves to the opener tag of the next
* top-level tag.
*/
$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_closing_tag_on_balanced_or_void_tags( $processed_content );

// Moves the cursor to the end of the processed items.
do {
$p->next_balanced_tag_closer_tag();
if ( $number_of_top_level_tags > 1 ) {
$number_of_top_level_tags -= 1;
} else {
break;
}
} while ( $p->next_tag() );

// Pops the last tag because it skipped the closing tag of the template tag.
array_pop( $tag_stack );
}
}
}
}
1 change: 1 addition & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/compat/wordpress-6.4/kses.php';

// WordPress 6.5 compat.
require __DIR__ . '/compat/wordpress-6.5/compat.php';
require __DIR__ . '/compat/wordpress-6.5/blocks.php';
require __DIR__ . '/compat/wordpress-6.5/block-patterns.php';
require __DIR__ . '/compat/wordpress-6.5/kses.php';
Expand Down
Loading
Loading