diff --git a/lib/experimental/html/class-wp-html-tag-processor.php b/lib/experimental/html/class-wp-html-tag-processor.php
index 5dc5981ef15f78..49a81371fc006c 100644
--- a/lib/experimental/html/class-wp-html-tag-processor.php
+++ b/lib/experimental/html/class-wp-html-tag-processor.php
@@ -180,6 +180,7 @@
* @since 6.2.0
*/
class WP_HTML_Tag_Processor {
+ const MAX_BOOKMARKS = 10;
/**
* The HTML document to parse.
@@ -362,6 +363,15 @@ class WP_HTML_Tag_Processor {
*/
private $classname_updates = array();
+ /**
+ * Tracks a semantic location in the original HTML that shuffles
+ * with the updates applied to the document.
+ *
+ * @since 6.2.0
+ * @var array
+ */
+ private $bookmarks = array();
+
const ADD_CLASS = true;
const REMOVE_CLASS = false;
const SKIP_CLASS = null;
@@ -479,6 +489,47 @@ public function next_tag( $query = null ) {
return true;
}
+
+ /**
+ * Sets a bookmark in the HTML document.
+ *
+ * @param string $name Identifies this particular bookmark.
+ * @return false|void
+ */
+ public function set_bookmark( $name ) {
+ if ( null === $this->tag_name_starts_at ) {
+ return false;
+ }
+
+ if ( ! array_key_exists( $name, $this->bookmarks ) && count( $this->bookmarks ) > self::MAX_BOOKMARKS ) {
+ return false;
+ }
+
+ $this->bookmarks[ $name ] = new WP_HTML_Text_Replacement(
+ $this->tag_name_starts_at - 1,
+ $this->tag_ends_at,
+ ''
+ );
+ }
+
+
+ /**
+ * Removes a bookmark once it's not necessary anymore.
+ *
+ * @param string $name Name of the bookmark to remove.
+ * @return bool
+ */
+ public function release_bookmark( $name ) {
+
+ if ( ! array_key_exists( $name, $this->bookmarks ) ) {
+ return false;
+ }
+
+ unset( $this->bookmarks[ $name ] );
+ return true;
+ }
+
+
/**
* Skips the contents of the title and textarea tags until an appropriate
* tag closer is found.
@@ -1102,11 +1153,115 @@ private function apply_attributes_updates() {
$this->updated_html .= substr( $this->html, $this->updated_bytes, $diff->start - $this->updated_bytes );
$this->updated_html .= $diff->text;
$this->updated_bytes = $diff->end;
+
+ foreach ( $this->bookmarks as $name => &$position ) {
+ $update_head = $position->start >= $diff->start;
+ $update_tail = $position->end >= $diff->start;
+
+ if ( ! $update_head && ! $update_tail ) {
+ continue;
+ }
+
+ /*
+ * If a change is made that encompasses an entire bookmark then we
+ * have to remove the bookmark as the semantic place it pointed to
+ * no longer exists. It could seem like we can let it remain as
+ * long as we haven't removed the text, but if the text is different
+ * then the place likely doesn't exist at all either and we need to
+ * start over.
+ */
+ if ( $diff->start <= $position->start && $diff->end >= $position->end ) {
+ unset( $this->bookmarks[ $name ] );
+ continue;
+ }
+
+ $delta = strlen( $diff->text ) - ( $diff->end - $diff->start );
+
+ if ( $update_head ) {
+ $position->start += $delta;
+ }
+
+ if ( $update_tail ) {
+ $position->end += $delta;
+ }
+ }
}
$this->attribute_updates = array();
}
+ /**
+ * Move the current pointer in the Tag Processor to a given bookmark's location.
+ *
+ * @param string $bookmark_name Name of bookmark to which to rewind.
+ * @return bool
+ * @throws Exception Throws on invalid bookmark name if WP_DEBUG set.
+ */
+ public function seek( $bookmark_name ) {
+ if ( ! array_key_exists( $bookmark_name, $this->bookmarks ) ) {
+ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
+ throw new Exception( 'Invalid bookmark name' );
+ }
+ return false;
+ }
+
+ // Apply all the updates.
+ $this->apply_string_diffs();
+
+ $start = $this->bookmarks[ $bookmark_name ]->start;
+ $this->parsed_bytes = $start;
+ $this->updated_bytes = $start;
+ $this->updated_html = substr( $this->html, 0, $this->parsed_bytes );
+ return $this->next_tag();
+ }
+
+ public function dangerously_get_contents( $start_bookmark, $end_bookmark, $region = 'outer' ) {
+ if (
+ empty( $start_bookmark ) ||
+ empty( $end_bookmark ) ||
+ ! isset( $this->bookmarks[ $start_bookmark ], $this->bookmarks[ $end_bookmark ] ) ||
+ ( $start_bookmark === $end_bookmark && $region !== 'outer' )
+ ) {
+ return false;
+ }
+
+ $start = $this->bookmarks[ $start_bookmark ];
+ $end = $this->bookmarks[ $end_bookmark ];
+
+ if ( $start->start > $end->start || $start->end > $end->end ) {
+ return false;
+ }
+
+ $start = 'outer' === $region ? $start->start : $start->end + 1;
+ $end = 'outer' === $region ? $end->end + 1 : $end->start - 1;
+
+ return substr( $this->html, $start, $end - $start );
+ }
+
+ public function dangerously_replace( $start_bookmark, $end_bookmark, $text, $region = 'outer' ) {
+ if (
+ empty( $start_bookmark ) ||
+ empty( $end_bookmark ) ||
+ ! isset( $this->bookmarks[ $start_bookmark ], $this->bookmarks[ $end_bookmark ] ) ||
+ ( $start_bookmark === $end_bookmark && $region !== 'outer' )
+ ) {
+ return false;
+ }
+
+ $start = $this->bookmarks[ $start_bookmark ];
+ $end = $this->bookmarks[ $end_bookmark ];
+
+ if ( $start->start > $end->start || $start->end > $end->end ) {
+ return false;
+ }
+
+ $start = 'outer' === $region ? $start->start : $start->end + 1;
+ $end = 'outer' === $region ? $end->end + 1 : $end->start - 1;
+
+ $this->attribute_updates[] = new WP_HTML_Text_Replacement( $start, $end, $text );
+ $this->apply_attributes_updates();
+ }
+
/**
* Sort function to arrange objects with a start property in ascending order.
*
@@ -1416,6 +1571,15 @@ public function get_updated_html() {
return $this->updated_html . substr( $this->html, $this->updated_bytes );
}
+ return $this->apply_string_diffs();
+ }
+
+ /**
+ * I just ripped out the part I need to call in the rewind().
+ *
+ * @TODO separate it more cleanly.
+ */
+ private function apply_string_diffs() {
/*
* Parsing is in progress – let's apply the attribute updates without moving on to the next tag.
*
diff --git a/phpunit/html/wp-html-tag-processor-bookmark-test.php b/phpunit/html/wp-html-tag-processor-bookmark-test.php
new file mode 100644
index 00000000000000..b7e05a0a212f91
--- /dev/null
+++ b/phpunit/html/wp-html-tag-processor-bookmark-test.php
@@ -0,0 +1,270 @@
+
OneTwoThree' );
+ $p->next_tag( 'li' );
+ $p->set_bookmark( 'first li' );
+ $p->next_tag( 'li' );
+ $p->set_bookmark( 'second li' );
+ $p->set_attribute( 'foo-2', 'bar-2' );
+ $p->seek( 'first li' );
+ $p->set_attribute( 'foo-1', 'bar-1' );
+ $p->seek( 'second li' );
+ $p->next_tag( 'li' );
+ $p->set_attribute( 'foo-3', 'bar-3' );
+ $this->assertEquals(
+ '',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_updates_bookmark_for_additions_after_both_sides() {
+ $p = new WP_HTML_Tag_Processor( 'First
Second
' );
+ $p->next_tag();
+ $p->set_bookmark( 'first' );
+ $p->next_tag();
+ $p->add_class( 'second' );
+
+ $p->seek( 'first' );
+ $p->add_class( 'first' );
+
+ $this->assertEquals(
+ 'First
Second
',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_updates_bookmark_for_additions_before_both_sides() {
+ $p = new WP_HTML_Tag_Processor( 'First
Second
' );
+ $p->next_tag();
+ $p->set_bookmark( 'first' );
+ $p->next_tag();
+ $p->set_bookmark( 'second' );
+
+ $p->seek( 'first' );
+ $p->add_class( 'first' );
+
+ $p->seek( 'second' );
+ $p->add_class( 'second' );
+
+ $this->assertEquals(
+ 'First
Second
',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_updates_bookmark_for_deletions_after_both_sides() {
+ $p = new WP_HTML_Tag_Processor( 'First
Second
' );
+ $p->next_tag();
+ $p->set_bookmark( 'first' );
+ $p->next_tag();
+ $p->remove_attribute( 'disabled' );
+
+ $p->seek( 'first' );
+ $p->set_attribute( 'untouched', true );
+
+ $this->assertEquals(
+ /** @TODO: we shouldn't have to assert the extra space after removing the attribute. */
+ 'First
Second
',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_updates_bookmark_for_deletions_before_both_sides() {
+ $p = new WP_HTML_Tag_Processor( 'First
Second
' );
+ $p->next_tag();
+ $p->set_bookmark( 'first' );
+ $p->next_tag();
+ $p->set_bookmark( 'second' );
+
+ $p->seek( 'first' );
+ $p->remove_attribute( 'disabled' );
+
+ $p->seek( 'second' );
+ $p->set_attribute( 'safe', true );
+
+ $this->assertEquals(
+ /** @TODO: we shouldn't have to assert the extra space after removing the attribute. */
+ 'First
Second
',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_replaces_inner_contents() {
+ $p = new WP_HTML_Tag_Processor( '' );
+ $p->next_tag( [ 'class_name' => 'inner' ] );
+ $p->set_bookmark( 'start' );
+ $p->next_tag( [ 'tag_name' => 'div', 'tag_closers' => 'visit' ] );
+ $p->set_bookmark( 'end' );
+ $p->dangerously_replace( 'start', 'end', '--', 'inner' );
+
+ $this->assertEquals(
+ '',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_replaces_outside_contents() {
+ $p = new WP_HTML_Tag_Processor( '' );
+ $p->next_tag( [ 'class_name' => 'inner' ] );
+ $p->set_bookmark( 'start' );
+ $p->next_tag( [ 'tag_name' => 'div', 'tag_closers' => 'visit' ] );
+ $p->set_bookmark( 'end' );
+ $p->dangerously_replace( 'start', 'end', '--' );
+
+ $this->assertEquals(
+ 'Before--After
',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_replaces_single_token() {
+ $p = new WP_HTML_Tag_Processor( 'This is an
tag.' );
+ $p->next_tag();
+ $p->set_bookmark( 'image' );
+ $p->dangerously_replace( 'image', 'image', '(image)' );
+
+ $this->assertEquals(
+ 'This is an (image) tag.',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_does_nothing_when_replacing_inner_of_single_token() {
+ $p = new WP_HTML_Tag_Processor( 'This is an
tag.' );
+ $p->next_tag();
+ $p->set_bookmark( 'image' );
+ $p->dangerously_replace( 'image', 'image', '(image)', 'inner' );
+
+ $this->assertEquals(
+ 'This is an
tag.',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_does_nothing_when_given_twisted_bookmarks() {
+ $p = new WP_HTML_Tag_Processor( '
' );
+ $p->next_tag();
+ $p->set_bookmark( 'first' );
+ $p->next_tag();
+ $p->set_bookmark( 'second' );
+ $p->dangerously_replace( 'second', 'first', '--' );
+
+ $this->assertEquals(
+ '
',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_bookmarks_deactive_when_bookmarked_token_disappears() {
+ $p = new WP_HTML_Tag_Processor( '' );
+ $p->next_tag();
+ $p->set_bookmark( 'first' );
+ $p->next_tag();
+ $p->set_bookmark( 'inner_start' );
+ $p->next_tag( [ 'tag_closers' => 'visit' ] );
+ $p->set_bookmark( 'inner_end' );
+ $p->dangerously_replace( 'first', 'inner_end', '' );
+
+ $this->expectException( Exception::class );
+ $p->seek( 'inner_start' );
+
+ $p->set_attribute( 'wonky', true );
+
+ $this->assertEquals(
+ 'After',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_gets_inner_content() {
+ $p = new WP_HTML_Tag_Processor( '' );
+ $p->next_tag();
+ $p->set_bookmark( 'start' );
+ $p->next_tag( [ 'tag_closers' => 'visit', 'match_offset' => 3 ] );
+ $p->set_bookmark( 'end' );
+
+ $this->assertEquals(
+ 'BeforeInside
After',
+ $p->dangerously_get_contents( 'start', 'end', 'inner' )
+ );
+ }
+
+ public function test_gets_outer_content() {
+ $p = new WP_HTML_Tag_Processor( '' );
+ $p->next_tag( [ 'class_name' => 'start' ] );
+ $p->set_bookmark( 'start' );
+ $p->next_tag( [ 'tag_closers' => 'visit' ] );
+ $p->set_bookmark( 'end' );
+
+ $this->assertEquals(
+ 'Inside
',
+ $p->dangerously_get_contents( 'start', 'end' )
+ );
+ }
+
+ public function test_can_replace_parent_with_children() {
+ $p = new WP_HTML_Tag_Processor( 'Unwrapping HTML
Blah blah
![]()
' );
+ $p->next_tag( [ 'class_name' => 'wrapper' ] );
+ $p->set_bookmark( 'start' );
+ $p->next_tag( [ 'tag_name' => 'div', 'tag_closers' => 'visit' ] );
+ $p->set_bookmark( 'end' );
+
+ $inner_html = $p->dangerously_get_contents( 'start', 'end', 'inner' );
+ $p->dangerously_replace( 'start', 'end', $inner_html, 'outer' );
+
+ $this->assertEquals(
+ 'Unwrapping HTML
Blah blah
![]()
',
+ $p->get_updated_html()
+ );
+ }
+
+ public function test_can_write_dangerous_functions_to_replace_inner_html() {
+ $replace_inner_html = function ( WP_HTML_Tag_Processor $p, $html ) {
+ $tag = $p->get_tag();
+ $p->set_bookmark( '__start_of_node' );
+
+ $depth = 1;
+ while ( $depth > 0 && $p->next_tag( [ 'tag_name' => $tag, 'tag_closers' => 'visit' ] ) ) {
+ $depth += $p->is_tag_closer() ? -1 : 1;
+
+ if ( $depth === 0 ) {
+ $p->set_bookmark( '__end_of_node' );
+ break;
+ }
+ }
+
+ $p->dangerously_replace( '__start_of_node', '__end_of_node', $html, 'inner' );
+ $p->release_bookmark( '__start_of_node' );
+ $p->release_bookmark( '__end_of_node' );
+ };
+
+ $p = new WP_HTML_Tag_Processor( '' );
+ $p->next_tag( [ 'class_name' => 'wrapper' ] );
+
+ $replace_inner_html( $p, 'Weee!' );
+ $this->assertEquals(
+ 'Unwrapping HTML
Weee!
untouched
',
+ $p->get_updated_html()
+ );
+ }
+}