From 0f8008e22111fc78f22b7e38464bb5e05ffa18b6 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Mon, 29 Jan 2024 14:05:20 +0100 Subject: [PATCH 01/23] Add append_content_after_closing_tag_on_balanced_or_void_tags method --- ...interactivity-api-directives-processor.php | 46 +++ ...activity-api-directives-processor-test.php | 267 +++++++++++++++++- 2 files changed, 310 insertions(+), 3 deletions(-) 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 b437bcefa67568..f687c02f1c1362 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 @@ -67,6 +67,52 @@ 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 opening 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->seek( $end_name ); + $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. 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 index aa1ee999fd58f1..f1df14b94276af 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php @@ -301,21 +301,21 @@ public function test_set_content_between_balanced_tags_with_unbalanced_tags() { $p->next_tag(); $result = $p->set_content_between_balanced_tags( $new_content ); $this->assertFalse( $result ); - $this->assertEquals( '
Missing closing div', $p ); + $this->assertEquals( $content, $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 ); + $this->assertEquals( $content, $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 ); + $this->assertEquals( $content, $p ); // It supports unbalanced tags inside the content. $content = '
Missing opening span
'; @@ -366,4 +366,265 @@ public function test_is_void_element() { $p->next_tag(); $this->assertFalse( $p->is_void() ); } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method with a simple text. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_tags_simple_text() { + $content_1 = '
Text
'; + $content_2 = 'New text'; + $p = new WP_Interactivity_API_Directives_Processor( $content_1 ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_2 ); + $this->assertTrue( $result ); + $this->assertEquals( $content_1 . $content_2, $p ); + $this->assertNull( $p->get_tag() ); // There are no more tags. + } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method with simple tags. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_tags_simple_tags() { + $content_1 = '
Text
'; + $content_2 = '
New text
'; + $content_3 = '
More new text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content_1 ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_2 ); + $this->assertTrue( $result ); + $this->assertEquals( $content_1 . $content_2, $p ); + // The processor in now positioned in the opening tag of the appended tag. + $this->assertEquals( 'content-2', $p->get_attribute( 'class' ) ); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_3 ); + $this->assertTrue( $result ); + $this->assertEquals( $content_1 . $content_2 . $content_3, $p ); + $this->assertEquals( 'content-3', $p->get_attribute( 'class' ) ); + } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method in the middle of two tags. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_tags_in_the_middle_of_tags() { + $content_1 = '
Text
'; + $content_2 = 'New text'; + $content_3 = '
More new text
'; + $content_4 = '
Even more new text
'; + + $p = new WP_Interactivity_API_Directives_Processor( $content_1 . $content_3 ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_2 ); + $this->assertTrue( $result ); + $this->assertEquals( $content_1 . $content_2 . $content_3, $p ); + // When appending text without tags, it jumps to the next tag in the content. + $this->assertEquals( 'content-3', $p->get_attribute( 'class' ) ); + + $p = new WP_Interactivity_API_Directives_Processor( $content_1 . $content_3 ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_4 ); + $this->assertTrue( $result ); + $this->assertEquals( $content_1 . $content_4 . $content_3, $p ); + $this->assertEquals( 'content-4', $p->get_attribute( 'class' ) ); + } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method doesn't modify the content when called on a closing tag. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_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->append_content_after_closing_tag_on_balanced_or_void_tags( 'New text' ); + $this->assertFalse( $result ); + $this->assertEquals( $content, $p ); + } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method on multiple calls to the same tag. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_tags_multiple_calls_in_same_tag() { + $content_1 = '
Text
'; + $content_2 = '
New text
'; + $content_3 = '
More new text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content_1 ); + $p->next_tag(); + $p->set_bookmark( 'first div' ); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_2 ); + $this->assertTrue( $result ); + $this->assertEquals( $content_1 . $content_2, $p ); + $this->assertEquals( 'content-2', $p->get_attribute( 'class' ) ); + $p->seek( 'first div' ); // Rewind to the first div. + $this->assertEquals( 'content-1', $p->get_attribute( 'class' ) ); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_3 ); + $this->assertEquals( $content_1 . $content_3 . $content_2, $p ); + $this->assertEquals( 'content-3', $p->get_attribute( 'class' ) ); + } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method on combinations with set_attribute calls. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_tags_with_set_attribute() { + $content_1 = '
Text
'; + $content_2 = '
New text
'; + + $p = new WP_Interactivity_API_Directives_Processor( $content_1 ); + $p->next_tag(); + $p->set_attribute( 'class', 'test' ); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_2 ); + $this->assertTrue( $result ); + $this->assertEquals( '
Text
' . $content_2, $p ); + + $p = new WP_Interactivity_API_Directives_Processor( $content_1 ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_2 ); + $this->assertTrue( $result ); + $p->set_attribute( 'class', 'test' ); + $this->assertEquals( $content_1 . '
New text
', $p ); + } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method where the existing content includes tags. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_tags_with_existing_tags() { + $content_1 = '
Text
'; + $content_2 = '
New text
'; + $content_3 = '
More new text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content_1 ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_2 ); + $this->assertTrue( $result ); + $this->assertEquals( $content_1 . $content_2, $p ); + $this->assertEquals( 'content-2-div', $p->get_attribute( 'class' ) ); + $p->next_tag(); + $this->assertEquals( 'content-2-span', $p->get_attribute( 'class' ) ); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_3 ); + $this->assertTrue( $result ); + $this->assertEquals( $content_1 . '
New text' . $content_3 . '
', $p ); + } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method fails with an empty string. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_tags_empty() { + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( '' ); + $this->assertFalse( $result ); + $this->assertEquals( $content, $p ); + $this->assertEquals( 'content', $p->get_attribute( 'class' ) ); // It didn't move. + + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( '' ); + $this->assertFalse( $result ); + $this->assertEquals( $content, $p ); + $this->assertEquals( 'content', $p->get_attribute( 'class' ) ); // It didn't move. + } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method on self-closing tags. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_tags_self_closing_tag() { + $content_1 = ''; + $content_2 = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content_1 ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_2 ); + $this->assertTrue( $result ); + $this->assertEquals( $content_1 . $content_2, $p ); + $this->assertEquals( 'content-2', $p->get_attribute( 'class' ) ); // It didn't move. + + $content_1 = ''; + $p = new WP_Interactivity_API_Directives_Processor( $content_1 ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_2 ); + $this->assertTrue( $result ); + $this->assertEquals( $content_1 . $content_2, $p ); + $this->assertEquals( 'content-2', $p->get_attribute( 'class' ) ); // It didn't move. + } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method on a non-existent tag. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_tags_non_existent_tag() { + $content_1 = 'Just a string with no tags.'; + $content_2 = '
New text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content_1 ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $content_2 ); + $this->assertFalse( $result ); + $this->assertEquals( $content_1, $p ); + } + + /** + * Tests the `append_content_after_closing_tag_on_balanced_or_void_tags` + * method with unbalanced tags. + * + * @covers ::append_content_after_closing_tag_on_balanced_or_void_tags + */ + public function test_append_content_after_closing_tag_on_balanced_or_void_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->append_content_after_closing_tag_on_balanced_or_void_tags( $new_content ); + $this->assertFalse( $result ); + $this->assertEquals( $content, $p ); + + $content = '
Missing closing div
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $new_content ); + $this->assertFalse( $result ); + $this->assertEquals( $content, $p ); + + $content = '
Missing closing div'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $new_content ); + $this->assertFalse( $result ); + $this->assertEquals( $content, $p ); + + // It supports unbalanced tags inside the content, as long as it finds a + // balanced closing tag. + $content = '
Missing opening span
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $result = $p->append_content_after_closing_tag_on_balanced_or_void_tags( $new_content ); + $this->assertTrue( $result ); + $this->assertEquals( $content . $new_content, $p ); + } } From 2c70fb87a8b4e6e02e82860369751ca19f70009a Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 30 Jan 2024 12:57:32 +0100 Subject: [PATCH 02/23] Make next_balanced_tag_closer_tag public and add tests --- ...interactivity-api-directives-processor.php | 10 +-- ...activity-api-directives-processor-test.php | 89 +++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) 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 f687c02f1c1362..b8937f83738a0f 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 @@ -124,7 +124,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; } @@ -139,13 +139,13 @@ 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. * * @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/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php index f1df14b94276af..8e237b9724ec25 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php @@ -627,4 +627,93 @@ public function test_append_content_after_closing_tag_on_balanced_or_void_tags_w $this->assertTrue( $result ); $this->assertEquals( $content . $new_content, $p ); } + + /** + * Tests that the `next_balanced_tag_closer_tag` method finds a closing tag + * for a standard tag. + * + * @covers ::next_balanced_tag_closer_tag + */ + public function test_next_balanced_tag_closer_tag_standard_tags() { + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertTrue( $p->next_balanced_tag_closer_tag() ); + $this->assertEquals( 'DIV', $p->get_tag() ); + $this->assertTrue( $p->is_tag_closer() ); + } + + /** + * Tests that the `next_balanced_tag_closer_tag` method returns false for a + * self-closing tag. + * + * @covers ::next_balanced_tag_closer_tag + */ + public function test_next_balanced_tag_closer_tag_void_tag() { + $content = ''; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertFalse( $p->next_balanced_tag_closer_tag() ); + + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertFalse( $p->next_balanced_tag_closer_tag() ); + } + + /** + * Tests that the `next_balanced_tag_closer_tag` method correctly handles + * nested tags. + * + * @covers ::next_balanced_tag_closer_tag + */ + public function test_next_balanced_tag_closer_tag_nested_tags() { + $content = '
Nested content
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertTrue( $p->next_balanced_tag_closer_tag() ); + $this->assertEquals( 'DIV', $p->get_tag() ); + $this->assertTrue( $p->is_tag_closer() ); + + $content = '
Nested content
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertTrue( $p->next_balanced_tag_closer_tag() ); + $this->assertEquals( 'DIV', $p->get_tag() ); + $this->assertTrue( $p->is_tag_closer() ); + $this->assertFalse( $p->next_tag() ); // No more content. + } + + /** + * Tests that the `next_balanced_tag_closer_tag` method returns false when no + * matching closing tag is found. + * + * @covers ::next_balanced_tag_closer_tag + */ + public function test_next_balanced_tag_closer_tag_no_matching_closing_tag() { + $content = '
No closing tag here'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + + $content = '
No closing tag here
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertFalse( $p->next_balanced_tag_closer_tag() ); + $this->assertFalse( $p->next_balanced_tag_closer_tag() ); + } + + /** + * Test that the `next_balanced_tag_closer_tag` method returns false when + * returned on a closing tag. + * + * @covers ::next_balanced_tag_closer_tag + */ + public function test_next_balanced_tag_closer_tag_on_closing_tag() { + $content = '
Closing tag after this
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + // Visit opening tag first and then closing tag. + $p->next_tag(); + $p->next_tag( array( 'tag_closers' => 'visit' ) ); + $this->assertFalse( $p->next_balanced_tag_closer_tag() ); + } } From 093bb36c3f4ae63b81af5f266d77b831d3431c0d Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 30 Jan 2024 16:14:09 +0100 Subject: [PATCH 03/23] Add compat for array_is_list added in WP 6.5 --- lib/compat/wordpress-6.5/compat.php | 38 +++++++++++++++++++++++++++++ lib/load.php | 1 + 2 files changed, 39 insertions(+) create mode 100644 lib/compat/wordpress-6.5/compat.php diff --git a/lib/compat/wordpress-6.5/compat.php b/lib/compat/wordpress-6.5/compat.php new file mode 100644 index 00000000000000..78447927125894 --- /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/load.php b/lib/load.php index 4b2b4d5d8b0db8..ab12b5b91c3e9a 100644 --- a/lib/load.php +++ b/lib/load.php @@ -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'; From e378aad9fe9571acf666740261dd0ee8b7c59650 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 30 Jan 2024 19:09:36 +0100 Subject: [PATCH 04/23] Add missing covers in wp-text tests --- .../class-wp-interactivity-api-wp-text-test.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-wp-text-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-wp-text-test.php index f5b570a197a298..17e71e7869dadf 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-wp-text-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-wp-text-test.php @@ -27,6 +27,8 @@ public function set_up() { /** * Tests that the `data-wp-text` directive sets inner text content. + * + * @covers ::process_directives */ public function test_wp_text_sets_inner_content() { $html = '
Text
'; @@ -36,6 +38,8 @@ public function test_wp_text_sets_inner_content() { /** * Tests that the `data-wp-text` directive works with numerical values. + * + * @covers ::process_directives */ public function test_wp_text_sets_inner_content_numbers() { $this->interactivity->state( 'myPlugin', array( 'number' => 100 ) ); @@ -47,6 +51,8 @@ public function test_wp_text_sets_inner_content_numbers() { /** * Tests that the `data-wp-text` directive removes inner text content when the * state is not a string or number. + * + * @covers ::process_directives */ public function test_wp_text_removes_inner_content_on_types_that_are_not_strings_or_numbers() { $this->interactivity->state( @@ -83,6 +89,8 @@ public function test_wp_text_removes_inner_content_on_types_that_are_not_strings /** * Tests that the `data-wp-text` directive overwrites entire inner content, * including nested tags. + * + * @covers ::process_directives */ public function test_wp_text_sets_inner_content_with_nested_tags() { $html = '
Text
Another text
'; @@ -93,6 +101,8 @@ public function test_wp_text_sets_inner_content_with_nested_tags() { /** * Tests that the `data-wp-text` directive works even with unbalanced tags * when they are different tags (div -> unbalanced span). + * + * @covers ::process_directives */ public function test_wp_text_sets_inner_content_even_with_unbalanced_but_different_tags_inside_content() { $html = '
Text
'; @@ -103,6 +113,8 @@ public function test_wp_text_sets_inner_content_even_with_unbalanced_but_differe /** * Tests that the `data-wp-text` fails to overwrite inner content if there are * unbalanced when they are the same tags (div -> unbalanced div). + * + * @covers ::process_directives */ public function test_wp_text_fails_with_unbalanced_and_same_tags_inside_content() { $html = '
Text
'; @@ -113,6 +125,8 @@ public function test_wp_text_fails_with_unbalanced_and_same_tags_inside_content( /** * Tests that the `data-wp-text` directive cannot set inner HTML content and * it will be encoded as text. + * + * @covers ::process_directives */ public function test_wp_text_cant_set_inner_html_in_the_content() { $this->interactivity->state( 'myPlugin', array( 'text' => 'Updated' ) ); From 767b9aab602850dd41f778c3248be7f6b688ab4b Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 30 Jan 2024 19:10:06 +0100 Subject: [PATCH 05/23] Minor fixes to the Interactivity API directive processor --- .../class-wp-interactivity-api-directives-processor.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 b8937f83738a0f..5b6a86dfec70f4 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 @@ -71,7 +71,7 @@ public function set_content_between_balanced_tags( string $new_content ): bool { * Appends content after the closing tag of a balanced tag or after a void * tag. * - * This method positions the processor in the opening tag of the appended + * This method positions the processor in the last tag of the appended * content, if it exists. * * @access private @@ -100,7 +100,6 @@ public function append_content_after_closing_tag_on_balanced_or_void_tags( strin $end = $this->bookmarks[ $end_name ]->start + $this->bookmarks[ $end_name ]->length + 1; - $this->seek( $end_name ); $this->release_bookmark( $start_name ); $this->release_bookmark( $end_name ); } @@ -143,6 +142,8 @@ private function get_balanced_tag_bookmarks() { * 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. */ public function next_balanced_tag_closer_tag(): bool { From 106eee6e134f91f4b8d7b18a3c766e2f722e4205 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 30 Jan 2024 19:14:24 +0100 Subject: [PATCH 06/23] Create internal method process_directives_args to call it from wp-each --- .../class-wp-interactivity-api.php | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) 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 ad9e5d7c439533..60ad0d7e1731a8 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 @@ -178,11 +178,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 ); @@ -266,11 +285,11 @@ public function process_directives( string $html ): string { } /* - * 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(); } /** From 58abeb0561bb15d631e6072eb678081606395bf0 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Tue, 30 Jan 2024 20:07:48 +0100 Subject: [PATCH 07/23] Add kebab-case to camelCase method --- .../class-wp-interactivity-api.php | 17 ++++++++++++++ .../class-wp-interactivity-api-test.php | 22 +++++++++++++++++++ 2 files changed, 39 insertions(+) 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 60ad0d7e1731a8..bbf9f689aef763 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 @@ -406,6 +406,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. diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-test.php index 719f9592563c3b..86462cddca4cad 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-test.php @@ -567,4 +567,26 @@ public function test_evaluate_nested_value() { $result = $this->evaluate( 'otherPlugin::context.nested.key' ); $this->assertEquals( 'otherPlugin-context-nested', $result ); } + + /** + * Tests kebab_to_camel_case method with typical cases. + * + * @covers ::kebab_to_camel_case + */ + public function test_kebab_to_camel_case() { + $method = new ReflectionMethod( $this->interactivity, 'kebab_to_camel_case' ); + $method->setAccessible( true ); + + $this->assertSame( '', $method->invoke( $this->interactivity, '' ) ); + $this->assertSame( 'item', $method->invoke( $this->interactivity, 'item' ) ); + $this->assertSame( 'myItem', $method->invoke( $this->interactivity, 'my-item' ) ); + $this->assertSame( 'my_item', $method->invoke( $this->interactivity, 'my_item' ) ); + $this->assertSame( 'myItem', $method->invoke( $this->interactivity, 'My-iTem' ) ); + $this->assertSame( 'myItemWithMultipleHyphens', $method->invoke( $this->interactivity, 'my-item-with-multiple-hyphens' ) ); + $this->assertSame( 'myItemWith-DoubleHyphens', $method->invoke( $this->interactivity, 'my-item-with--double-hyphens' ) ); + $this->assertSame( 'myItemWith_underScore', $method->invoke( $this->interactivity, 'my-item-with_under-score' ) ); + $this->assertSame( 'myItem', $method->invoke( $this->interactivity, '-my-item' ) ); + $this->assertSame( 'myItem', $method->invoke( $this->interactivity, 'my-item-' ) ); + $this->assertSame( 'myItem', $method->invoke( $this->interactivity, '-my-item-' ) ); + } } From 4a62bb680fc1e241e4657a7e4ccaa2ca99c450a2 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 31 Jan 2024 13:12:18 +0100 Subject: [PATCH 08/23] Add wp-each processor --- .../class-wp-interactivity-api.php | 108 +++- ...lass-wp-interactivity-api-wp-each-test.php | 516 ++++++++++++++++++ 2 files changed, 622 insertions(+), 2 deletions(-) create mode 100644 phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php 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 bbf9f689aef763..20dfb39f14e361 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 @@ -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', ); /** @@ -279,7 +285,7 @@ private function process_directives_args( string $html, array &$context_stack, a : 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 ) ); } } @@ -709,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 ); + } + } + } } diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php new file mode 100644 index 00000000000000..7dd623eba5c93e --- /dev/null +++ b/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php @@ -0,0 +1,516 @@ +interactivity = new WP_Interactivity_API(); + $this->interactivity->state( 'myPlugin', array( 'list' => array( 1, 2 ) ) ); + $this->interactivity->state( 'myPlugin', array( 'after' => 'after-wp-each' ) ); + } + + /** + * Tests that the `data-wp-each` directive doesn't do anything if it's not on + * a template tag. + * + * @covers ::process_directives + */ + public function test_wp_each_doesnt_do_anything_on_non_template_tags() { + $original = ' +
+ +
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't do anything if the array + * is associative instead of indexed. + * + * @covers ::process_directives + */ + public function test_wp_each_doesnt_do_anything_on_associative_arrays() { + $this->interactivity->state( + 'myPlugin', + array( + 'assoc' => array( + 'one' => 1, + 'two' => 2, + ), + ) + ); + $original = ' + '; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with simple tags. + * + * @covers ::process_directives + */ + public function test_wp_each_simple_tags() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't do anything if the array is + * empty. + * + * @covers ::process_directives + */ + public function test_wp_each_empty_array() { + $this->interactivity->state( 'myPlugin', array( 'empty' => array() ) ); + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive merges the item with the previous + * context correctly. + * + * @covers ::process_directives + */ + public function test_wp_each_merges_context_correctly() { + $original = '' . + '
' . + '' . + '
Text
' . + '
'; + $expected = '' . + '
' . + '' . + '1' . + '2' . + '
New text
' . + '
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with arrays from the context. + * + * @covers ::process_directives + */ + public function test_wp_each_gets_arrays_from_context() { + $original = '' . + '
' . + '' . + '
Text
' . + '
'; + $expected = '' . + '
' . + '' . + '1' . + '2' . + '
Text
' . + '
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with the default namespace. + * + * @covers ::process_directives + */ + public function test_wp_each_default_namespace() { + $original = '' . + '
' . + '' . + '
Text
' . + '
'; + $expected = '' . + '
' . + '' . + '1' . + '2' . + '
Text
' . + '
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with multiple tags per item. + * + * @covers ::process_directives + */ + public function test_wp_each_multiple_tags_per_item() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + '1' . + '2' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with void tags. + * + * @covers ::process_directives + */ + public function test_wp_each_void_tags() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '' . + '' . + '' . + '' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with combinations of void and + * non-void tags. + * + * @covers ::process_directives + */ + public function test_wp_each_void_and_non_void_tags() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '' . + '1' . + '' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with nested tags. + * + * @covers ::process_directives + */ + public function test_wp_each_nested_tags() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '
' . + 'id: 1' . + '
' . + '
' . + 'id: 2' . + '
' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with nested item properties. + * + * @covers ::process_directives + */ + public function test_wp_each_nested_item_properties() { + $this->interactivity->state( + 'myPlugin', + array( + 'list' => array( + array( + 'id' => 1, + 'name' => 'one', + ), + array( + 'id' => 2, + 'name' => 'two', + ), + ), + ) + ); + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + 'one' . + '2' . + 'two' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with different item names. + * + * @covers ::process_directives + */ + public function test_wp_each_different_item_names() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive transforms kebab-case into + * camelCase. + * + * @covers ::process_directives + */ + public function test_wp_each_different_item_names_transforms_camelcase() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + '2' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't work with top-level texts. + * + * @covers ::process_directives + */ + public function test_wp_each_doesnt_work_with_top_level_text() { + $original = '' . + ''; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + + $original = '' . + ''; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with nestded template tags. + * + * @covers ::process_directives + */ + public function test_wp_each_nested_template_tags() { + $this->interactivity->state( 'myPlugin', array( 'list2' => array( 3, 4 ) ) ); + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '1' . + '' . + '3' . + '4' . + '2' . + '' . + '3' . + '4' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive works with nestded template tags + * that use a previous item as a list. + * + * @covers ::process_directives + */ + public function test_wp_each_nested_template_tags_using_previous_item_as_list() { + $this->interactivity->state( 'myPlugin', array( 'list2' => array( array( 1, 2 ), array( 3, 4 ) ) ) ); + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '' . + '1' . + '2' . + '' . + '3' . + '4' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't process unbalanced tags. + * + * @covers ::process_directives + */ + public function test_wp_each_unbalanced_tags() { + $original = '' . + '' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + } + + /** + * Tests that the `data-wp-each` directive doesn't process unbalanced tags in + * nested templates. + * + * @covers ::process_directives + */ + public function test_wp_each_unbalanced_tags_in_nested_template_tags() { + $this->interactivity->state( 'myPlugin', array( 'list2' => array( 3, 4 ) ) ); + $original = '' . + '' . + '
Text
'; + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $original, $new ); + } +} From 29f10fa39ae67d3f2c3307dfc56198c141f400e9 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 31 Jan 2024 15:42:52 +0100 Subject: [PATCH 09/23] Make sure it doesn't process non-array values --- .../class-wp-interactivity-api.php | 20 +++++++--- ...lass-wp-interactivity-api-wp-each-test.php | 39 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) 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 20dfb39f14e361..1687614ed663bc 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 @@ -740,15 +740,23 @@ private function data_wp_each_processor( WP_Interactivity_API_Directives_Process $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 associative arrays because those will be + * deserialized as objects in JS. + * + * It doesn't process empty or non-array values. * * 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. + * 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, '>' ) ) { + if ( + empty( $result ) || + ! is_array( $result ) || + ! array_is_list( $result ) || + ! str_starts_with( $inner_content, '<' ) || + ! str_ends_with( $inner_content, '>' ) + ) { return; } diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php index 7dd623eba5c93e..5e6546eaae4506 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php @@ -513,4 +513,43 @@ public function test_wp_each_unbalanced_tags_in_nested_template_tags() { $new = $this->interactivity->process_directives( $original ); $this->assertEquals( $original, $new ); } + + /** + * Tests that the `data-wp-each` directive doesn't process if it doesn't get + * an array. + * + * @covers ::process_directives + */ + public function test_wp_each_doesnt_process_if_not_array() { + $original = '' . + '' . + '
Text
'; + $expected = '' . + '' . + '
Text
'; + + $this->interactivity->state( 'myPlugin', array( 'list' => null ) ); + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + + $this->interactivity->state( 'myPlugin', array( 'list' => 'Text' ) ); + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + + $this->interactivity->state( 'myPlugin', array( 'list' => 100 ) ); + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + + $this->interactivity->state( 'myPlugin', array( 'list' => false ) ); + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + + $this->interactivity->state( 'myPlugin', array( 'list' => true ) ); + $new = $this->interactivity->process_directives( $original ); + $this->assertEquals( $expected, $new ); + } } From 3b67d479b5e85dc493f6b8d25335ad89f2b54205 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 31 Jan 2024 16:37:00 +0100 Subject: [PATCH 10/23] Trim before checking that starts and ends with tags --- .../class-wp-interactivity-api.php | 4 ++-- .../class-wp-interactivity-api-wp-each-test.php | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) 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 1687614ed663bc..c9b9455e453143 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 @@ -754,8 +754,8 @@ private function data_wp_each_processor( WP_Interactivity_API_Directives_Process empty( $result ) || ! is_array( $result ) || ! array_is_list( $result ) || - ! str_starts_with( $inner_content, '<' ) || - ! str_ends_with( $inner_content, '>' ) + ! str_starts_with( trim( $inner_content ), '<' ) || + ! str_ends_with( trim( $inner_content ), '>' ) ) { return; } diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php index 5e6546eaae4506..82334262a52228 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php @@ -402,6 +402,19 @@ public function test_wp_each_doesnt_work_with_top_level_text() { ''; $new = $this->interactivity->process_directives( $original ); $this->assertEquals( $original, $new ); + + // But it should work fine with spaces and linebreaks. + $original = ' + '; + $new = $this->interactivity->process_directives( $original ); + $p = new WP_HTML_Tag_Processor( $new ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( '1', $p->get_attribute( 'id') ); + $p->next_tag( array( 'class_name' => 'test' ) ); + $this->assertEquals( '2', $p->get_attribute( 'id') ); } /** From 72a2f19de85622bfa4274aa5074776855c3b38c7 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 31 Jan 2024 16:40:07 +0100 Subject: [PATCH 11/23] Fix typo and some extra tabs --- ...activity-api-directives-processor-test.php | 80 +++++++++---------- ...lass-wp-interactivity-api-wp-each-test.php | 2 +- 2 files changed, 41 insertions(+), 41 deletions(-) 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 index 8e237b9724ec25..ad6fb06741c4ba 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php @@ -635,12 +635,12 @@ public function test_append_content_after_closing_tag_on_balanced_or_void_tags_w * @covers ::next_balanced_tag_closer_tag */ public function test_next_balanced_tag_closer_tag_standard_tags() { - $content = '
Text
'; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertTrue( $p->next_balanced_tag_closer_tag() ); - $this->assertEquals( 'DIV', $p->get_tag() ); - $this->assertTrue( $p->is_tag_closer() ); + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertTrue( $p->next_balanced_tag_closer_tag() ); + $this->assertEquals( 'DIV', $p->get_tag() ); + $this->assertTrue( $p->is_tag_closer() ); } /** @@ -650,15 +650,15 @@ public function test_next_balanced_tag_closer_tag_standard_tags() { * @covers ::next_balanced_tag_closer_tag */ public function test_next_balanced_tag_closer_tag_void_tag() { - $content = ''; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertFalse( $p->next_balanced_tag_closer_tag() ); + $content = ''; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertFalse( $p->next_balanced_tag_closer_tag() ); - $content = '
Text
'; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertFalse( $p->next_balanced_tag_closer_tag() ); + $content = '
Text
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertFalse( $p->next_balanced_tag_closer_tag() ); } /** @@ -668,20 +668,20 @@ public function test_next_balanced_tag_closer_tag_void_tag() { * @covers ::next_balanced_tag_closer_tag */ public function test_next_balanced_tag_closer_tag_nested_tags() { - $content = '
Nested content
'; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertTrue( $p->next_balanced_tag_closer_tag() ); - $this->assertEquals( 'DIV', $p->get_tag() ); - $this->assertTrue( $p->is_tag_closer() ); + $content = '
Nested content
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertTrue( $p->next_balanced_tag_closer_tag() ); + $this->assertEquals( 'DIV', $p->get_tag() ); + $this->assertTrue( $p->is_tag_closer() ); - $content = '
Nested content
'; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertTrue( $p->next_balanced_tag_closer_tag() ); - $this->assertEquals( 'DIV', $p->get_tag() ); - $this->assertTrue( $p->is_tag_closer() ); - $this->assertFalse( $p->next_tag() ); // No more content. + $content = '
Nested content
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertTrue( $p->next_balanced_tag_closer_tag() ); + $this->assertEquals( 'DIV', $p->get_tag() ); + $this->assertTrue( $p->is_tag_closer() ); + $this->assertFalse( $p->next_tag() ); // No more content. } /** @@ -691,14 +691,14 @@ public function test_next_balanced_tag_closer_tag_nested_tags() { * @covers ::next_balanced_tag_closer_tag */ public function test_next_balanced_tag_closer_tag_no_matching_closing_tag() { - $content = '
No closing tag here'; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); + $content = '
No closing tag here'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); - $content = '
No closing tag here
'; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - $p->next_tag(); - $this->assertFalse( $p->next_balanced_tag_closer_tag() ); + $content = '
No closing tag here
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + $p->next_tag(); + $this->assertFalse( $p->next_balanced_tag_closer_tag() ); $this->assertFalse( $p->next_balanced_tag_closer_tag() ); } @@ -709,11 +709,11 @@ public function test_next_balanced_tag_closer_tag_no_matching_closing_tag() { * @covers ::next_balanced_tag_closer_tag */ public function test_next_balanced_tag_closer_tag_on_closing_tag() { - $content = '
Closing tag after this
'; - $p = new WP_Interactivity_API_Directives_Processor( $content ); - // Visit opening tag first and then closing tag. - $p->next_tag(); - $p->next_tag( array( 'tag_closers' => 'visit' ) ); - $this->assertFalse( $p->next_balanced_tag_closer_tag() ); + $content = '
Closing tag after this
'; + $p = new WP_Interactivity_API_Directives_Processor( $content ); + // Visit opening tag first and then closing tag. + $p->next_tag(); + $p->next_tag( array( 'tag_closers' => 'visit' ) ); + $this->assertFalse( $p->next_balanced_tag_closer_tag() ); } } diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php index 82334262a52228..c36e42a325853e 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php @@ -418,7 +418,7 @@ public function test_wp_each_doesnt_work_with_top_level_text() { } /** - * Tests that the `data-wp-each` directive works with nestded template tags. + * Tests that the `data-wp-each` directive works with nested template tags. * * @covers ::process_directives */ From 5f3082c382fd90a4862dd932396c220e2aa292ac Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 31 Jan 2024 16:45:47 +0100 Subject: [PATCH 12/23] Fix PHPCS --- ...class-wp-interactivity-api-directives-processor-test.php | 1 - .../class-wp-interactivity-api-wp-each-test.php | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) 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 index ad6fb06741c4ba..c83eeb6c9bdaaf 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-directives-processor-test.php @@ -699,7 +699,6 @@ public function test_next_balanced_tag_closer_tag_no_matching_closing_tag() { $p = new WP_Interactivity_API_Directives_Processor( $content ); $p->next_tag(); $this->assertFalse( $p->next_balanced_tag_closer_tag() ); - $this->assertFalse( $p->next_balanced_tag_closer_tag() ); } /** diff --git a/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php b/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php index c36e42a325853e..9eac945a962e1c 100644 --- a/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php +++ b/phpunit/interactivity-api/class-wp-interactivity-api-wp-each-test.php @@ -409,12 +409,12 @@ public function test_wp_each_doesnt_work_with_top_level_text() { '; $new = $this->interactivity->process_directives( $original ); - $p = new WP_HTML_Tag_Processor( $new ); + $p = new WP_HTML_Tag_Processor( $new ); $p->next_tag( array( 'class_name' => 'test' ) ); $p->next_tag( array( 'class_name' => 'test' ) ); - $this->assertEquals( '1', $p->get_attribute( 'id') ); + $this->assertEquals( '1', $p->get_attribute( 'id' ) ); $p->next_tag( array( 'class_name' => 'test' ) ); - $this->assertEquals( '2', $p->get_attribute( 'id') ); + $this->assertEquals( '2', $p->get_attribute( 'id' ) ); } /** From a20240daba4c8c84fe2d984d1c148f1d821db13a Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Wed, 31 Jan 2024 19:20:28 +0100 Subject: [PATCH 13/23] Add kebal to camel case conversion in the JS runtime --- .../directive-each/render.php | 12 +++++++++ packages/interactivity/src/directives.js | 4 ++- .../src/utils/kebab-to-camelcase.js | 14 ++++++++++ .../interactivity/src/utils/test/utils.js | 26 +++++++++++++++++++ .../class-wp-interactivity-api-test.php | 2 +- .../interactivity/directive-each.spec.ts | 9 +++++++ 6 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 packages/interactivity/src/utils/kebab-to-camelcase.js create mode 100644 packages/interactivity/src/utils/test/utils.js diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php index 3d018bca46d060..03a2b89cc1233d 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php @@ -21,6 +21,18 @@
+
+ + +

A

+

B

+

C

+
+ +
+