Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cc9b730
HTML API: Add failing tests for attribute removal creating self-closi…
sirreal Jul 14, 2026
4c90591
HTML API: Remove preceding solidus separators when removing attributes.
sirreal Jul 14, 2026
b133d02
HTML API: Add failing tests for attribute updates sharing a document …
sirreal Jul 14, 2026
4b77c87
HTML API: Keep the copied-bytes cursor monotonic when applying updates.
sirreal Jul 14, 2026
a12950f
HTML API: Add failing tests for repeated attribute removal corrupting…
sirreal Jul 14, 2026
e9f9b69
HTML API: Only enqueue duplicate attribute removals once.
sirreal Jul 14, 2026
dc35c14
HTML API: Add failing test for suppressed duplicate attribute removals.
sirreal Jul 14, 2026
34139ff
HTML API: Only match removals when detecting enqueued duplicate remov…
sirreal Jul 14, 2026
23e9a78
HTML API: Extend attribute removal test coverage.
sirreal Jul 14, 2026
8585f15
HTML API: Document lexical update span invariants.
sirreal Jul 14, 2026
b13dbfa
HTML API: Expand failing test for updates targeting duplicate attribu…
sirreal Jul 14, 2026
8141c7a
HTML API: Enqueue duplicate attribute removals individually.
sirreal Jul 14, 2026
feb910f
HTML API: Document non-overlap obligation for lexical update producers.
sirreal Jul 14, 2026
17c5726
HTML API: Add failing tests for updates within extended removal spans.
sirreal Jul 14, 2026
4a30f31
HTML API: Supersede updates within removed attribute spans.
sirreal Jul 14, 2026
9ca5ade
HTML API: Reconcile all removed attribute spans in a single pass.
sirreal Jul 14, 2026
16e3a6b
lints
sirreal Jul 14, 2026
7f6786a
Merge branch 'trunk' into html-api-rem-attr-sc-flag
sirreal Jul 15, 2026
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
139 changes: 132 additions & 7 deletions src/wp-includes/html-api/class-wp-html-tag-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,12 @@ class WP_HTML_Tag_Processor {
* boundaries, however, so these should never be exposed outside of this
* class or any classes which intentionally expand its functionality.
*
* Any code which creates these lexical updates must also ensure that no
* two updates replace overlapping spans of the document: the cursor and
* bookmark position accounting when applying the updates assumes that
* every update replaces a distinct span. Only zero-length insertions may
* share their offset with another update's span.
*
* These are enqueued while editing the document instead of being immediately
* applied to avoid processing overhead, string allocations, and string
* copies when applying many updates to a single document.
Expand Down Expand Up @@ -2629,9 +2635,26 @@ 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 );
/*
* Updates may share a document offset, e.g. a new attribute inserted
* at the end of the tag name and a removed attribute whose span starts
* there because it was separated from the tag name by only solidus
* characters. The copied-bytes cursor must never move backward, or
* already-replaced spans of the document would be copied again.
*
* Only zero-length insertions may share their offset with another
* update's span; the sort places them after a removal of that span,
* so the inserted text lands where the removed span was. Updates
* with positive length must never overlap each other: the cursor
* and bookmark position accounting below assume that every update
* replaces a distinct span of the document.
*/
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 );
Expand Down Expand Up @@ -4745,24 +4768,126 @@ public function remove_attribute( $name ): bool {
*
* Result: <div />
*/
$this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement(
$removal_spans = array();
$removal_spans[] = $this->get_attribute_removal_span(
$this->attributes[ $name ]->start,
$this->attributes[ $name ]->length,
$this->attributes[ $name ]->length
);
foreach ( $this->duplicate_attributes[ $name ] ?? array() as $attribute_token ) {
$removal_spans[] = $this->get_attribute_removal_span(
$attribute_token->start,
$attribute_token->length
);
}

$this->supersede_updates_in_removal_spans( $removal_spans );

$this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement(
$removal_spans[0]->start,
$removal_spans[0]->length,
''
);

// Removes any duplicated attributes if they were also present.
foreach ( $this->duplicate_attributes[ $name ] ?? array() as $attribute_token ) {
$removal_span_count = count( $removal_spans );
for ( $i = 1; $i < $removal_span_count; $i++ ) {
$this->lexical_updates[] = new WP_HTML_Text_Replacement(
$attribute_token->start,
$attribute_token->length,
$removal_spans[ $i ]->start,
$removal_spans[ $i ]->length,
''
);
}

return true;
}

/**
* Removes enqueued lexical updates replacing spans within removed spans.
*
* Removing spans of the document supersedes updates already enqueued
* over intersecting spans: removal replaces the entire span, and no two
* updates may replace overlapping spans of the document, since bookmark
* and cursor position accounting assumes that every update replaces a
* distinct span. This also ensures that each removal is enqueued exactly
* once, no matter how many times an attribute is removed.
*
* Zero-length insertions replace no spans of the document and are
* unaffected; they may share their offset with a removed span.
*
* The given spans must be sorted by starting position and must not
* overlap each other, as is the case for attribute spans, which appear
* in document order. Each enqueued update is then checked against the
* spans with a single binary search: an update can only intersect the
* last span starting before the update's end.
*
* @since 7.1.0
*
* @param WP_HTML_Span[] $removal_spans Non-overlapping spans of the document being
* removed, sorted by starting position.
*/
private function supersede_updates_in_removal_spans( array $removal_spans ): void {
$span_count = count( $removal_spans );

foreach ( $this->lexical_updates as $key => $update ) {
if ( 0 === $update->length ) {
continue;
}

$update_end = $update->start + $update->length;

// Find the last removal span starting before the end of the update.
$candidate = null;
$low = 0;
$high = $span_count - 1;
while ( $low <= $high ) {
$probe = intdiv( $low + $high, 2 );
if ( $removal_spans[ $probe ]->start < $update_end ) {
$candidate = $removal_spans[ $probe ];
$low = $probe + 1;
} else {
$high = $probe - 1;
}
}

if ( null !== $candidate && $update->start < $candidate->start + $candidate->length ) {
unset( $this->lexical_updates[ $key ] );
}
}
}

/**
* Returns the full span of the document to remove for an attribute.
*
* Solidus characters ("/") may precede an attribute name, where they act
* like attribute separators and are not part of any syntax token. Such
* solidus characters must be removed with the attribute; otherwise a
* remaining solidus directly before the tag-closing ">" would become a
* self-closing flag and change the meaning of the surrounding HTML.
*
* Example:
*
* <svg><g /attr>inside g</svg>
*
* Removing only `attr` would produce `<svg><g />inside g</svg>`, where
* the self-closing G element no longer contains the text that follows.
*
* @since 7.1.0
*
* @param int $start Byte offset into the document where the attribute starts.
* @param int $length Byte length of the attribute.
* @return WP_HTML_Span Span of the document to remove for the attribute.
*/
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.
*
Expand Down
71 changes: 71 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,77 @@ public function test_unquoted_slash_attribute_does_not_self_close_foreign_conten
);
}

/**
* Ensures that removing an attribute does not change the structure of the document.
*
* A solidus before an attribute name is ignored when parsing, but if removing
* the attribute left the solidus directly before the tag-closing ">", the tag
* would gain a self-closing flag. In foreign content this changes whether the
* element contains the content that follows it.
*
* @ticket 65372
*
* @covers WP_HTML_Tag_Processor::remove_attribute
*/
public function test_remove_attribute_does_not_self_close_foreign_content(): void {
$processor = WP_HTML_Processor::create_fragment( '<svg><g /attr>ok' );

$this->assertTrue( $processor->next_tag( 'G' ), 'Failed to find the G tag: check test setup.' );
$this->assertFalse( $processor->has_self_closing_flag(), 'The G tag must not have a self-closing flag: check test setup.' );
$this->assertTrue( $processor->remove_attribute( 'attr' ), 'Failed to remove the attribute.' );

$updated_html = $processor->get_updated_html();
$this->assertSame( '<svg><g >ok', $updated_html, 'Failed to remove the attribute with its preceding solidus.' );

$processor = WP_HTML_Processor::create_fragment( $updated_html );
$this->assertTrue( $processor->next_tag( 'G' ), 'Failed to find the G tag in the updated HTML.' );
$this->assertFalse(
$processor->has_self_closing_flag(),
'Failed to prevent the G tag from becoming self-closing when removing the attribute.'
);

$this->assertTrue( $processor->next_token(), 'Failed to find text following the G tag in the updated HTML.' );
$this->assertSame(
array( 'HTML', 'BODY', 'SVG', 'G', '#text' ),
$processor->get_breadcrumbs(),
'Failed to keep text following the G tag inside the G element.'
);
}

/**
* Ensures that removing an attribute preserves a genuine self-closing flag
* and the resulting document structure in foreign content.
*
* @ticket 65372
*
* @covers WP_HTML_Tag_Processor::remove_attribute
*/
public function test_remove_attribute_preserves_self_closing_foreign_content(): void {
$processor = WP_HTML_Processor::create_fragment( '<math><mi /attr/>ok' );

$this->assertTrue( $processor->next_tag( 'MI' ), 'Failed to find the MI tag: check test setup.' );
$this->assertTrue( $processor->has_self_closing_flag(), 'The MI tag must have a self-closing flag: check test setup.' );
$this->assertFalse( $processor->expects_closer(), 'The self-closing MI tag must not expect a closer: check test setup.' );
$this->assertTrue( $processor->remove_attribute( 'attr' ), 'Failed to remove the attribute.' );

$updated_html = $processor->get_updated_html();
$this->assertSame( '<math><mi />ok', $updated_html, 'Removing the attribute produced unexpected HTML.' );

$processor = WP_HTML_Processor::create_fragment( $updated_html );
$this->assertTrue( $processor->next_tag( 'MI' ), 'Failed to find the MI tag in the updated HTML.' );
$this->assertTrue(
$processor->has_self_closing_flag(),
'Failed to preserve the self-closing flag of the MI tag when removing the attribute.'
);

$this->assertTrue( $processor->next_token(), 'Failed to find text following the MI tag in the updated HTML.' );
$this->assertSame(
array( 'HTML', 'BODY', 'MATH', '#text' ),
$processor->get_breadcrumbs(),
'Failed to keep text following the self-closing MI tag outside the MI element.'
);
}

/**
* Ensures that expects_closer works for void-like elements in foreign content.
*
Expand Down
Loading
Loading