Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 58 additions & 12 deletions src/wp-includes/html-api/class-wp-html-tag-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -2796,8 +2796,11 @@ private function get_enqueued_attribute_value( string $comparable_name ) {

$enqueued_text = $this->lexical_updates[ $comparable_name ]->text;

// Removed attributes erase the entire span.
if ( '' === $enqueued_text ) {
/*
* Removed attributes erase the entire span, unless a space is needed
* to prevent a preceding slash from becoming a self-closing flag.
*/
if ( '' === $enqueued_text || ' ' === $enqueued_text ) {
return null;
}

Expand Down Expand Up @@ -2991,7 +2994,8 @@ public function get_attribute_names_with_prefix( $prefix ): ?array {
continue;
}

if ( '' === $update->text ) {
// A removal replaces the attribute with nothing, or with a space when a slash precedes it.
if ( '' === $update->text || ' ' === $update->text ) {
$removals[ $update_name ] = true;
} elseif ( ! isset( $this->attributes[ $update_name ] ) && str_starts_with( $update_name, $comparable ) ) {
$additions[] = $update_name;
Expand Down Expand Up @@ -4798,20 +4802,62 @@ public function remove_attribute( $name ): bool {
* replacement: ``
*
* Result: <div />
*
* Inside a tag, `/` is ignored unless `>` immediately follows it, which
* makes it the self-closing flag. A `/` directly before an attribute is
* always one of these ignored separators: tag names and attribute names
* end at `/`, and when a `/` appears in an unquoted value, the characters
* after it belong to the value, not to a new attribute. Removing the
* attribute could leave that `/` directly before the `>`, so a space
* replaces the attribute instead.
*
* Example – remove the attribute attr from <g /attr>:
* <g /attr>
* ^--^
* replacement: ` `
*
* Result: <g / >
*
* get_enqueued_attribute_value() and get_attribute_names_with_prefix()
* read a replacement of a single space as a removal.
*/
$attribute_token = $this->attributes[ $name ];
$replacement = '/' === $this->html[ $attribute_token->start - 1 ] ? ' ' : '';

$this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement(
$this->attributes[ $name ]->start,
$this->attributes[ $name ]->length,
''
$attribute_token->start,
$attribute_token->length,
$replacement
);

$duplicate_attributes = $this->duplicate_attributes[ $name ] ?? array();
$duplicates_already_removed = false;

/*
* Duplicate removals are always enqueued as a batch. If the first is
* already present, all are present, and none should be enqueued again.
*/
if ( count( $duplicate_attributes ) > 0 ) {
$first_duplicate = $duplicate_attributes[0];
foreach ( $this->lexical_updates as $update ) {
if ( $first_duplicate->start === $update->start && $first_duplicate->length === $update->length ) {
$duplicates_already_removed = true;
break;
}
}
}

// 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_token->start,
$attribute_token->length,
''
);
if ( ! $duplicates_already_removed ) {
foreach ( $duplicate_attributes as $attribute_token ) {
$replacement = '/' === $this->html[ $attribute_token->start - 1 ] ? ' ' : '';

$this->lexical_updates[] = new WP_HTML_Text_Replacement(
$attribute_token->start,
$attribute_token->length,
$replacement
);
}
}

return true;
Expand Down
54 changes: 54 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,60 @@ public function test_unquoted_slash_attribute_does_not_self_close_foreign_conten
);
}

/**
* Ensures that removing attributes preserves foreign-content tree semantics.
*
* @ticket 65967
*
* @covers ::remove_attribute
*
* @dataProvider data_remove_attribute_preserves_foreign_content_tree
*
* @param string $html HTML containing the attribute to remove.
* @param string $tag_name Foreign element tag name to modify.
* @param bool $expected_has_self_closing_flag Expected self-closing flag state.
* @param bool $expected_expects_closer Expected closer expectation.
* @param array $expected_text_breadcrumbs Expected breadcrumbs for the following text.
*/
public function test_remove_attribute_preserves_foreign_content_tree( $html, $tag_name, $expected_has_self_closing_flag, $expected_expects_closer, $expected_text_breadcrumbs ) {
$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->assertTrue( $processor->remove_attribute( 'attr' ), 'Could not remove the target attribute.' );

$processor = WP_HTML_Processor::create_fragment( $processor->get_updated_html() );
$this->assertTrue( $processor->next_tag( $tag_name ), "Failed to find the updated {$tag_name} tag." );
$this->assertNull( $processor->get_attribute( 'attr' ), 'The updated tag retained the removed attribute.' );
$this->assertSame( $expected_has_self_closing_flag, $processor->has_self_closing_flag(), 'Removing the attribute changed the self-closing flag state.' );
$this->assertSame( $expected_expects_closer, $processor->expects_closer(), 'Removing the attribute changed the closer expectation.' );

$this->assertTrue( $processor->next_token(), 'Failed to find text following the updated tag.' );
$this->assertSame( $expected_text_breadcrumbs, $processor->get_breadcrumbs(), 'Removing the attribute changed the text node ancestry.' );
}

/**
* Data provider.
*
* @return array[]
*/
public static function data_remove_attribute_preserves_foreign_content_tree() {
return array(
'SVG element without self-closing flag' => array(
'<svg><g /attr>inside</g></svg>',
'G',
false,
true,
array( 'HTML', 'BODY', 'SVG', 'G', '#text' ),
),
'MathML element with self-closing flag' => array(
'<math><mi /attr/>outside</math>',
'MI',
true,
false,
array( 'HTML', 'BODY', 'MATH', '#text' ),
),
);
}

/**
* Ensures that expects_closer works for void-like elements in foreign content.
*
Expand Down
25 changes: 25 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,31 @@ public function test_removing_long_attributes_doesnt_break_seek() {
);
}

/**
* @ticket 65967
*
* @covers WP_HTML_Tag_Processor::remove_attribute
* @covers WP_HTML_Tag_Processor::seek
* @covers WP_HTML_Tag_Processor::set_bookmark
*/
public function test_repeated_duplicate_attribute_removal_does_not_break_following_bookmark() {
$processor = new WP_HTML_Tag_Processor( '<div a a></div><path id=x></path>' );
$this->assertTrue( $processor->next_tag( 'div' ), 'Could not find the DIV tag: check test setup.' );
$this->assertTrue( $processor->set_bookmark( 'div' ), 'Could not bookmark the DIV tag.' );
$this->assertTrue( $processor->next_tag( 'path' ), 'Could not find the PATH tag: check test setup.' );
$this->assertTrue( $processor->set_bookmark( 'path' ), 'Could not bookmark the PATH tag.' );

$this->assertTrue( $processor->seek( 'div' ), 'Could not seek back to the DIV tag.' );
$this->assertTrue( $processor->remove_attribute( 'a' ), 'Could not remove the duplicated attribute.' );
$this->assertTrue( $processor->remove_attribute( 'a' ), 'Could not remove the duplicated attribute again.' );
$this->assertNull( $processor->get_attribute( 'a' ), 'The duplicated attribute was not removed.' );
$processor->get_updated_html();

$this->assertTrue( $processor->seek( 'path' ), 'Could not seek to the PATH tag after removing the attributes.' );
$this->assertSame( 'PATH', $processor->get_tag(), 'The PATH bookmark no longer points to its tag.' );
$this->assertSame( 'x', $processor->get_attribute( 'id' ), 'The PATH bookmark points to the wrong tag.' );
}

/**
* @ticket 56299
*
Expand Down
Loading
Loading