diff --git a/phpunit/directives/wp-directive-processor.php b/phpunit/directives/wp-directive-processor.php
new file mode 100644
index 00000000..8ca135de
--- /dev/null
+++ b/phpunit/directives/wp-directive-processor.php
@@ -0,0 +1,117 @@
+outside![]()
inside
';
+
+ 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() {
+ $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..324df9f5
--- /dev/null
+++ b/src/directives/class-wp-directive-processor.php
@@ -0,0 +1,137 @@
+might_have_directives = false;
+ }
+ }
+
+ public function next_directive() {
+ if ( false === $this->might_have_directives ) {
+ return false;
+ }
+
+ while ( $this->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
+ $tag_name = $this->get_tag();
+ if ( 0 === stripos( self::DIRECTIVE_PREFIX, $tag_name ) ) {
+ return true;
+ }
+
+ $attribute_directives = $this->get_attribute_names_with_prefix( self::DIRECTIVE_PREFIX );
+ if ( 0 < count( $attribute_directives ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function next_balanced_closer() {
+ $depth = 0;
+
+ $tag_name = $this->get_tag();
+ 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;
+ }
+
+ public function get_inner_html() {
+ $this->set_bookmark( 'start' );
+ if ( ! $this->next_balanced_closer() ) {
+ $this->release_bookmark( 'start' );
+ return false;
+ }
+ $this->set_bookmark( 'end' );
+
+ $start = $this->bookmarks['start']->end + 1;
+ $end = $this->bookmarks['end']->start;
+
+ $this->release_bookmark( 'start' );
+ $this->release_bookmark( 'end' );
+
+ return substr( $this->html, $start, $end - $start );
+ }
+
+ public function set_inner_html( $new_html ) {
+ $this->set_bookmark( 'start' );
+ if ( ! $this->next_balanced_closer() ) {
+ $this->release_bookmark( 'start' );
+ return false;
+ }
+ $this->set_bookmark( 'end' );
+
+ $start = $this->bookmarks['start']->end + 1;
+ $end = $this->bookmarks['end']->start;
+
+ $this->release_bookmark( 'start' );
+ $this->release_bookmark( 'end' );
+
+ $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end, $new_html );
+ return true;
+ }
+
+ public function get_outer_html( $new_html ) {
+ $this->set_bookmark( 'start' );
+ if ( ! $this->next_balanced_closer() ) {
+ $this->release_bookmark( 'start' );
+ return false;
+ }
+ $this->set_bookmark( 'end' );
+
+ $start = $this->bookmarks['start']->start;
+ $end = $this->bookmarks['end']->end + 1;
+
+ $this->release_bookmark( 'start' );
+ $this->release_bookmark( 'end' );
+
+ // For consistency with set_outer_html:
+ $this->next_tag();
+ return substr( $this->html, $start, $end - $start );
+ }
+
+ public function set_outer_html( $new_html ) {
+ $this->set_bookmark( 'start' );
+ if ( ! $this->next_balanced_closer() ) {
+ $this->release_bookmark( 'start' );
+ return false;
+ }
+ $this->set_bookmark( 'end' );
+
+ $start = $this->bookmarks['start']->start;
+ $end = $this->bookmarks['end']->end + 1;
+
+ $this->release_bookmark( 'start' );
+ $this->release_bookmark( 'end' );
+
+ $this->next_tag();
+ $this->set_bookmark( 'next' );
+ $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end, $new_html );
+ // updates before the current position are not supported well and we end
+ // up at an invalid combination of copied bytes and parsed bytes index.
+ // bookmarks are updated correctly, though, so seek() makes it right again.
+ $this->seek('next');
+ $this->release_bookmark( 'next' );
+ return true;
+ }
+
+}