diff --git a/phpunit/directives/wp-directive-processor.php b/phpunit/directives/wp-directive-processor.php new file mode 100644 index 00000000..e24e247c --- /dev/null +++ b/phpunit/directives/wp-directive-processor.php @@ -0,0 +1,128 @@ +outside
inside
'; + + 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() ); + } + + 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/src/directives/class-wp-directive-processor.php b/src/directives/class-wp-directive-processor.php new file mode 100644 index 00000000..d9a18cf0 --- /dev/null +++ b/src/directives/class-wp-directive-processor.php @@ -0,0 +1,155 @@ +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; + } + + /** + * Return 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 ]->end + 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 ); + } + + /** + * Set 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 ]->end + 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 WP_HTML_Text_Replacement( $start, $end, $new_html ); + return true; + } + + /** + * Return 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 ); + } + + /** + * Whether a given HTML element is void (e.g.
). + * + * @param string $tag_name The element in question. + * @return bool True if the element is void. + * + * @see https://html.spec.whatwg.org/#elements-2 + */ + 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; + } + } +} diff --git a/src/directives/wp-process-directives.php b/src/directives/wp-process-directives.php index c465d6ba..21ca430a 100644 --- a/src/directives/wp-process-directives.php +++ b/src/directives/wp-process-directives.php @@ -1,6 +1,7 @@ get_tag() ) && + ! WP_Directive_Processor::is_html_void_element( $tags->get_tag() ) && ( 0 !== count( $attributes ) || 0 !== count( $tag_stack ) ) ) { $tag_stack[] = array( $tag_name, $attributes ); @@ -56,26 +57,3 @@ function wp_process_directives( $tags, $prefix, $directives ) { return $tags; } -// TODO: Move into `WP_HTML_Tag_Processor` (or `WP_HTML_Processor`). -// See e.g. https://github.com/WordPress/gutenberg/pull/47573. -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; - } -}