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
72 changes: 66 additions & 6 deletions src/wp-includes/html-api/class-wp-html-tag-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -2379,6 +2379,7 @@ private function after_tag(): void {
* need to be flushed to raw lexical updates.
*/
$this->class_name_updates_to_attributes_updates();
$this->rewrite_br_tag_closer();

/*
* Purge updates if there are too many. The actual count isn't
Expand Down Expand Up @@ -2582,6 +2583,64 @@ private function class_name_updates_to_attributes_updates(): void {
}
}

/**
* Rewrites BR end tag tokens with enqueued attribute updates as tag openers.
*
* According to the HTML specification `</br attr>` is treated as a BR element
* with no attributes. When adding attributes to such a token, the existing
* attribute-like text must be discarded before new attributes can appear.
*
* @since 7.2.0
* @ignore
*/
private function rewrite_br_tag_closer(): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think we could handle this instead inside get_updated_html()?

my thinking was that maybe we could add a basic check: if there are class or attribute updates, and it’s a BR, and it’s a “closing tag” then add a new lexical update to remove the leading /?

if (
self::STATE_MATCHED_TAG !== $this->parser_state ||
! $this->is_closing_tag ||
'BR' !== $this->get_tag()
) {
return;
}

$attribute_updates = array();
foreach ( $this->lexical_updates as $name => $update ) {
if ( is_int( $name ) ) {
continue;
}

$attribute_updates[] = $update;
unset( $this->lexical_updates[ $name ] );
}

if ( 0 === count( $attribute_updates ) ) {
return;
}

usort( $attribute_updates, array( self::class, 'sort_start_ascending' ) );

$updated_attributes = '';
foreach ( $attribute_updates as $update ) {
$updated_attributes .= $update->text;
}

/*
* Keep the original token start and tag-name bytes intact so bookmarks
* remain anchored and source tag-name casing is preserved.
*/
$this->lexical_updates[] = new WP_HTML_Text_Replacement(
$this->token_starts_at + 1,
1,
''
);

$attribute_like_text_starts_at = $this->tag_name_starts_at + $this->tag_name_length;
$this->lexical_updates[] = new WP_HTML_Text_Replacement(
$attribute_like_text_starts_at,
$this->token_starts_at + $this->token_length - 1 - $attribute_like_text_starts_at,
$updated_attributes
);
}

/**
* Applies attribute updates to HTML document.
*
Expand Down Expand Up @@ -2960,7 +3019,7 @@ private function get_decoded_attribute_value( WP_HTML_Attribute_Token $attribute
public function get_attribute_names_with_prefix( $prefix ): ?array {
if (
self::STATE_MATCHED_TAG !== $this->parser_state ||
$this->is_closing_tag
$this->is_tag_closer()
) {
return null;
}
Expand Down Expand Up @@ -4599,7 +4658,7 @@ private static function escape_javascript_script_contents( string $sourcecode ):
public function set_attribute( $name, $value ): bool {
if (
self::STATE_MATCHED_TAG !== $this->parser_state ||
$this->is_closing_tag
$this->is_tag_closer()
) {
return false;
}
Expand Down Expand Up @@ -4750,7 +4809,7 @@ public function set_attribute( $name, $value ): bool {
public function remove_attribute( $name ): bool {
if (
self::STATE_MATCHED_TAG !== $this->parser_state ||
$this->is_closing_tag
$this->is_tag_closer()
) {
return false;
}
Expand Down Expand Up @@ -4828,7 +4887,7 @@ public function remove_attribute( $name ): bool {
public function add_class( $class_name ): bool {
if (
self::STATE_MATCHED_TAG !== $this->parser_state ||
$this->is_closing_tag
$this->is_tag_closer()
) {
return false;
}
Expand Down Expand Up @@ -4870,7 +4929,7 @@ public function add_class( $class_name ): bool {
public function remove_class( $class_name ): bool {
if (
self::STATE_MATCHED_TAG !== $this->parser_state ||
$this->is_closing_tag
$this->is_tag_closer()
) {
return false;
}
Expand Down Expand Up @@ -4944,6 +5003,7 @@ public function get_updated_html(): string {
* 1. Apply the enqueued edits and update all the pointers to reflect those changes.
*/
$this->class_name_updates_to_attributes_updates();
$this->rewrite_br_tag_closer();
$before_current_tag += $this->apply_attributes_updates( $before_current_tag );

/*
Expand Down Expand Up @@ -5048,7 +5108,7 @@ private function parse_query( $query ) {
* @return bool Whether the given tag and its attribute match the search criteria.
*/
private function matches(): bool {
if ( $this->is_closing_tag && ! $this->stop_on_tag_closers ) {
if ( $this->is_tag_closer() && ! $this->stop_on_tag_closers ) {
return false;
}

Expand Down
34 changes: 33 additions & 1 deletion tests/phpunit/tests/html-api/wpHtmlProcessorSemanticRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,39 @@ public function test_br_end_tag_unsupported() {

$this->assertTrue( $processor->next_tag(), 'Failed to find the expected opening BR tag.' );
$this->assertFalse( $processor->is_tag_closer(), 'Should have treated the tag as an opening tag.' );
$this->assertNull( $processor->get_attribute_names_with_prefix( '' ), 'Should have ignored any attributes on the tag.' );
$this->assertSame( array(), $processor->get_attribute_names_with_prefix( '' ), 'Should have returned an empty array of attribute names.' );
}

/**
* Ensures that closing `</br>` tags can be rewritten with added attributes
* through the full HTML Processor, while ignoring source attribute-like text.
*
* @covers WP_HTML_Processor::step_in_body
* @covers WP_HTML_Processor::set_attribute
* @covers WP_HTML_Processor::add_class
*
* @ticket 63891
*/
public function test_can_add_attributes_to_br_end_tag() {
$processor = WP_HTML_Processor::create_fragment( '</br id="ignored" class="ignored"><span>' );

$this->assertTrue( $processor->next_tag( 'BR' ), 'Failed to find the expected BR tag.' );
$this->assertNull( $processor->get_attribute( 'id' ), 'Should not have read attribute-like text as source attributes.' );
$this->assertNull( $processor->get_attribute( 'class' ), 'Should not have read attribute-like text as source attributes.' );

$this->assertTrue( $processor->set_attribute( 'data-test-id', '14' ), 'Failed to set an attribute on the BR tag.' );
$this->assertTrue( $processor->set_attribute( 'hidden', true ), 'Failed to set a boolean attribute on the BR tag.' );
$this->assertTrue( $processor->add_class( 'new-class' ), 'Failed to add a class to the BR tag.' );

$this->assertSame( '14', $processor->get_attribute( 'data-test-id' ), 'Should have read the pending attribute value.' );
$this->assertTrue( $processor->get_attribute( 'hidden' ), 'Should have read the pending boolean attribute value.' );
$this->assertSame( 'new-class', $processor->get_attribute( 'class' ), 'Should have read the pending class value.' );

$this->assertSame(
'<br class="new-class" data-test-id="14" hidden><span>',
$processor->get_updated_html(),
'Should have rewritten the BR end tag as an opener with only the new attributes.'
);
}

/*******************************************************************
Expand Down
84 changes: 84 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,90 @@ public function test_updates_bookmark_for_deletions_before_both_sides() {
);
}

/**
* @ticket 63891
*
* @covers WP_HTML_Tag_Processor::seek
* @covers WP_HTML_Tag_Processor::set_bookmark
* @covers WP_HTML_Tag_Processor::set_attribute
*/
public function test_bookmark_on_rewritten_br_close_tag_remains_seekable() {
$processor = new WP_HTML_Tag_Processor( '</br id="ignored"><span></span>' );
$processor->next_tag( 'BR' );
$processor->set_bookmark( 'br' );
$processor->set_attribute( 'data-a', '1' );

$this->assertSame(
'<br data-a="1"><span></span>',
$processor->get_updated_html(),
'Failed to rewrite the BR end tag before seeking back to it.'
);
$this->assertTrue(
$processor->seek( 'br' ),
'Failed to seek back to the rewritten BR tag.'
);
$this->assertSame(
'BR',
$processor->get_tag(),
'Seeking to the rewritten BR tag should land on the BR tag.'
);
$this->assertFalse(
$processor->is_tag_closer(),
'Seeking to the rewritten BR tag should land on an opener.'
);
$this->assertTrue(
$processor->set_attribute( 'data-b', '2' ),
'Should be able to update the rewritten BR tag after seeking back to it.'
);
$this->assertSame(
'<br data-b="2" data-a="1"><span></span>',
$processor->get_updated_html(),
'The bookmark should remain anchored to the rewritten BR tag.'
);
}

/**
* @ticket 63891
*
* @covers WP_HTML_Tag_Processor::seek
* @covers WP_HTML_Tag_Processor::set_bookmark
* @covers WP_HTML_Tag_Processor::set_attribute
*/
public function test_bookmark_on_br_close_tag_rewritten_after_advancing_remains_seekable() {
$processor = new WP_HTML_Tag_Processor( '</br><span></span>' );
$processor->next_tag( 'BR' );
$processor->set_bookmark( 'br' );
$processor->set_attribute( 'data-a', '1' );

$this->assertTrue(
$processor->next_tag( 'SPAN' ),
'Failed to advance past the modified BR end tag.'
);
$this->assertSame(
'<br data-a="1"><span></span>',
$processor->get_updated_html(),
'Failed to rewrite the BR end tag while advancing to the next tag.'
);
$this->assertTrue(
$processor->seek( 'br' ),
'Failed to seek back to the rewritten BR tag.'
);
$this->assertSame(
'BR',
$processor->get_tag(),
'Seeking to the rewritten BR tag should land on the BR tag.'
);
$this->assertTrue(
$processor->set_attribute( 'data-b', '2' ),
'Should be able to update the rewritten BR tag after seeking back to it.'
);
$this->assertSame(
'<br data-b="2" data-a="1"><span></span>',
$processor->get_updated_html(),
'The bookmark should remain anchored to the BR tag rewritten while advancing.'
);
}

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