diff --git a/src/wp-includes/html-api/class-wp-html-open-elements.php b/src/wp-includes/html-api/class-wp-html-open-elements.php index cb913853f0ee9..210492ab9af08 100644 --- a/src/wp-includes/html-api/class-wp-html-open-elements.php +++ b/src/wp-includes/html-api/class-wp-html-open-elements.php @@ -520,11 +520,6 @@ public function pop(): bool { return false; } - if ( 'context-node' === $item->bookmark_name ) { - $this->stack[] = $item; - return false; - } - $this->after_element_pop( $item ); return true; } @@ -585,10 +580,6 @@ public function push( WP_HTML_Token $stack_item ): void { * @return bool Whether the node was found and removed from the stack of open elements. */ public function remove_node( WP_HTML_Token $token ): bool { - if ( 'context-node' === $token->bookmark_name ) { - return false; - } - foreach ( $this->walk_up() as $position_from_end => $item ) { if ( $token->bookmark_name !== $item->bookmark_name ) { continue; diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 9efcdd90aa15c..ce6500381a645 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -5409,11 +5409,14 @@ public function seek( $bookmark_name ): bool { */ if ( 'backward' === $direction ) { /* - * Instead of clearing the parser state and starting fresh, calling the stack methods - * maintains the proper flags in the parser. + * When moving backward, stateful stacks should be cleared. */ foreach ( $this->state->stack_of_open_elements->walk_up() as $item ) { - if ( 'context-node' === $item->bookmark_name ) { + /* + * Fragment parsers always start with an HTML root node at the top of the stack. + * Do not remove it. + */ + if ( 'root-node' === $item->bookmark_name ) { break; } @@ -5421,39 +5424,68 @@ public function seek( $bookmark_name ): bool { } foreach ( $this->state->active_formatting_elements->walk_up() as $item ) { - if ( 'context-node' === $item->bookmark_name ) { - break; - } - $this->state->active_formatting_elements->remove_node( $item ); } - parent::seek( 'context-node' ); - $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; - $this->state->frameset_ok = true; - $this->element_queue = array(); - $this->current_element = null; + /* + * **After** clearing stacks, more processor state can be reset. + * This must be done after clearing the stack because those stacks generate events that + * would appear on a subsequent call to `next_token()`. + */ + $this->state->frameset_ok = true; + $this->state->stack_of_template_insertion_modes = array(); + $this->state->head_element = null; + $this->state->form_element = null; + $this->state->current_token = null; + $this->current_element = null; + $this->element_queue = array(); + + /* + * The absence of a context node indicates a full parse. + * The presence of a context node indicates a fragment parser. + */ + if ( null === $this->context_node ) { + $this->change_parsing_namespace( 'html' ); + $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_INITIAL; + $this->breadcrumbs = array(); - if ( isset( $this->context_node ) ) { - $this->breadcrumbs = array_slice( $this->breadcrumbs, 0, 2 ); + $this->bookmarks['initial'] = new WP_HTML_Span( 0, 0 ); + parent::seek( 'initial' ); + unset( $this->bookmarks['initial'] ); } else { - $this->breadcrumbs = array(); - } - } + $this->change_parsing_namespace( + $this->context_node->integration_node_type + ? 'html' + : $this->context_node->namespace + ); + + if ( 'TEMPLATE' === $this->context_node->node_name ) { + $this->state->stack_of_template_insertion_modes[] = WP_HTML_Processor_State::INSERTION_MODE_IN_TEMPLATE; + } - // When moving forwards, reparse the document until reaching the same location as the original bookmark. - if ( $bookmark_starts_at === $this->bookmarks[ $this->state->current_token->bookmark_name ]->start ) { - return true; + $this->reset_insertion_mode_appropriately(); + $this->breadcrumbs = array_slice( $this->breadcrumbs, 0, 2 ); + parent::seek( $this->context_node->bookmark_name ); + } } - while ( $this->next_token() ) { + /* + * Here, the processor moves forward through the document until it matches the bookmark. + * do-while is used here because the processor is expected to already be stopped on + * a token than may match the bookmarked location. + */ + do { + /* + * The processor will stop on virtual tokens, but bookmarks may not be set on them. + * They should not be matched when seeking a bookmark, skip them. + */ + if ( $this->is_virtual() ) { + continue; + } if ( $bookmark_starts_at === $this->bookmarks[ $this->state->current_token->bookmark_name ]->start ) { - while ( isset( $this->current_element ) && WP_HTML_Stack_Event::POP === $this->current_element->operation ) { - $this->current_element = array_shift( $this->element_queue ); - } return true; } - } + } while ( $this->next_token() ); return false; } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php new file mode 100644 index 0000000000000..eafb03c0cbaec --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php @@ -0,0 +1,143 @@ +' ); + $this->assertTrue( $processor->next_tag( 'DIV' ) ); + $this->assertTrue( $processor->set_bookmark( 'mark' ), 'Failed to set bookmark.' ); + $this->assertTrue( $processor->has_bookmark( 'mark' ), 'Failed has_bookmark check.' ); + + // Confirm the bookmark works and processing continues normally. + $this->assertTrue( $processor->seek( 'mark' ), 'Failed to seek to bookmark.' ); + $this->assertSame( 'DIV', $processor->get_tag() ); + $this->assertSame( array( 'HTML', 'BODY', 'DIV' ), $processor->get_breadcrumbs() ); + $this->assertTrue( $processor->next_tag() ); + $this->assertSame( 'SPAN', $processor->get_tag() ); + $this->assertSame( array( 'HTML', 'BODY', 'DIV', 'SPAN' ), $processor->get_breadcrumbs() ); + } + + /** + * @dataProvider data_processor_constructors + * + * @ticket 62290 + */ + public function test_processor_seek_backward( callable $factory ) { + $processor = $factory( '
' ); + $this->assertTrue( $processor->next_tag( 'DIV' ) ); + $this->assertTrue( $processor->set_bookmark( 'mark' ), 'Failed to set bookmark.' ); + $this->assertTrue( $processor->has_bookmark( 'mark' ), 'Failed has_bookmark check.' ); + + // Move past the bookmark so it must scan backwards. + $this->assertTrue( $processor->next_tag( 'SPAN' ) ); + + // Confirm the bookmark works. + $this->assertTrue( $processor->seek( 'mark' ), 'Failed to seek to bookmark.' ); + $this->assertSame( 'DIV', $processor->get_tag() ); + } + + /** + * @dataProvider data_processor_constructors + * + * @ticket 62290 + */ + public function test_processor_seek_forward( callable $factory ) { + $processor = $factory( '
' ); + $this->assertTrue( $processor->next_tag( 'DIV' ) ); + $this->assertTrue( $processor->set_bookmark( 'one' ), 'Failed to set bookmark "one".' ); + $this->assertTrue( $processor->has_bookmark( 'one' ), 'Failed "one" has_bookmark check.' ); + + // Move past the bookmark so it must scan backwards. + $this->assertTrue( $processor->next_tag( 'SPAN' ) ); + $this->assertTrue( $processor->get_attribute( 'two' ) ); + $this->assertTrue( $processor->set_bookmark( 'two' ), 'Failed to set bookmark "two".' ); + $this->assertTrue( $processor->has_bookmark( 'two' ), 'Failed "two" has_bookmark check.' ); + + // Seek back. + $this->assertTrue( $processor->seek( 'one' ), 'Failed to seek to bookmark "one".' ); + $this->assertSame( 'DIV', $processor->get_tag() ); + + // Seek forward and continue processing. + $this->assertTrue( $processor->seek( 'two' ), 'Failed to seek to bookmark "two".' ); + $this->assertSame( 'SPAN', $processor->get_tag() ); + $this->assertTrue( $processor->get_attribute( 'two' ) ); + + $this->assertTrue( $processor->next_tag() ); + $this->assertSame( 'A', $processor->get_tag() ); + $this->assertTrue( $processor->get_attribute( 'three' ) ); + } + + /** + * Ensure the parsing namespace is handled when seeking from foreign content. + * + * @dataProvider data_processor_constructors + * + * @ticket 62290 + */ + public function test_seek_back_from_foreign_content( callable $factory ) { + $processor = $factory( '' ); + $this->assertTrue( $processor->next_tag( 'CUSTOM-ELEMENT' ) ); + $this->assertTrue( $processor->set_bookmark( 'mark' ), 'Failed to set bookmark "mark".' ); + $this->assertTrue( $processor->has_bookmark( 'mark' ), 'Failed "mark" has_bookmark check.' ); + + /* + * has self-closing flag, but HTML elements (that are not void elements) cannot self-close, + * they must be closed by some means, usually a closing tag. + * + * If the div were interpreted as foreign content, it would self-close. + */ + $this->assertTrue( $processor->has_self_closing_flag() ); + $this->assertTrue( $processor->expects_closer(), 'Incorrectly interpreted HTML custom-element with self-closing flag as self-closing element.' ); + + // Proceed into foreign content. + $this->assertTrue( $processor->next_tag( 'RECT' ) ); + $this->assertSame( 'svg', $processor->get_namespace() ); + $this->assertTrue( $processor->has_self_closing_flag() ); + $this->assertFalse( $processor->expects_closer() ); + $this->assertSame( array( 'HTML', 'BODY', 'CUSTOM-ELEMENT', 'SVG', 'RECT' ), $processor->get_breadcrumbs() ); + + // Seek back. + $this->assertTrue( $processor->seek( 'mark' ), 'Failed to seek to bookmark "mark".' ); + $this->assertSame( 'CUSTOM-ELEMENT', $processor->get_tag() ); + // If the parsing namespace were not correct here (html), + // then the self-closing flag would be misinterpreted. + $this->assertTrue( $processor->has_self_closing_flag() ); + $this->assertTrue( $processor->expects_closer(), 'Incorrectly interpreted HTML custom-element with self-closing flag as self-closing element.' ); + + // Proceed into foreign content again. + $this->assertTrue( $processor->next_tag( 'RECT' ) ); + $this->assertSame( 'svg', $processor->get_namespace() ); + $this->assertTrue( $processor->has_self_closing_flag() ); + $this->assertFalse( $processor->expects_closer() ); + + // The RECT should still descend from the CUSTOM-ELEMENT despite its self-closing flag. + $this->assertSame( array( 'HTML', 'BODY', 'CUSTOM-ELEMENT', 'SVG', 'RECT' ), $processor->get_breadcrumbs() ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_processor_constructors(): array { + return array( + 'Full parser' => array( array( WP_HTML_Processor::class, 'create_full_parser' ) ), + 'Fragment parser' => array( array( WP_HTML_Processor::class, 'create_fragment' ) ), + ); + } +} diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index 864093b66c0a8..c3eb847fa0319 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -133,7 +133,7 @@ public function test_clear_to_navigate_after_seeking() { // Create a bookmark inside of that stack. if ( null !== $processor->get_attribute( 'two' ) ) { - $processor->set_bookmark( 'two' ); + $this->assertTrue( $processor->set_bookmark( 'two' ) ); break; } }