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;
- }
-}