From 90503cbd2085f200055420c427ccad31c64b1f6b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 9 Sep 2024 10:07:51 +0200 Subject: [PATCH 1/5] Handling content after BODY,HTML where possible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve support for content appearing after BODY, HTML nodes where possible. Some HTML may be representable by the HTML API without introducing ordering problems. When HTML moves to the "after-{body,html,…}" insertion modes, BODY and HTML remain on the stack of open elements. Certain content is inserted in place (comments), some content is inserted in BODY (white space text), and most other content will switch back to the "in body" insertion mode. The difficulty with the HTML API is that the result in the tree may be out-of-order compared to the positioning of the corresponding tokens in the HTML input text. For example: FOO Produces this tree where the ordering is the same even though a BODY closer is reached: [DOCUMENT] └HTML ├HEAD ├BODY │ └#text: FOO └#comment: comment Compare with this case: FOO The tree is identical, the FOO text is _out of order_ in the tree compared to the HTML string. This case continues to be invalid because when the FOO text needs to be reached, the parser would have already reached a BODY tag closer and content outside of the body: [DOCUMENT] └HTML ├HEAD ├BODY │ └#text: FOO └#comment: comment This approach deviates from the explicit steps in the spec and will pop the BODY and HTML tags from the stack of open elements when content should appear outside of them. Flags are set for each "after-" mode to prevent returning to BODY and then continuing to produce content outside, which would produce the unsupported out-of-order behavior. See https://html.spec.whatwg.org/#parsing-main-afterbody. --- .../html-api/class-wp-html-processor.php | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) 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 cb581fac3988f..cd081d2df48c9 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -256,6 +256,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_procuded_after_html_content = false; + /* * Public Interface Functions */ @@ -3953,6 +3959,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_procuded_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } goto after_body_anything_else; @@ -3964,8 +3973,15 @@ private function step_after_body(): bool { case '#comment': case '#funky-comment': case '#presumptuous-tag': - $this->bail( 'Content outside of BODY is unsupported.' ); - break; + if ( $this->has_procuded_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 @@ -4001,6 +4017,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_procuded_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 ); } @@ -4038,6 +4057,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_procuded_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.' ); @@ -4157,6 +4179,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_procuded_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' ); @@ -4230,8 +4255,14 @@ private function step_after_after_body(): bool { case '#comment': case '#funky-comment': case '#presumptuous-tag': - $this->bail( 'Content outside of HTML is unsupported.' ); - break; + if ( ! $this->has_procuded_after_html_content ) { + while ( $this->state->stack_of_open_elements->pop() ) { + // Just pop while we can. + } + $this->has_procuded_after_html_content = true; + } + $this->insert_html_element( $this->state->current_token ); + return true; /* * > A DOCTYPE token @@ -4251,6 +4282,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_procuded_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_body(); } goto after_after_body_anything_else; @@ -4261,6 +4295,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_procuded_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 ); } @@ -4293,8 +4330,14 @@ private function step_after_after_frameset(): bool { case '#comment': case '#funky-comment': case '#presumptuous-tag': - $this->bail( 'Content outside of HTML is unsupported.' ); - break; + if ( ! $this->has_procuded_after_html_content ) { + while ( $this->state->stack_of_open_elements->pop() ) { + // Just pop while we can. + } + $this->has_procuded_after_html_content = true; + } + $this->insert_html_element( $this->state->current_token ); + return true; /* * > A DOCTYPE token @@ -4317,6 +4360,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_procuded_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.' ); @@ -4326,6 +4372,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_procuded_after_html_content ) { + $this->bail( 'Cannot produce out-of-order content.' ); + } return $this->step_in_head(); } From f4654266894175cb48c3ca6c4b4e9072786d17c6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 13:39:01 +0400 Subject: [PATCH 2/5] Build/Test Tools: Lower the HTML Processor unreachable-statement ignore count. This branch replaces the `bail()` and `break` in the comment cases of the "after body", "after after body" and "after after frameset" insertion modes with code that inserts the comment. PHPStan now finds 12 unreachable statements in the HTML Processor, not the 15 that phpstan.neon.dist expects, and reports the ignore count as an error. --- phpstan.neon.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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\.$#' From dc801e97cb17fb6c3ba5a816a6fceb5413bb7f13 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 14:06:54 +0400 Subject: [PATCH 3/5] HTML API: Reset after-body state when seeking backward. A backward seek reparses the document from the start, but it kept the flags that record content produced after BODY and after HTML. The reparse then bailed with "Cannot produce out-of-order content." on content that parsed without error the first time. Reset both flags with the rest of the parser state. See #65967. --- .../html-api/class-wp-html-processor.php | 2 ++ .../html-api/wpHtmlProcessor-bookmark.php | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+) 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 d7675996d9ceb..68ece2c7d9880 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -5624,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_procuded_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.' ); + } } From 708e82def3c27a7caf80a1a48bc34ee29d936264 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 14:07:05 +0400 Subject: [PATCH 4/5] HTML API: Fix the spelling of the after-HTML content flag. Rename `$has_procuded_after_html_content` to `$has_produced_after_html_content`. See #65967. --- .../html-api/class-wp-html-processor.php | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) 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 68ece2c7d9880..0979f2dfe0a66 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -266,7 +266,7 @@ class WP_HTML_Processor extends WP_HTML_Tag_Processor { private $has_produced_after_body_content = false; /** @var bool */ - private $has_procuded_after_html_content = false; + private $has_produced_after_html_content = false; /* * Public Interface Functions @@ -4372,7 +4372,7 @@ 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_procuded_after_html_content ) { + 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(); @@ -4387,7 +4387,7 @@ private function step_after_body(): bool { case '#funky-comment': case '#presumptuous-tag': case '#processing-instruction': - if ( $this->has_procuded_after_html_content ) { + if ( $this->has_produced_after_html_content ) { $this->bail( 'Cannot produce out-of-order content.' ); } if ( ! $this->has_produced_after_body_content ) { @@ -4438,7 +4438,7 @@ 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_procuded_after_html_content ) { + 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; @@ -4479,7 +4479,7 @@ 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_procuded_after_html_content ) { + 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(); @@ -4604,7 +4604,7 @@ 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_procuded_after_html_content ) { + 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(); @@ -4692,11 +4692,11 @@ private function step_after_after_body(): bool { case '#funky-comment': case '#presumptuous-tag': case '#processing-instruction': - if ( ! $this->has_procuded_after_html_content ) { + if ( ! $this->has_produced_after_html_content ) { while ( $this->state->stack_of_open_elements->pop() ) { // Just pop while we can. } - $this->has_procuded_after_html_content = true; + $this->has_produced_after_html_content = true; } $this->insert_html_element( $this->state->current_token ); return true; @@ -4719,7 +4719,7 @@ 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_procuded_after_html_content ) { + 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(); @@ -4731,7 +4731,7 @@ 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_procuded_after_html_content ) { + 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; @@ -4769,11 +4769,11 @@ private function step_after_after_frameset(): bool { case '#funky-comment': case '#presumptuous-tag': case '#processing-instruction': - if ( ! $this->has_procuded_after_html_content ) { + if ( ! $this->has_produced_after_html_content ) { while ( $this->state->stack_of_open_elements->pop() ) { // Just pop while we can. } - $this->has_procuded_after_html_content = true; + $this->has_produced_after_html_content = true; } $this->insert_html_element( $this->state->current_token ); return true; @@ -4799,7 +4799,7 @@ 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_procuded_after_html_content ) { + 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(); @@ -4811,7 +4811,7 @@ 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_procuded_after_html_content ) { + 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(); @@ -5625,7 +5625,7 @@ public function seek( $bookmark_name ): bool { $this->current_element = null; $this->element_queue = array(); $this->has_produced_after_body_content = false; - $this->has_procuded_after_html_content = false; + $this->has_produced_after_html_content = false; /* * The absence of a context node indicates a full parse. From 77ce73977c62a43d7e1f888c37479260013b15a3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 16:35:58 +0400 Subject: [PATCH 5/5] HTML API: Add unit tests for content after BODY and HTML. Cover the full parser on comments, processing instructions and whitespace after the BODY and HTML end tags: - Breadcrumbs and depth of every node in the supported shapes. - `ERROR_UNSUPPORTED` at the first token the processor cannot visit in document order after it has visited a node outside of BODY. - `serialize()` of full documents, and `normalize()`, which parses a BODY fragment and so keeps this content inside BODY. The expected trees come from PHP 8.5's `Dom\HTMLDocument`. It parses `` as a bogus comment; the HTML standard now parses it as a processing instruction, which goes to the same place in the tree. See #65967. --- .../html-api/wpHtmlProcessor-serialize.php | 99 ++++++++ .../tests/html-api/wpHtmlProcessor.php | 50 ++++ .../html-api/wpHtmlProcessorBreadcrumbs.php | 214 ++++++++++++++++++ 3 files changed, 363 insertions(+) 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( + 't

x

', + 't

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', '

x

' ), + 'Whitespace after comment after BODY' => array( '

x

', '

x

' ), + 'Processing instruction and newline after HTML' => array( "

x

\n", "

x

\n" ), + ); + } + /** * Ensures that leading newlines in PRE, LISTING, and TEXTAREA elements are normalized * according to their parsing namespace, and that normalization is idempotent in these cases. diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index 58e7ef18cba41..046c7a00dca08 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -1226,4 +1226,54 @@ public function test_prevents_unbounded_bookmarking() { $processor->set_bookmark( 'beyond the limit' ) ); } + + /** + * Ensures that after visiting a node outside of BODY, the processor bails + * on any later token it cannot visit in document order. + * + * @ticket 65967 + * + * @dataProvider data_unsupported_content_after_body_and_html + * + * @covers ::next_token + * + * @param string $html Full HTML document. + * @param string[] $breadcrumbs_before_bail Breadcrumbs of the last node visited before bailing. + */ + public function test_bails_on_unsupported_content_after_body_and_html( string $html, array $breadcrumbs_before_bail ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + + $breadcrumbs = array(); + while ( $processor->next_token() ) { + $breadcrumbs = $processor->get_breadcrumbs(); + } + + $this->assertSame( WP_HTML_Processor::ERROR_UNSUPPORTED, $processor->get_last_error(), 'Failed to bail on unsupported content.' ); + $this->assertSame( $breadcrumbs_before_bail, $breadcrumbs, 'Bailed at the wrong token.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_unsupported_content_after_body_and_html(): array { + return array( + 'Whitespace after comment after BODY' => array( ' ', array( 'HTML', '#comment' ) ), + 'Whitespace after processing instruction after BODY' => array( ' ', array( 'HTML', '#processing-instruction' ) ), + 'Whitespace after presumptuous tag after BODY' => array( ' ', array( 'HTML', '#presumptuous-tag' ) ), + 'Whitespace into unclosed P after comment after BODY' => array( '

x ', array( 'HTML', '#comment' ) ), + 'Text after comment after BODY' => array( 'x', array( 'HTML', '#comment' ) ), + 'NULL byte after comment after BODY' => array( "\x00", array( 'HTML', '#comment' ) ), + 'Element after comment after BODY' => array( '

', array( 'HTML', '#comment' ) ), + 'BODY end tag after comment after BODY' => array( '', array( 'HTML', '#comment' ) ), + 'Whitespace after HTML after comment after BODY' => array( ' ', array( 'HTML', '#comment' ) ), + 'Newline after comment after HTML' => array( "\n", array( '#comment' ) ), + 'Text after comment after HTML' => array( 'x', array( '#comment' ) ), + 'Element after comment after HTML' => array( '

', array( '#comment' ) ), + 'HTML end tag after comment after HTML' => array( '', array( '#comment' ) ), + 'Whitespace after comment after FRAMESET and HTML' => array( ' ', array( '#comment' ) ), + 'NOFRAMES after comment after FRAMESET and HTML' => array( '', array( '#comment' ) ), + ); + } } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php b/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php index b54fc047ab040..9cc7c360aef91 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessorBreadcrumbs.php @@ -580,4 +580,218 @@ public static function data_virtual_nodes_breadcrumbs() { 'Implied A tag closer on A tag descendent' => array( '', 4, 'A', 'close', array( 'HTML', 'BODY' ) ), ); } + + /** + * Ensures that nodes after the BODY and HTML end tags report their place in the document tree. + * + * @ticket 65967 + * + * @dataProvider data_content_after_body_and_html + * + * @covers WP_HTML_Processor::get_breadcrumbs + * + * @param string $html Full HTML document. + * @param string[][] $expected_breadcrumbs Breadcrumbs of each node, in document order. + */ + public function test_breadcrumbs_of_content_after_body_and_html( string $html, array $expected_breadcrumbs ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + + $breadcrumbs = array(); + while ( $processor->next_token() ) { + if ( ! $processor->is_tag_closer() ) { + $breadcrumbs[] = $processor->get_breadcrumbs(); + } + } + + $this->assertNull( $processor->get_last_error(), 'Failed to parse the document.' ); + $this->assertSame( $expected_breadcrumbs, $breadcrumbs, 'Found the wrong breadcrumbs.' ); + } + + /** + * Ensures that nodes after the BODY and HTML end tags report their depth in the document tree. + * + * @ticket 65967 + * + * @dataProvider data_content_after_body_and_html + * + * @covers WP_HTML_Processor::get_current_depth + * + * @param string $html Full HTML document. + * @param string[][] $expected_breadcrumbs Breadcrumbs of each node, in document order. + */ + public function test_depth_of_content_after_body_and_html( string $html, array $expected_breadcrumbs ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + + $depths = array(); + while ( $processor->next_token() ) { + if ( ! $processor->is_tag_closer() ) { + $depths[] = $processor->get_current_depth(); + } + } + + $this->assertNull( $processor->get_last_error(), 'Failed to parse the document.' ); + $this->assertSame( array_map( 'count', $expected_breadcrumbs ), $depths, 'Found the wrong depths.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_content_after_body_and_html(): array { + return array( + 'Comment after BODY' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#comment' ), + ), + ), + 'Processing instruction after BODY' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#processing-instruction' ), + ), + ), + 'Funky comment after BODY' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#funky-comment' ), + ), + ), + 'Whitespace after BODY' => array( + ' ', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', '#text' ), + ), + ), + 'Whitespace and comment after BODY' => array( + ' ', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', '#text' ), + array( 'HTML', '#comment' ), + ), + ), + 'Comments after BODY' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#comment' ), + array( 'HTML', '#comment' ), + ), + ), + 'Ignored DOCTYPE after comment after BODY' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#comment' ), + ), + ), + 'Comment after closed P and BODY' => array( + '

x

', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', 'P' ), + array( 'HTML', 'BODY', 'P', '#text' ), + array( 'HTML', '#comment' ), + ), + ), + 'Comment after unclosed P and BODY' => array( + '

x', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', 'P' ), + array( 'HTML', 'BODY', 'P', '#text' ), + array( 'HTML', '#comment' ), + ), + ), + 'Comment after HTML' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( '#comment' ), + ), + ), + 'Processing instruction after HTML' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( '#processing-instruction' ), + ), + ), + 'Whitespace after HTML' => array( + ' ', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', '#text' ), + ), + ), + 'Whitespace and comment after HTML' => array( + ' ', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', 'BODY', '#text' ), + array( '#comment' ), + ), + ), + 'Ignored DOCTYPE after comment after HTML' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( '#comment' ), + ), + ), + 'Comments after BODY and after HTML' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'BODY' ), + array( 'HTML', '#comment' ), + array( '#comment' ), + ), + ), + 'Comment after FRAMESET and HTML' => array( + '', + array( + array( 'HTML' ), + array( 'HTML', 'HEAD' ), + array( 'HTML', 'FRAMESET' ), + array( '#comment' ), + ), + ), + ); + } }