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 @@ +
  • One
  • Two
  • Three
  • ' ); + $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( '
    Before
    Inside
    After
    ' ); + $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( + '
    Before
    --
    After
    ', + $p->get_updated_html() + ); + } + + public function test_replaces_outside_contents() { + $p = new WP_HTML_Tag_Processor( '
    Before
    Inside
    After
    ' ); + $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( '
    Before
    Inside
    After
    ' ); + $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( '
    Before
    Inside
    After
    ' ); + $p->next_tag(); + $p->set_bookmark( 'start' ); + $p->next_tag( [ 'tag_closers' => 'visit', 'match_offset' => 3 ] ); + $p->set_bookmark( 'end' ); + + $this->assertEquals( + 'Before
    Inside
    After', + $p->dangerously_get_contents( 'start', 'end', 'inner' ) + ); + } + + public function test_gets_outer_content() { + $p = new WP_HTML_Tag_Processor( '
    Before
    Inside
    After
    ' ); + $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( '

    Unwrapping HTML

    Blah blah

    untouched
    ' ); + $p->next_tag( [ 'class_name' => 'wrapper' ] ); + + $replace_inner_html( $p, 'Weee!' ); + $this->assertEquals( + '

    Unwrapping HTML

    Weee!
    untouched
    ', + $p->get_updated_html() + ); + } +}