diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ea97113cb45b2..0afbbd00bb995 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -165,7 +165,7 @@ parameters: - message: '#^Unreachable statement \- code above always terminates\.$#' identifier: deadCode.unreachable - count: 15 + count: 12 path: src/wp-includes/html-api/class-wp-html-processor.php - message: '#^If condition is always true\.$#' 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 7f871ec9e6628..0979f2dfe0a66 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -262,6 +262,12 @@ class WP_HTML_Processor extends WP_HTML_Tag_Processor { */ private $context_node = null; + /** @var bool */ + private $has_produced_after_body_content = false; + + /** @var bool */ + private $has_produced_after_html_content = false; + /* * Public Interface Functions */ @@ -4366,6 +4372,9 @@ private function step_after_body(): bool { */ case '#text': if ( parent::TEXT_IS_WHITESPACE === $this->text_node_classification ) { + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } goto after_body_anything_else; @@ -4378,8 +4387,15 @@ private function step_after_body(): bool { case '#funky-comment': case '#presumptuous-tag': case '#processing-instruction': - $this->bail( 'Content outside of BODY is unsupported.' ); - break; + if ( $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } + if ( ! $this->has_produced_after_body_content ) { + $this->state->stack_of_open_elements->pop_until( 'BODY' ); + $this->has_produced_after_body_content = true; + } + $this->insert_html_element( $this->state->current_token ); + return true; /* * > A DOCTYPE token @@ -4422,6 +4438,9 @@ private function step_after_body(): bool { * > Parse error. Switch the insertion mode to "in body" and reprocess the token. */ after_body_anything_else: + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot return to in body when content has been produced outside of body.' ); + } $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; return $this->step( self::REPROCESS_CURRENT_NODE ); } @@ -4460,6 +4479,9 @@ private function step_in_frameset(): bool { */ case '#text': if ( parent::TEXT_IS_WHITESPACE === $this->text_node_classification ) { + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } $this->bail( 'Non-whitespace characters cannot be handled in frameset.' ); @@ -4582,6 +4604,9 @@ private function step_after_frameset(): bool { */ case '#text': if ( parent::TEXT_IS_WHITESPACE === $this->text_node_classification ) { + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } $this->bail( 'Non-whitespace characters cannot be handled in after frameset' ); @@ -4667,8 +4692,14 @@ private function step_after_after_body(): bool { case '#funky-comment': case '#presumptuous-tag': case '#processing-instruction': - $this->bail( 'Content outside of HTML is unsupported.' ); - break; + if ( ! $this->has_produced_after_html_content ) { + while ( $this->state->stack_of_open_elements->pop() ) { + // Just pop while we can. + } + $this->has_produced_after_html_content = true; + } + $this->insert_html_element( $this->state->current_token ); + return true; /* * > A DOCTYPE token @@ -4688,6 +4719,9 @@ private function step_after_after_body(): bool { */ case '#text': if ( parent::TEXT_IS_WHITESPACE === $this->text_node_classification ) { + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } goto after_after_body_anything_else; @@ -4697,6 +4731,9 @@ private function step_after_after_body(): bool { * > Parse error. Switch the insertion mode to "in body" and reprocess the token. */ after_after_body_anything_else: + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot return to in body when content has been produced outside of body.' ); + } $this->state->insertion_mode = WP_HTML_Processor_State::INSERTION_MODE_IN_BODY; return $this->step( self::REPROCESS_CURRENT_NODE ); } @@ -4732,8 +4769,14 @@ private function step_after_after_frameset(): bool { case '#funky-comment': case '#presumptuous-tag': case '#processing-instruction': - $this->bail( 'Content outside of HTML is unsupported.' ); - break; + if ( ! $this->has_produced_after_html_content ) { + while ( $this->state->stack_of_open_elements->pop() ) { + // Just pop while we can. + } + $this->has_produced_after_html_content = true; + } + $this->insert_html_element( $this->state->current_token ); + return true; /* * > A DOCTYPE token @@ -4756,6 +4799,9 @@ private function step_after_after_frameset(): bool { */ case '#text': if ( parent::TEXT_IS_WHITESPACE === $this->text_node_classification ) { + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } $this->bail( 'Non-whitespace characters cannot be handled in after after frameset.' ); @@ -4765,6 +4811,9 @@ private function step_after_after_frameset(): bool { * > A start tag whose tag name is "noframes" */ case '+NOFRAMES': + if ( $this->has_produced_after_body_content || $this->has_produced_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_head(); } @@ -5575,6 +5624,8 @@ public function seek( $bookmark_name ): bool { $this->state->current_token = null; $this->current_element = null; $this->element_queue = array(); + $this->has_produced_after_body_content = false; + $this->has_produced_after_html_content = false; /* * The absence of a context node indicates a full parse. diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php index 094fea367878a..23a2dff5e83c4 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-bookmark.php @@ -170,4 +170,35 @@ public function test_bookmarks_not_allowed_on_virtual_nodes() { $this->assertTrue( $processor->next_token() ); $this->assertTrue( $processor->set_bookmark( 'mark' ) ); } + + /** + * Ensures that after a backward seek, content after BODY and HTML parses as it did the first time. + * + * @ticket 65967 + * + * @covers ::seek + */ + public function test_seek_backward_reparses_content_after_body_and_html() { + $processor = WP_HTML_Processor::create_full_parser( '
x
' ); + $this->assertTrue( $processor->next_tag( 'P' ) ); + $this->assertTrue( $processor->set_bookmark( 'p' ), 'Failed to set bookmark "p".' ); + + $walk = static function () use ( $processor ) { + $tokens = array(); + while ( $processor->next_token() ) { + $closer = $processor->is_tag_closer() ? '/' : ''; + $tokens[] = $closer . $processor->get_token_name() . ' ' . implode( ' > ', $processor->get_breadcrumbs() ); + } + return $tokens; + }; + + $first_pass = $walk(); + $this->assertNull( $processor->get_last_error(), 'Failed to parse the document on the first pass.' ); + + $this->assertTrue( $processor->seek( 'p' ), 'Failed to seek to bookmark "p".' ); + + $second_pass = $walk(); + $this->assertSame( $first_pass, $second_pass, 'Found different tokens after seeking backward.' ); + $this->assertNull( $processor->get_last_error(), 'Failed to parse the document after seeking backward.' ); + } } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php index 1ce8855305ae9..23920fb7915cc 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php @@ -552,6 +552,105 @@ public static function data_provider_serialize_doctype() { ); } + /** + * Ensures that full documents serialize content after the BODY and HTML end tags + * at its place in the document tree. + * + * @ticket 65967 + * + * @dataProvider data_provider_serialize_content_after_body_and_html + * + * @covers ::serialize + * + * @param string $html Full HTML document. + * @param string $expected Expected serialization. + */ + public function test_full_document_serialize_includes_content_after_body_and_html( string $html, string $expected ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + $this->assertSame( $expected, $processor->serialize() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_provider_serialize_content_after_body_and_html(): array { + return array( + 'Comment after BODY' => array( + '', + '', + ), + 'Processing instruction after BODY' => array( + '', + '', + ), + 'Whitespace and comment after BODY' => array( + ' ', + ' ', + ), + 'Comment after unclosed P and BODY' => array( + 'x', + '
x
', + ), + 'Comment after HTML' => array( + '', + '', + ), + 'Processing instruction after HTML' => array( + '', + '', + ), + 'Whitespace and comments after HTML' => array( + ' ', + ' ', + ), + 'Comments after BODY and after HTML' => array( + '', + '', + ), + 'Comment after FRAMESET and HTML' => array( + '', + '', + ), + 'Document with a trailing comment' => array( + 'x
', + 'x
', + ), + ); + } + + /** + * Ensures that normalize(), which parses its input as a BODY fragment, keeps + * content after BODY and HTML end tags inside BODY. + * + * @ticket 65967 + * + * @dataProvider data_provider_normalize_content_after_body_and_html + * + * @covers ::normalize + * + * @param string $html HTML fragment. + * @param string $expected Expected normalized HTML. + */ + public function test_normalize_keeps_content_after_body_and_html_in_body( string $html, string $expected ) { + $this->assertSame( $expected, WP_HTML_Processor::normalize( $html ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_provider_normalize_content_after_body_and_html(): array { + return array( + 'Comment after BODY' => array( 'x
', 'x
' ), + 'Comment after unclosed P and BODY' => array( 'x