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
49 changes: 38 additions & 11 deletions src/wp-includes/html-api/class-wp-html-tag-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -2798,8 +2798,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 @@ -4745,19 +4748,43 @@ public function remove_attribute( $name ): bool {
*
* Result: <div />
*/
$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 65372
*
* @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 65372
*
* @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
39 changes: 39 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -1332,6 +1332,45 @@ public function test_remove_attribute_with_an_existing_attribute_name_removes_it
);
}

/**
* @ticket 65372
*
* @covers WP_HTML_Tag_Processor::remove_attribute
*
* @dataProvider data_remove_attribute_preserves_self_closing_flag_state
*
* @param string $html HTML containing the attribute to remove.
* @param int $removal_count Number of times to remove the attribute.
* @param string $expected Expected HTML after removing the attribute.
* @param bool $expected_has_self_closing_flag Expected self-closing flag state.
*/
public function test_remove_attribute_preserves_self_closing_flag_state( $html, $removal_count, $expected, $expected_has_self_closing_flag ) {
$processor = new WP_HTML_Tag_Processor( $html );
$processor->next_tag( 'g' );
$this->assertSame( $expected_has_self_closing_flag, $processor->has_self_closing_flag(), 'Test setup has the wrong self-closing flag state.' );

for ( $i = 0; $i < $removal_count; $i++ ) {
$processor->remove_attribute( 'attr' );
$this->assertNull( $processor->get_attribute( 'attr' ) );
}

$this->assertSame( $expected, $processor->get_updated_html() );
$this->assertSame( $expected_has_self_closing_flag, $processor->has_self_closing_flag(), 'Removing the attribute changed the self-closing flag state.' );
}

/**
* Data provider.
*
* @return array[]
*/
public static function data_remove_attribute_preserves_self_closing_flag_state() {
return array(
'Attribute preceded by a slash' => array( '<svg><g /attr>ok', 1, '<svg><g / >ok', false ),
'Attribute before self-closing flag' => array( '<svg><g /attr/>ok', 1, '<svg><g / />ok', true ),
'Duplicate attribute removed twice' => array( '<svg><g attr /attr>ok', 2, '<svg><g / >ok', false ),
);
}

/**
* @ticket 58119
*
Expand Down
Loading