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 3c849047a9a51..73d848d91cde3 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 @@ -2576,9 +2576,12 @@ private function apply_attributes_updates( int $shift_this_point ): int { $accumulated_shift_for_given_point += $shift; } - $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); + if ( $diff->start > $bytes_already_copied ) { + $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); + } + $output_buffer .= $diff->text; - $bytes_already_copied = $diff->start + $diff->length; + $bytes_already_copied = max( $bytes_already_copied, $diff->start + $diff->length ); } $this->html = $output_buffer . substr( $this->html, $bytes_already_copied ); @@ -4544,17 +4547,27 @@ public function remove_attribute( $name ): bool { * * Result:
*/ - $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( + $attribute_removal = $this->get_attribute_removal_span( $this->attributes[ $name ]->start, - $this->attributes[ $name ]->length, + $this->attributes[ $name ]->length + ); + + $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( + $attribute_removal->start, + $attribute_removal->length, '' ); // Removes any duplicated attributes if they were also present. foreach ( $this->duplicate_attributes[ $name ] ?? array() as $attribute_token ) { - $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $attribute_removal = $this->get_attribute_removal_span( $attribute_token->start, - $attribute_token->length, + $attribute_token->length + ); + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $attribute_removal->start, + $attribute_removal->length, '' ); } @@ -4562,6 +4575,30 @@ public function remove_attribute( $name ): bool { return true; } + /** + * Returns the full span to remove for an attribute. + * + * Slash characters may be used as attribute separators, as in `
`. + * If an attribute following those separators is removed without removing the + * slash, then the remaining slash can become a self-closing flag. + * + * @since 7.1.0 + * + * @param int $start Byte offset where the attribute starts. + * @param int $length Byte length of the attribute. + * @return WP_HTML_Span Full span to remove. + */ + private function get_attribute_removal_span( int $start, int $length ): WP_HTML_Span { + $tag_name_ends_at = $this->tag_name_starts_at + $this->tag_name_length; + + while ( $start > $tag_name_ends_at && '/' === $this->html[ $start - 1 ] ) { + --$start; + ++$length; + } + + return new WP_HTML_Span( $start, $length ); + } + /** * Adds a new class name to the currently matched tag. * diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index 8cece32438bd3..8b002ea4e1fb3 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -606,6 +606,74 @@ public function test_unquoted_slash_attribute_does_not_self_close_foreign_conten ); } + /** + * Ensures that removing attributes preserves foreign-content self-closing semantics. + * + * @covers ::remove_attribute + * + * @dataProvider data_remove_attribute_preserves_foreign_content_self_closing_semantics + * + * @param string $html HTML containing a foreign element with an attribute. + * @param string $tag_name Foreign element tag name to modify. + * @param string $attribute_to_remove Name of the attribute to remove. + * @param string $updated_html Expected updated HTML. + * @param string $normalized_html Expected normalized HTML after reparsing the updated HTML. + * @param bool $expected_has_self_closing_flag Expected self-closing flag state. + * @param bool $expected_expects_closer Expected closer expectation. + */ + public function test_remove_attribute_preserves_foreign_content_self_closing_semantics( $html, $tag_name, $attribute_to_remove, $updated_html, $normalized_html, $expected_has_self_closing_flag, $expected_expects_closer ) { + $processor = WP_HTML_Processor::create_fragment( $html ); + + $this->assertTrue( $processor->next_tag( $tag_name ), "Failed to find the {$tag_name} tag: check test setup." ); + $this->assertSame( + $expected_has_self_closing_flag, + $processor->has_self_closing_flag(), + 'Test setup has the wrong self-closing flag state.' + ); + $this->assertSame( + $expected_expects_closer, + $processor->expects_closer(), + 'Test setup has the wrong closer expectation.' + ); + + $this->assertTrue( $processor->remove_attribute( $attribute_to_remove ), 'Could not remove the target attribute.' ); + + $this->assertSame( + $updated_html, + $processor->get_updated_html(), + 'Removing the attribute should preserve foreign-content self-closing semantics.' + ); + $this->assertSame( + $expected_has_self_closing_flag, + $processor->has_self_closing_flag(), + 'Removing the attribute should preserve the self-closing flag state.' + ); + $this->assertSame( + $expected_expects_closer, + $processor->expects_closer(), + 'Removing the attribute should preserve the closer expectation.' + ); + $this->assertSame( + $normalized_html, + WP_HTML_Processor::normalize( $processor->get_updated_html() ), + 'Updated HTML should preserve foreign-content self-closing semantics when parsed again.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_remove_attribute_preserves_foreign_content_self_closing_semantics() { + return array( + 'SVG G without self-closing flag' => array( '', 'G', 'b', '', '', false, true ), + 'MathML MI without self-closing flag' => array( '', 'MI', 'b', '', '', false, true ), + 'SVG G with self-closing flag' => array( 'text', 'G', '=', 'text', 'text', true, false ), + 'MathML MI with self-closing flag' => array( 'text', 'MI', '=', 'text', 'text', true, false ), + ); + } + /** * 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 88762ddbb60c4..2095454e234e4 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1107,6 +1107,109 @@ public function test_get_attribute_reflects_removed_attribute_before_it_is_appli ); } + /** + * Ensures that removing attributes preserves self-closing flag state. + * + * @covers WP_HTML_Tag_Processor::remove_attribute + * + * @dataProvider data_remove_attribute_preserves_self_closing_flag_state + * + * @param string $html HTML containing an attribute to remove. + * @param string $attribute_to_remove Name of the attribute to remove. + * @param string $expected Expected updated HTML. + * @param bool $expected_has_self_closing_flag Expected self-closing flag state. + */ + public function test_remove_attribute_preserves_self_closing_flag_state( $html, $attribute_to_remove, $expected, $expected_has_self_closing_flag ) { + $processor = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( $processor->next_tag(), 'Could not find the DIV tag: check test setup.' ); + $this->assertSame( + $expected_has_self_closing_flag, + $processor->has_self_closing_flag(), + 'Test setup has the wrong self-closing flag state.' + ); + + $processor->remove_attribute( $attribute_to_remove ); + + $this->assertSame( + $expected, + $processor->get_updated_html(), + 'Removing the attribute should not change the self-closing flag state.' + ); + $this->assertSame( + $expected_has_self_closing_flag, + $processor->has_self_closing_flag(), + 'Removing the attribute should preserve the self-closing flag state.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_remove_attribute_preserves_self_closing_flag_state() { + return array( + 'Boolean attribute after slash separator' => array( '
', 'b', '
', false ), + 'Unquoted attribute after slash separator' => array( '
', 'b', '
', false ), + 'Double-quoted attribute after slash separator' => array( '
', 'b', '
', false ), + 'Single-quoted attribute after slash separator' => array( "
", 'b', '
', false ), + 'Equals-sign attribute after slash separator' => array( '
', '=', '
', false ), + 'Duplicate attribute after slash separator' => array( '
', 'b', '
', false ), + 'Attribute after multiple slash separators' => array( '
', 'b', '
', false ), + 'Boolean attribute before self-closing flag' => array( '
', 'b', '
', true ), + 'Equals-sign attribute before self-closing flag' => array( '
', '=', '
', true ), + 'Slash-separated attribute before self-closing flag' => array( '
', 'b', '
', true ), + 'Spaced boolean attribute before self-closing flag' => array( '
', 'b', '
', true ), + 'Spaced equals-sign attribute before self-closing flag' => array( '
', '=', '
', true ), + ); + } + + /** + * Ensures that adding and removing attributes with the same insertion point doesn't reinsert the removed text. + * + * @covers WP_HTML_Tag_Processor::set_attribute + * @covers WP_HTML_Tag_Processor::add_class + * @covers WP_HTML_Tag_Processor::remove_attribute + * + * @dataProvider data_can_add_attribute_while_removing_slash_separated_attribute + * + * @param string $html HTML containing a slash-separated attribute. + * @param string $method Method for adding an attribute before removing another. + * @param string $expected Expected updated HTML. + */ + public function test_can_add_attribute_while_removing_slash_separated_attribute( $html, $method, $expected ) { + $processor = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( $processor->next_tag(), 'Could not find the DIV tag: check test setup.' ); + + if ( 'set_attribute' === $method ) { + $processor->set_attribute( 'a', 'x' ); + } else { + $processor->add_class( 'x' ); + } + + $processor->remove_attribute( 'b' ); + + $this->assertSame( + $expected, + $processor->get_updated_html(), + 'Adding an attribute while removing another should not reinsert the removed slash-separated attribute.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_can_add_attribute_while_removing_slash_separated_attribute() { + return array( + 'Set attribute, no self-closing flag' => array( '
', 'set_attribute', '
' ), + 'Add class, no self-closing flag' => array( '
', 'add_class', '
' ), + 'Set attribute, self-closing flag' => array( '
', 'set_attribute', '
' ), + 'Add class, self-closing flag' => array( '
', 'add_class', '
' ), + ); + } + /** * @ticket 56299 *