From 3beee24b4e5c08db8193cb8422d3f6983e989c4f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 9 Jun 2026 21:46:14 +0200 Subject: [PATCH 01/13] HTML API: Ignore slash inside unquoted attribute values --- .../html-api/class-wp-html-tag-processor.php | 29 ++++++++++++++++- .../tests/html-api/wpHtmlProcessor.php | 32 +++++++++++++++++++ .../tests/html-api/wpHtmlTagProcessor.php | 27 +++++++++++++++- 3 files changed, 86 insertions(+), 2 deletions(-) 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 77c1a471db5b1..f094f1a2adc09 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 @@ -3337,7 +3337,34 @@ public function has_self_closing_flag(): bool { *
* ^ this appears one character before the end of the closing ">". */ - return '/' === $this->html[ $this->token_starts_at + $this->token_length - 2 ]; + $self_closing_flag_at = $this->token_starts_at + $this->token_length - 2; + if ( '/' !== $this->html[ $self_closing_flag_at ] ) { + return false; + } + + foreach ( $this->attributes as $attribute ) { + $attribute_ends_at = $attribute->start + $attribute->length; + if ( + $self_closing_flag_at >= $attribute->start && + $self_closing_flag_at < $attribute_ends_at + ) { + return false; + } + } + + foreach ( $this->duplicate_attributes ?? array() as $duplicate_attributes ) { + foreach ( $duplicate_attributes as $attribute ) { + $attribute_ends_at = $attribute->start + $attribute->length; + if ( + $self_closing_flag_at >= $attribute->start && + $self_closing_flag_at < $attribute_ends_at + ) { + return false; + } + } + } + + return true; } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index a89014282df73..ad979eb2e8278 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -583,6 +583,38 @@ 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( 'text' ); + + $this->assertTrue( $processor->next_tag( 'MI' ), 'Could not find MI tag: check test setup.' ); + $this->assertSame( + 'abc/', + $processor->get_attribute( 'disabled' ), + '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->expects_closer(), + 'MI with a trailing slash in an unquoted attribute value should still expect a closer.' + ); + + $this->assertTrue( $processor->next_token(), 'Could not find text following MI tag: check test setup.' ); + $this->assertSame( '#text', $processor->get_token_name(), 'Should have found the text node following the MI tag.' ); + $this->assertSame( + array( 'HTML', 'BODY', 'MATH', 'MI', '#text' ), + $processor->get_breadcrumbs(), + 'Text following the MI tag should remain inside the MI 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..a6e1844a332c2 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -111,13 +111,38 @@ 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 ), ); } + /** + * Ensures a trailing slash in an unquoted attribute value is part of the value. + * + * @ticket 61576 + * + * @covers WP_HTML_Tag_Processor::get_attribute + * @covers WP_HTML_Tag_Processor::has_self_closing_flag + */ + public function test_trailing_slash_in_unquoted_attribute_value_is_not_self_closing_flag() { + $processor = new WP_HTML_Tag_Processor( 'text' ); + $this->assertTrue( $processor->next_tag(), 'Could not find MI tag: check test setup.' ); + + $this->assertSame( + 'abc/', + $processor->get_attribute( 'disabled' ), + '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.' + ); + } + /** * @ticket 56299 * From 43f6865712522920f7a6a1bee873a4fc82a88528 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 16 Jun 2026 20:46:58 +0200 Subject: [PATCH 02/13] HTML API: Track self-closing flag during tokenization --- .../html-api/class-wp-html-tag-processor.php | 63 ++++++++----------- .../tests/html-api/wpHtmlTagProcessor.php | 39 ++++++++++++ 2 files changed, 65 insertions(+), 37 deletions(-) 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 c58e5768c3b07..e63b42f1ca142 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 6.9.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,42 +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 ">". - */ - $self_closing_flag_at = $this->token_starts_at + $this->token_length - 2; - if ( '/' !== $this->html[ $self_closing_flag_at ] ) { - return false; - } - - foreach ( $this->attributes as $attribute ) { - $attribute_ends_at = $attribute->start + $attribute->length; - if ( - $self_closing_flag_at >= $attribute->start && - $self_closing_flag_at < $attribute_ends_at - ) { - return false; - } - } - - foreach ( $this->duplicate_attributes ?? array() as $duplicate_attributes ) { - foreach ( $duplicate_attributes as $attribute ) { - $attribute_ends_at = $attribute->start + $attribute->length; - if ( - $self_closing_flag_at >= $attribute->start && - $self_closing_flag_at < $attribute_ends_at - ) { - return false; - } - } - } - - return true; + return $this->self_closing_flag; } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index a6e1844a332c2..1fc7f77fe836a 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -119,6 +119,45 @@ public static function data_has_self_closing_flag() { ); } + /** + * Ensures internally consumed special-element closers do not affect the opener's + * self-closing flag. + * + * @ticket 61576 + * + * @covers WP_HTML_Tag_Processor::has_self_closing_flag + * + * @dataProvider data_special_atomic_self_closing_flags + * + * @param string $html Input HTML whose first tag might contain the self-closing flag `/`. + * @param bool $flag_is_set Whether the input HTML's first tag contains the self-closing flag. + */ + public function test_special_atomic_elements_report_opening_tag_self_closing_flag( string $html, bool $flag_is_set ) { + $processor = new WP_HTML_Tag_Processor( $html ); + + $this->assertTrue( $processor->next_token(), 'Expected to find complete special atomic tag.' ); + + if ( $flag_is_set ) { + $this->assertTrue( $processor->has_self_closing_flag(), 'Did not find the self-closing flag on the opening tag.' ); + } else { + $this->assertFalse( $processor->has_self_closing_flag(), 'Reported the internally consumed closing tag self-closing flag on the opening tag.' ); + } + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_special_atomic_self_closing_flags() { + return array( + 'SCRIPT closer self-closing flag' => array( '', false ), + 'STYLE closer self-closing flag' => array( '', false ), + 'TITLE closer self-closing flag' => array( 'x', false ), + 'TITLE opener self-closing flag' => array( 'x', true ), + ); + } + /** * Ensures a trailing slash in an unquoted attribute value is part of the value. * From 463c8832b6a1a54539f52a015d2e523f73fe76d7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 16 Jun 2026 20:52:04 +0200 Subject: [PATCH 03/13] HTML API: Correct self-closing flag since tag --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e63b42f1ca142..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 @@ -630,7 +630,7 @@ class WP_HTML_Tag_Processor { /** * Whether the current tag token has the self-closing flag. * - * @since 6.9.0 + * @since 7.1.0 * * @var bool */ From d1d4d55f0b8a61f2eb3d28aa80d6b7b8d1ef44de Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 16 Jun 2026 21:03:59 +0200 Subject: [PATCH 04/13] HTML API: Simplify self-closing flag tests --- .../tests/html-api/wpHtmlProcessor.php | 21 ++---- .../tests/html-api/wpHtmlTagProcessor.php | 64 +------------------ 2 files changed, 7 insertions(+), 78 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index ad979eb2e8278..30de6d406468e 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -589,29 +589,20 @@ public function test_expects_closer_foreign_content_self_closing() { * @ticket 61576 */ public function test_trailing_slash_in_unquoted_attribute_value_does_not_self_close_foreign_content() { - $processor = WP_HTML_Processor::create_fragment( 'text' ); + $processor = WP_HTML_Processor::create_fragment( 'This mtext tag is not self-closing, it has [a="b/"] attribute.' ); - $this->assertTrue( $processor->next_tag( 'MI' ), 'Could not find MI tag: check test setup.' ); - $this->assertSame( - 'abc/', - $processor->get_attribute( 'disabled' ), - 'Trailing slash in unquoted attribute value should belong to the attribute value.' - ); + $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->expects_closer(), - 'MI with a trailing slash in an unquoted attribute value should still expect a closer.' - ); - $this->assertTrue( $processor->next_token(), 'Could not find text following MI tag: check test setup.' ); - $this->assertSame( '#text', $processor->get_token_name(), 'Should have found the text node following the MI tag.' ); + $this->assertTrue( $processor->next_token(), 'Could not find text following MTEXT tag: check test setup.' ); $this->assertSame( - array( 'HTML', 'BODY', 'MATH', 'MI', '#text' ), + array( 'HTML', 'BODY', 'MATH', 'MTEXT', '#text' ), $processor->get_breadcrumbs(), - 'Text following the MI tag should remain inside the MI element.' + 'Text following the MTEXT tag should remain inside the MTEXT element.' ); } diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 1fc7f77fe836a..949cfa1b2a62d 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -116,69 +116,7 @@ public static function data_has_self_closing_flag() { '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 ), - ); - } - - /** - * Ensures internally consumed special-element closers do not affect the opener's - * self-closing flag. - * - * @ticket 61576 - * - * @covers WP_HTML_Tag_Processor::has_self_closing_flag - * - * @dataProvider data_special_atomic_self_closing_flags - * - * @param string $html Input HTML whose first tag might contain the self-closing flag `/`. - * @param bool $flag_is_set Whether the input HTML's first tag contains the self-closing flag. - */ - public function test_special_atomic_elements_report_opening_tag_self_closing_flag( string $html, bool $flag_is_set ) { - $processor = new WP_HTML_Tag_Processor( $html ); - - $this->assertTrue( $processor->next_token(), 'Expected to find complete special atomic tag.' ); - - if ( $flag_is_set ) { - $this->assertTrue( $processor->has_self_closing_flag(), 'Did not find the self-closing flag on the opening tag.' ); - } else { - $this->assertFalse( $processor->has_self_closing_flag(), 'Reported the internally consumed closing tag self-closing flag on the opening tag.' ); - } - } - - /** - * Data provider. - * - * @return array[] - */ - public static function data_special_atomic_self_closing_flags() { - return array( - 'SCRIPT closer self-closing flag' => array( '', false ), - 'STYLE closer self-closing flag' => array( '', false ), - 'TITLE closer self-closing flag' => array( 'x', false ), - 'TITLE opener self-closing flag' => array( 'x', true ), - ); - } - - /** - * Ensures a trailing slash in an unquoted attribute value is part of the value. - * - * @ticket 61576 - * - * @covers WP_HTML_Tag_Processor::get_attribute - * @covers WP_HTML_Tag_Processor::has_self_closing_flag - */ - public function test_trailing_slash_in_unquoted_attribute_value_is_not_self_closing_flag() { - $processor = new WP_HTML_Tag_Processor( 'text' ); - $this->assertTrue( $processor->next_tag(), 'Could not find MI tag: check test setup.' ); - - $this->assertSame( - 'abc/', - $processor->get_attribute( 'disabled' ), - '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.' + 'Self-closing flag on internally consumed special element closer' => array( 'x', false ), ); } From c432f7f9b8e52777ac040e9b5fabaf77b8f4e8e7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 16 Jun 2026 22:02:18 +0200 Subject: [PATCH 05/13] HTML API: Simplify self-closing flag detection --- .../html-api/class-wp-html-tag-processor.php | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) 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 c7a545f8b2003..9d668c88405bd 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 @@ -2154,25 +2154,18 @@ private function parse_next_attribute(): bool { $doc_length = strlen( $this->html ); // Skip whitespace and slashes. - $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; - } - + $skipped_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); + $this->bytes_already_parsed += $skipped_length; if ( $this->bytes_already_parsed >= $doc_length ) { $this->parser_state = self::STATE_INCOMPLETE_INPUT; return false; } + $this->self_closing_flag = $skipped_length > 0 && + '/' === $this->html[ $this->bytes_already_parsed - 1 ] && + '>' === $this->html[ $this->bytes_already_parsed ]; + /* * Treat the equal sign as a part of the attribute * name if it is the first encountered byte. From d8b95598ffaee2cc4d9d0a010911a0db86af664d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 21:49:54 +0200 Subject: [PATCH 06/13] HTML API: Address self-closing flag review feedback --- .../html-api/class-wp-html-tag-processor.php | 38 +++++++++---------- .../tests/html-api/wpHtmlProcessor.php | 23 +++++++++++ 2 files changed, 42 insertions(+), 19 deletions(-) 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 9d668c88405bd..49a8aaef9f635 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 @@ -634,7 +634,7 @@ class WP_HTML_Tag_Processor { * * @var bool */ - private $self_closing_flag = false; + private $has_self_closing_flag = false; /** * Byte offset in input document where current tag name starts. @@ -1085,12 +1085,12 @@ private function base_class_next_token(): bool { * the closing to tag to point to the opening of the special atomic * tag sequence. */ - $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; + $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; + $has_self_closing_flag = $this->has_self_closing_flag; + $attributes = $this->attributes; + $duplicate_attributes = $this->duplicate_attributes; // Find the closing tag if necessary. switch ( $tag_name ) { @@ -1140,15 +1140,15 @@ private function base_class_next_token(): bool { * functions that skip the contents have moved all the internal cursors past * the inner content of the tag. */ - $this->token_starts_at = $was_at; - $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; - $this->text_starts_at = $tag_ends_at; - $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; + $this->token_starts_at = $was_at; + $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; + $this->text_starts_at = $tag_ends_at; + $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->has_self_closing_flag = $has_self_closing_flag; + $this->attributes = $attributes; + $this->duplicate_attributes = $duplicate_attributes; return true; } @@ -2162,7 +2162,7 @@ private function parse_next_attribute(): bool { return false; } - $this->self_closing_flag = $skipped_length > 0 && + $this->has_self_closing_flag = $skipped_length > 0 && '/' === $this->html[ $this->bytes_already_parsed - 1 ] && '>' === $this->html[ $this->bytes_already_parsed ]; @@ -2343,7 +2343,7 @@ private function after_tag(): void { $this->token_starts_at = null; $this->token_length = null; - $this->self_closing_flag = false; + $this->has_self_closing_flag = false; $this->tag_name_starts_at = null; $this->tag_name_length = null; $this->text_starts_at = 0; @@ -3352,7 +3352,7 @@ public function has_self_closing_flag(): bool { return false; } - return $this->self_closing_flag; + return $this->has_self_closing_flag; } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index 30de6d406468e..73c87b227283a 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -606,6 +606,29 @@ public function test_trailing_slash_in_unquoted_attribute_value_does_not_self_cl ); } + /** + * Ensures a slash-only unquoted attribute value does not close foreign content. + * + * @ticket 61576 + */ + public function test_slash_only_unquoted_attribute_value_does_not_self_close_foreign_content() { + $processor = WP_HTML_Processor::create_fragment( 'math:mi is not self-closing, it has [a="/"] attribute.' ); + + $this->assertTrue( $processor->next_tag( 'MI' ), 'Could not find MI tag: check test setup.' ); + $this->assertSame( '/', $processor->get_attribute( 'a' ), 'Slash-only unquoted attribute value should belong to the attribute value.' ); + $this->assertFalse( + $processor->has_self_closing_flag(), + 'Slash-only unquoted attribute value should not be interpreted as a self-closing flag.' + ); + + $this->assertTrue( $processor->next_token(), 'Could not find text following MI tag: check test setup.' ); + $this->assertSame( + array( 'HTML', 'BODY', 'MATH', 'MI', '#text' ), + $processor->get_breadcrumbs(), + 'Text following the MI tag should remain inside the MI element.' + ); + } + /** * Ensures that expects_closer works for void-like elements in foreign content. * From 987e0a5c9b58fba7166bdbe62e261b43ce619f3b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 23 Jun 2026 23:54:20 +0200 Subject: [PATCH 07/13] HTML API: Refine slash attribute regression test --- .../tests/html-api/wpHtmlProcessor.php | 37 ++++--------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index 73c87b227283a..17c43b6bb289f 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -583,49 +583,26 @@ 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 a slash-only unquoted attribute value does not close foreign content. * - * @ticket 61576 + * @ticket 65372 */ - public function test_slash_only_unquoted_attribute_value_does_not_self_close_foreign_content() { + public function test_unquoted_slash_attribute_does_not_self_close_foreign_content(): void { $processor = WP_HTML_Processor::create_fragment( 'math:mi is not self-closing, it has [a="/"] attribute.' ); - $this->assertTrue( $processor->next_tag( 'MI' ), 'Could not find MI tag: check test setup.' ); - $this->assertSame( '/', $processor->get_attribute( 'a' ), 'Slash-only unquoted attribute value should belong to the attribute value.' ); + $this->assertTrue( $processor->next_tag( 'MI' ), 'Failed to find the MI tag: check test setup.' ); + $this->assertSame( '/', $processor->get_attribute( 'a' ), 'Failed to treat the slash as the unquoted attribute value.' ); $this->assertFalse( $processor->has_self_closing_flag(), - 'Slash-only unquoted attribute value should not be interpreted as a self-closing flag.' + 'Failed to avoid interpreting the slash-only unquoted attribute value as a self-closing flag.' ); - $this->assertTrue( $processor->next_token(), 'Could not find text following MI tag: check test setup.' ); + $this->assertTrue( $processor->next_token(), 'Failed to find text following the MI tag: check test setup.' ); $this->assertSame( array( 'HTML', 'BODY', 'MATH', 'MI', '#text' ), $processor->get_breadcrumbs(), - 'Text following the MI tag should remain inside the MI element.' + 'Failed to keep text following the MI tag inside the MI element.' ); } From 2bad4069b8ebad6f17058837ff2749abce243758 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 24 Jun 2026 00:15:28 +0200 Subject: [PATCH 08/13] HTML API: Remove bad self-closing flag test case --- tests/phpunit/tests/html-api/wpHtmlTagProcessor.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 949cfa1b2a62d..3a3c35c4b387e 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -116,7 +116,6 @@ public static function data_has_self_closing_flag() { '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 ), ); } From 48c1ed811738623c6165eaff9a9b2da9af47657f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 25 Jun 2026 15:42:25 +0200 Subject: [PATCH 09/13] Add clarifying comment --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 5 +++++ 1 file changed, 5 insertions(+) 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 7eaced7e47c23..1937077e8edcc 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 @@ -2159,6 +2159,11 @@ private function parse_next_attribute(): bool { return false; } + /* + * In ``, `/` is the unquoted attribute value and has + * already been consumed. A skipped slash immediately before `>` + * represents the token's self-closing flag. + */ $this->has_self_closing_flag = $skipped_length > 0 && '/' === $this->html[ $this->bytes_already_parsed - 1 ] && '>' === $this->html[ $this->bytes_already_parsed ]; From 7d033f89890025e58c2748a2b2c3bb115f4eaefa Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 25 Jun 2026 19:15:16 +0200 Subject: [PATCH 10/13] Improve explanation and comment --- .../html-api/class-wp-html-tag-processor.php | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) 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 1937077e8edcc..dac804954ca55 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 @@ -2145,7 +2145,8 @@ private function parse_next_tag(): bool { * @since 6.2.0 * @ignore * - * @return bool Whether an attribute was found before the end of the document. + * @return bool True to indicate attribute parsing should continue. False if a stop condition + * was reached. */ private function parse_next_attribute(): bool { $doc_length = strlen( $this->html ); @@ -2159,14 +2160,25 @@ private function parse_next_attribute(): bool { return false; } - /* - * In ``, `/` is the unquoted attribute value and has - * already been consumed. A skipped slash immediately before `>` - * represents the token's self-closing flag. + /** + * This block serves two purposes: + * + * - A fast path for common tag-ending `>`. + * - A check for the self-closing flag which must appear as `/>`. + * + * In a tag like ``, `/` is the attribute value, not a self-closing + * flag. When it appears in this form, the parser has already consumed the + * attribute value, `$skipped_length` is 0, and this checks below correctly + * identify whether there is a self-closing flag. + * + * Note: Both start and end tags may have the self-closing flag. */ - $this->has_self_closing_flag = $skipped_length > 0 && - '/' === $this->html[ $this->bytes_already_parsed - 1 ] && - '>' === $this->html[ $this->bytes_already_parsed ]; + if ( '>' === $this->html[ $this->bytes_already_parsed ] ) { + if ( $skipped_length > 0 && '/' === $this->html[ $this->bytes_already_parsed - 1 ] ) { + $this->has_self_closing_flag = true; + } + return false; + } /* * Treat the equal sign as a part of the attribute From 06514844cc8044761d8acded8357b11930045ac8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 25 Jun 2026 19:22:34 +0200 Subject: [PATCH 11/13] Correct test case description --- tests/phpunit/tests/html-api/wpHtmlTagProcessor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index bdce0e0580af9..bfaf6d15367d1 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -115,7 +115,7 @@ public static function data_has_self_closing_flag() { '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 ), + 'Ignored "/" and whitespace' => array( '
', false ), ); } From 544a0ca65f475f5390828a2a26f7e2ff4de81261 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 25 Jun 2026 19:23:12 +0200 Subject: [PATCH 12/13] Add relevant test cases --- tests/phpunit/tests/html-api/wpHtmlTagProcessor.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index bfaf6d15367d1..a5f7c193af656 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -113,6 +113,8 @@ public static function data_has_self_closing_flag() { 'Self-closing flag after extra spaces' => array( '
', true ), 'Self-closing flag after attribute' => array( '
', true ), 'Slash inside unquoted attribute value' => array( '
', false ), + 'Slash only unquoted attribute value' => array( '
', false ), + 'Attribute [=="/"]' => array( '
', false ), 'Self-closing flag after quoted attribute' => array( '
', true ), 'Self-closing flag after boolean attribute' => array( '
', true ), 'Ignored "/" and whitespace' => array( '
', false ), From f685e062d0deceb16ccb1a1fbe0cae68d06b56d2 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 25 Jun 2026 19:29:31 +0200 Subject: [PATCH 13/13] Fix test case! --- tests/phpunit/tests/html-api/wpHtmlTagProcessor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index a5f7c193af656..dfb9b16442045 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -114,7 +114,7 @@ public static function data_has_self_closing_flag() { 'Self-closing flag after attribute' => array( '
', true ), 'Slash inside unquoted attribute value' => array( '
', false ), 'Slash only unquoted attribute value' => array( '
', false ), - 'Attribute [=="/"]' => array( '
', false ), + 'Attribute "=" with value ""' => array( '
', false ), 'Self-closing flag after quoted attribute' => array( '
', true ), 'Self-closing flag after boolean attribute' => array( '
', true ), 'Ignored "/" and whitespace' => array( '
', false ),