diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 501a623afb10b..c7a545f8b2003 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -627,6 +627,15 @@ class WP_HTML_Tag_Processor { */ private $token_length; + /** + * Whether the current tag token has the self-closing flag. + * + * @since 7.1.0 + * + * @var bool + */ + private $self_closing_flag = false; + /** * Byte offset in input document where current tag name starts. * @@ -1079,6 +1088,7 @@ private function base_class_next_token(): bool { $tag_name_starts_at = $this->tag_name_starts_at; $tag_name_length = $this->tag_name_length; $tag_ends_at = $this->token_starts_at + $this->token_length; + $self_closing_flag = $this->self_closing_flag; $attributes = $this->attributes; $duplicate_attributes = $this->duplicate_attributes; @@ -1136,6 +1146,7 @@ private function base_class_next_token(): bool { $this->text_length = $this->tag_name_starts_at - $this->text_starts_at; $this->tag_name_starts_at = $tag_name_starts_at; $this->tag_name_length = $tag_name_length; + $this->self_closing_flag = $self_closing_flag; $this->attributes = $attributes; $this->duplicate_attributes = $duplicate_attributes; @@ -2143,7 +2154,19 @@ private function parse_next_attribute(): bool { $doc_length = strlen( $this->html ); // Skip whitespace and slashes. - $this->bytes_already_parsed += strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); + $skipped_start = $this->bytes_already_parsed; + $this->bytes_already_parsed += strspn( $this->html, " \t\f\r\n/", $skipped_start ); + + // A slash inside an unquoted attribute value will not have been skipped here. + if ( + $this->bytes_already_parsed < $doc_length && + $this->bytes_already_parsed > $skipped_start && + '/' === $this->html[ $this->bytes_already_parsed - 1 ] && + '>' === $this->html[ $this->bytes_already_parsed ] + ) { + $this->self_closing_flag = true; + } + if ( $this->bytes_already_parsed >= $doc_length ) { $this->parser_state = self::STATE_INCOMPLETE_INPUT; @@ -2327,6 +2350,7 @@ private function after_tag(): void { $this->token_starts_at = null; $this->token_length = null; + $this->self_closing_flag = false; $this->tag_name_starts_at = null; $this->tag_name_length = null; $this->text_starts_at = 0; @@ -3335,15 +3359,7 @@ public function has_self_closing_flag(): bool { return false; } - /* - * The self-closing flag is the solidus at the _end_ of the tag, not the beginning. - * - * Example: - * - *
- * ^ this appears one character before the end of the closing ">". - */ - return '/' === $this->html[ $this->token_starts_at + $this->token_length - 2 ]; + return $this->self_closing_flag; } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index a89014282df73..30de6d406468e 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -583,6 +583,29 @@ public function test_expects_closer_foreign_content_self_closing() { $this->assertTrue( $processor->expects_closer() ); } + /** + * Ensures a trailing slash in an unquoted attribute value does not close foreign content. + * + * @ticket 61576 + */ + public function test_trailing_slash_in_unquoted_attribute_value_does_not_self_close_foreign_content() { + $processor = WP_HTML_Processor::create_fragment( 'This mtext tag is not self-closing, it has [a="b/"] attribute.' ); + + $this->assertTrue( $processor->next_tag( 'MTEXT' ), 'Could not find MTEXT tag: check test setup.' ); + $this->assertSame( 'b/', $processor->get_attribute( 'a' ), 'Trailing slash in unquoted attribute value should belong to the attribute value.' ); + $this->assertFalse( + $processor->has_self_closing_flag(), + 'Trailing slash in unquoted attribute value should not be interpreted as a self-closing flag.' + ); + + $this->assertTrue( $processor->next_token(), 'Could not find text following MTEXT tag: check test setup.' ); + $this->assertSame( + array( 'HTML', 'BODY', 'MATH', 'MTEXT', '#text' ), + $processor->get_breadcrumbs(), + 'Text following the MTEXT tag should remain inside the MTEXT element.' + ); + } + /** * Ensures that expects_closer works for void-like elements in foreign content. * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 22ace3890f469..949cfa1b2a62d 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -111,10 +111,12 @@ public static function data_has_self_closing_flag() { 'No self-closing flag on a foreign element' => array( '', false ), // These involve syntax peculiarities. 'Self-closing flag after extra spaces' => array( '
', true ), - 'Self-closing flag after attribute' => array( '
', true ), + 'Self-closing flag after attribute' => array( '
', true ), + 'Slash inside unquoted attribute value' => array( '
', false ), 'Self-closing flag after quoted attribute' => array( '
', true ), 'Self-closing flag after boolean attribute' => array( '
', true ), 'Boolean attribute that looks like a self-closer' => array( '
', false ), + 'Self-closing flag on internally consumed special element closer' => array( 'x', false ), ); }