From 0554baaf2c5f2e74c3a49f847fabfc4aca77c978 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 17:19:09 +0400 Subject: [PATCH 1/9] Simplify tag processor attribute parsing The private `parse_next_attribute()` method was always called in a loop, until all attributes were exhausted and the end of the tag was reached. The method is replaced with `parse_all_attributes()` that loops internally until no more attributes are found. `parse_all_attributes()` also consistently sets the incomplete state flag if the attribute parsing does not terminate before exhausting the input string, indicating an incomplete token. This simplifies calling code and eliminates duplicate and redundant checks, establishing the invariant that `parse_all_attributes()` will always terminate on the tag's closing `>` _OR_ will set the incomplete input state. --- .../html-api/class-wp-html-tag-processor.php | 335 ++++++++---------- 1 file changed, 152 insertions(+), 183 deletions(-) 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 c39368e9a40c1..c48c5819ded68 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 @@ -1009,33 +1009,17 @@ private function base_class_next_token(): bool { return true; } - // Parse all of its attributes. - while ( $this->parse_next_attribute() ) { - continue; - } + $this->parse_all_attributes(); // Ensure that the tag closes before the end of the document. - if ( - self::STATE_INCOMPLETE_INPUT === $this->parser_state || - $this->bytes_already_parsed >= strlen( $this->html ) - ) { - // Does this appropriately clear state (parsed attributes)? - $this->parser_state = self::STATE_INCOMPLETE_INPUT; + if ( self::STATE_INCOMPLETE_INPUT === $this->parser_state ) { $this->bytes_already_parsed = $was_at; - return false; } - $tag_ends_at = strpos( $this->html, '>', $this->bytes_already_parsed ); - if ( false === $tag_ends_at ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; - $this->bytes_already_parsed = $was_at; - - return false; - } - $this->parser_state = self::STATE_MATCHED_TAG; - $this->bytes_already_parsed = $tag_ends_at + 1; - $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; + $this->parser_state = self::STATE_MATCHED_TAG; + ++$this->bytes_already_parsed; + $this->token_length = $this->bytes_already_parsed - $this->token_starts_at; /* * Certain tags require additional processing. The first-letter pre-check @@ -1473,28 +1457,15 @@ private function skip_rcdata( string $tag_name ): bool { continue; } - while ( $this->parse_next_attribute() ) { - continue; - } - - $at = $this->bytes_already_parsed; - if ( $at >= strlen( $this->html ) ) { - return false; - } + $this->parse_all_attributes(); - if ( '>' === $html[ $at ] ) { - $this->bytes_already_parsed = $at + 1; - return true; - } - - if ( $at + 1 >= strlen( $this->html ) ) { + // Ensure that the tag closes before the end of the document. + if ( self::STATE_INCOMPLETE_INPUT === $this->parser_state ) { return false; } - if ( '/' === $html[ $at ] && '>' === $html[ $at + 1 ] ) { - $this->bytes_already_parsed = $at + 2; - return true; - } + ++$this->bytes_already_parsed; + return true; } return false; @@ -1683,18 +1654,15 @@ private function skip_script_data(): bool { return false; } - while ( $this->parse_next_attribute() ) { - continue; - } + $this->parse_all_attributes(); - if ( $this->bytes_already_parsed >= $doc_length ) { + // Ensure that the tag closes before the end of the document. + if ( self::STATE_INCOMPLETE_INPUT === $this->parser_state ) { return false; } - if ( '>' === $html[ $this->bytes_already_parsed ] ) { - ++$this->bytes_already_parsed; - return true; - } + ++$this->bytes_already_parsed; + return true; } ++$at; @@ -2191,168 +2159,169 @@ private function parse_next_tag(): bool { } /** - * Parses the next attribute. + * Parses attributes. * - * @since 6.2.0 - * @ignore + * This method must parse all attributes in a tag up to its closing `>`. After calling this + * function, the parser is guaranteed to be positioned at the closing `>` of the tag or at the + * end of the document if the tag is incomplete. + * + * This function will set the parser state to `STATE_INCOMPLETE_INPUT` if the tag is incomplete + * and the parser reaches the end of the document before finding a closing `>`. * - * @return bool Whether an attribute was found. Returns `false` upon reaching the end - * of the tag (`>` or `/>`) or the end of the document. + * @since 7.3.0 + * @ignore */ - private function parse_next_attribute(): bool { + private function parse_all_attributes(): void { $doc_length = strlen( $this->html ); - // Skip whitespace and slashes. - $skipped_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); - $this->bytes_already_parsed += $skipped_length; - if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; - - return false; - } - - /** - * This block serves two purposes: - * - * - A fast path for common tag-ending `>`. - * - A check for the self-closing flag which must appear as `/>`. - * - * In a tag like ``, `/` is the attribute value, not a self-closing - * flag. When it appears in this form, the parser has already consumed the - * attribute value, `$skipped_length` is 0, and this checks below correctly - * identify whether there is a self-closing flag. - * - * Note: Both start and end tags may have the self-closing flag. - */ - if ( '>' === $this->html[ $this->bytes_already_parsed ] ) { - if ( $skipped_length > 0 && '/' === $this->html[ $this->bytes_already_parsed - 1 ] ) { - $this->has_self_closing_flag = true; + while ( true ) { + // Skip whitespace and slashes. + $skipped_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); + $this->bytes_already_parsed += $skipped_length; + if ( $this->bytes_already_parsed >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + break; } - return false; - } - - /* - * Treat the equal sign as a part of the attribute - * name if it is the first encountered byte. - * - * @link https://html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state - */ - $name_length = '=' === $this->html[ $this->bytes_already_parsed ] - ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) - : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); - // No attribute, just tag closer. - if ( 0 === $name_length || $this->bytes_already_parsed + $name_length >= $doc_length ) { - return false; - } + /** + * This block serves two purposes: + * + * - A fast path for common tag-ending `>`. + * - A check for the self-closing flag which must appear as `/>`. + * + * In a tag like ``, `/` is the attribute value, not a self-closing + * flag. When it appears in this form, the parser has already consumed the + * attribute value, `$skipped_length` is 0, and this checks below correctly + * identify whether there is a self-closing flag. + * + * Note: Both start and end tags may have the self-closing flag. + */ + if ( '>' === $this->html[ $this->bytes_already_parsed ] ) { + if ( $skipped_length > 0 && '/' === $this->html[ $this->bytes_already_parsed - 1 ] ) { + $this->has_self_closing_flag = true; + } + break; + } - $attribute_start = $this->bytes_already_parsed; - $attribute_name = substr( $this->html, $attribute_start, $name_length ); - $this->bytes_already_parsed += $name_length; - if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; + /* + * Treat the equal sign as a part of the attribute + * name if it is the first encountered byte. + * + * @link https://html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state + */ + $name_length = '=' === $this->html[ $this->bytes_already_parsed ] + ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) + : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); - return false; - } + // No attribute, just tag closer. + if ( 0 === $name_length ) { + break; + } - $this->skip_whitespace(); - if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; + if ( $this->bytes_already_parsed + $name_length >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + break; + } - return false; - } + $attribute_start = $this->bytes_already_parsed; + $attribute_name = substr( $this->html, $attribute_start, $name_length ); + $this->bytes_already_parsed += $name_length; - $has_value = '=' === $this->html[ $this->bytes_already_parsed ]; - if ( $has_value ) { - ++$this->bytes_already_parsed; $this->skip_whitespace(); if ( $this->bytes_already_parsed >= $doc_length ) { $this->parser_state = self::STATE_INCOMPLETE_INPUT; - - return false; + break; } - switch ( $this->html[ $this->bytes_already_parsed ] ) { - case "'": - case '"': - $quote = $this->html[ $this->bytes_already_parsed ]; - $value_start = $this->bytes_already_parsed + 1; - $end_quote_at = strpos( $this->html, $quote, $value_start ); - $end_quote_at = false === $end_quote_at ? $doc_length : $end_quote_at; - $value_length = $end_quote_at - $value_start; - $attribute_end = $end_quote_at + 1; - $this->bytes_already_parsed = $attribute_end; + $has_value = '=' === $this->html[ $this->bytes_already_parsed ]; + if ( $has_value ) { + ++$this->bytes_already_parsed; + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; break; + } - default: - $value_start = $this->bytes_already_parsed; - $value_length = strcspn( $this->html, "> \t\f\r\n", $value_start ); - $attribute_end = $value_start + $value_length; - $this->bytes_already_parsed = $attribute_end; + switch ( $this->html[ $this->bytes_already_parsed ] ) { + case "'": + case '"': + $quote = $this->html[ $this->bytes_already_parsed ]; + $value_start = $this->bytes_already_parsed + 1; + $end_quote_at = strpos( $this->html, $quote, $value_start ); + $end_quote_at = false === $end_quote_at ? $doc_length : $end_quote_at; + $value_length = $end_quote_at - $value_start; + $attribute_end = $end_quote_at + 1; + $this->bytes_already_parsed = $attribute_end; + break; + + default: + $value_start = $this->bytes_already_parsed; + $value_length = strcspn( $this->html, "> \t\f\r\n", $value_start ); + $attribute_end = $value_start + $value_length; + $this->bytes_already_parsed = $attribute_end; + } + } else { + $value_start = $this->bytes_already_parsed; + $value_length = 0; + $attribute_end = $attribute_start + $name_length; } - } else { - $value_start = $this->bytes_already_parsed; - $value_length = 0; - $attribute_end = $attribute_start + $name_length; - } - - if ( $attribute_end >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; - - return false; - } - if ( $this->is_closing_tag ) { - return true; - } + if ( $attribute_end >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + break; + } - /* - * > There must never be two or more attributes on - * > the same start tag whose names are an ASCII - * > case-insensitive match for each other. - * - HTML 5 spec - * - * @link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive - * - * The tokenizer replaces U+0000 NULL bytes in - * attribute names with U+FFFD. - * - * @link https://html.spec.whatwg.org/#attribute-name-state - */ - $comparable_name = strtolower( str_replace( "\x00", "\u{FFFD}", $attribute_name ) ); - - // If an attribute is listed many times, only use the first declaration and ignore the rest. - if ( ! isset( $this->attributes[ $comparable_name ] ) ) { - $this->attributes[ $comparable_name ] = new WP_HTML_Attribute_Token( - $attribute_name, - $value_start, - $value_length, - $attribute_start, - $attribute_end - $attribute_start, - ! $has_value - ); + if ( $this->is_closing_tag ) { + continue; + } - return true; - } + /* + * > There must never be two or more attributes on + * > the same start tag whose names are an ASCII + * > case-insensitive match for each other. + * - HTML 5 spec + * + * @link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + * + * The tokenizer replaces U+0000 NULL bytes in + * attribute names with U+FFFD. + * + * @link https://html.spec.whatwg.org/#attribute-name-state + */ + $comparable_name = strtolower( str_replace( "\x00", "\u{FFFD}", $attribute_name ) ); + + // If an attribute is listed many times, only use the first declaration and ignore the rest. + if ( ! isset( $this->attributes[ $comparable_name ] ) ) { + $this->attributes[ $comparable_name ] = new WP_HTML_Attribute_Token( + $attribute_name, + $value_start, + $value_length, + $attribute_start, + $attribute_end - $attribute_start, + ! $has_value + ); + continue; + } - /* - * Track the duplicate attributes so if we remove it, all disappear together. - * - * While `$this->duplicated_attributes` could always be stored as an `array()`, - * which would simplify the logic here, storing a `null` and only allocating - * an array when encountering duplicates avoids needless allocations in the - * normative case of parsing tags with no duplicate attributes. - */ - $duplicate_span = new WP_HTML_Span( $attribute_start, $attribute_end - $attribute_start ); - if ( null === $this->duplicate_attributes ) { - $this->duplicate_attributes = array( $comparable_name => array( $duplicate_span ) ); - } elseif ( ! isset( $this->duplicate_attributes[ $comparable_name ] ) ) { - $this->duplicate_attributes[ $comparable_name ] = array( $duplicate_span ); - } else { - $this->duplicate_attributes[ $comparable_name ][] = $duplicate_span; + /* + * Track the duplicate attributes so if we remove it, all disappear together. + * + * While `$this->duplicated_attributes` could always be stored as an `array()`, + * which would simplify the logic here, storing a `null` and only allocating + * an array when encountering duplicates avoids needless allocations in the + * normative case of parsing tags with no duplicate attributes. + */ + $duplicate_span = new WP_HTML_Span( $attribute_start, $attribute_end - $attribute_start ); + if ( null === $this->duplicate_attributes ) { + $this->duplicate_attributes = array( $comparable_name => array( $duplicate_span ) ); + } elseif ( ! isset( $this->duplicate_attributes[ $comparable_name ] ) ) { + $this->duplicate_attributes[ $comparable_name ] = array( $duplicate_span ); + } else { + $this->duplicate_attributes[ $comparable_name ][] = $duplicate_span; + } } - return true; + assert( self::STATE_INCOMPLETE_INPUT === $this->parser_state || '>' === $this->html[ $this->bytes_already_parsed ] ); } /** From 6d2dbf0604ea3a821a5bf396e9bfb771e2fae021 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 18:47:35 +0400 Subject: [PATCH 2/9] Replace unreachable block with assertion - strspn skipped whitespace - leading "=" gives at least length 1 - strcspn must be at least 1 --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 c48c5819ded68..8a1498fca9229 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 @@ -2213,10 +2213,7 @@ private function parse_all_attributes(): void { ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); - // No attribute, just tag closer. - if ( 0 === $name_length ) { - break; - } + assert( $name_length > 0 ); if ( $this->bytes_already_parsed + $name_length >= $doc_length ) { $this->parser_state = self::STATE_INCOMPLETE_INPUT; From 2faddd6d814aedc2b079e66210317e37eb15a566 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 18:52:37 +0400 Subject: [PATCH 3/9] Improve documentation --- .../html-api/class-wp-html-tag-processor.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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 8a1498fca9229..2b754c9a180c0 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 @@ -2159,14 +2159,11 @@ private function parse_next_tag(): bool { } /** - * Parses attributes. + * Parses attributes exhaustively in a tag. * - * This method must parse all attributes in a tag up to its closing `>`. After calling this - * function, the parser is guaranteed to be positioned at the closing `>` of the tag or at the - * end of the document if the tag is incomplete. - * - * This function will set the parser state to `STATE_INCOMPLETE_INPUT` if the tag is incomplete - * and the parser reaches the end of the document before finding a closing `>`. + * After calling this function, the processor will either be at the tag-closing `>` or the + * parser state will be set to `self::STATE_INCOMPLETE_INPUT` if the end of the document is + * reached before attribute parsing finishes. * * @since 7.3.0 * @ignore From 5b3707e3d225e82606f3eb4df614caa202d2be10 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 19:28:40 +0400 Subject: [PATCH 4/9] Remove assertions --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 4 ---- 1 file changed, 4 deletions(-) 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 2b754c9a180c0..4ba3c81c5cb8a 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 @@ -2210,8 +2210,6 @@ private function parse_all_attributes(): void { ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); - assert( $name_length > 0 ); - if ( $this->bytes_already_parsed + $name_length >= $doc_length ) { $this->parser_state = self::STATE_INCOMPLETE_INPUT; break; @@ -2314,8 +2312,6 @@ private function parse_all_attributes(): void { $this->duplicate_attributes[ $comparable_name ][] = $duplicate_span; } } - - assert( self::STATE_INCOMPLETE_INPUT === $this->parser_state || '>' === $this->html[ $this->bytes_already_parsed ] ); } /** From d597cc2d21dca3af38b4f468bcaa03193a390ad9 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 29 Oct 2026 08:16:54 +0400 Subject: [PATCH 5/9] Sketching attribute whitespace fast path --- .../html-api/class-wp-html-tag-processor.php | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) 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 4ba3c81c5cb8a..c3ca48c0a986d 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 @@ -2219,25 +2219,33 @@ private function parse_all_attributes(): void { $attribute_name = substr( $this->html, $attribute_start, $name_length ); $this->bytes_already_parsed += $name_length; - $this->skip_whitespace(); - if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; - break; + // There is no whitespace to skip when `=` follows the name. + if ( '=' !== $this->html[ $this->bytes_already_parsed ] ) { + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + break; + } } $has_value = '=' === $this->html[ $this->bytes_already_parsed ]; if ( $has_value ) { ++$this->bytes_already_parsed; - $this->skip_whitespace(); - if ( $this->bytes_already_parsed >= $doc_length ) { - $this->parser_state = self::STATE_INCOMPLETE_INPUT; - break; + + // There is no whitespace to skip when a quote follows `=`. + $quote = $this->html[ $this->bytes_already_parsed ] ?? ''; + if ( '"' !== $quote && "'" !== $quote ) { + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= $doc_length ) { + $this->parser_state = self::STATE_INCOMPLETE_INPUT; + break; + } + $quote = $this->html[ $this->bytes_already_parsed ]; } - switch ( $this->html[ $this->bytes_already_parsed ] ) { + switch ( $quote ) { case "'": case '"': - $quote = $this->html[ $this->bytes_already_parsed ]; $value_start = $this->bytes_already_parsed + 1; $end_quote_at = strpos( $this->html, $quote, $value_start ); $end_quote_at = false === $end_quote_at ? $doc_length : $end_quote_at; From b3766919e55d04273e4eb2bbf4bdd735d8833b97 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 29 Oct 2026 20:48:42 +0400 Subject: [PATCH 6/9] Annotate non-empty-string --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 c3ca48c0a986d..bbcb2a89a6a3d 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 @@ -2215,7 +2215,8 @@ private function parse_all_attributes(): void { break; } - $attribute_start = $this->bytes_already_parsed; + $attribute_start = $this->bytes_already_parsed; + /** @phpstan-var non-empty-string $attribute_name */ $attribute_name = substr( $this->html, $attribute_start, $name_length ); $this->bytes_already_parsed += $name_length; From 14c91240e7320f0425ed17fe10928370622c638d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 30 Oct 2026 07:27:51 +0400 Subject: [PATCH 7/9] Break on a tag-closing `>` before the attribute whitespace scan When the next byte is `>`, parse_all_attributes() now stops without calling strspn() for whitespace and slashes. On the matching base this made the Tag Processor 2.8% faster with tracing JIT and 1.8% faster with JIT off, with no difference for the HTML Processor. --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 5 +++++ 1 file changed, 5 insertions(+) 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 bbcb2a89a6a3d..a315ed3afc3ec 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 @@ -2172,6 +2172,11 @@ private function parse_all_attributes(): void { $doc_length = strlen( $this->html ); while ( true ) { + // A tag-closing `>` needs no whitespace scan. + if ( $this->bytes_already_parsed < $doc_length && '>' === $this->html[ $this->bytes_already_parsed ] ) { + break; + } + // Skip whitespace and slashes. $skipped_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); $this->bytes_already_parsed += $skipped_length; From c10e0d38e1da77dcbcf0e7400224cf4d92d103fd Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 30 Oct 2026 07:39:16 +0400 Subject: [PATCH 8/9] Revert "Break on a tag-closing `>` before the attribute whitespace scan" This reverts commit 03d8747fb562027fb55aed25c5727a3f70f67ff2. --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 5 ----- 1 file changed, 5 deletions(-) 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 a315ed3afc3ec..bbcb2a89a6a3d 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 @@ -2172,11 +2172,6 @@ private function parse_all_attributes(): void { $doc_length = strlen( $this->html ); while ( true ) { - // A tag-closing `>` needs no whitespace scan. - if ( $this->bytes_already_parsed < $doc_length && '>' === $this->html[ $this->bytes_already_parsed ] ) { - break; - } - // Skip whitespace and slashes. $skipped_length = strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); $this->bytes_already_parsed += $skipped_length; From 26ad7767e4617e33ab11828962f4b7ccb91a82f7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 30 Oct 2026 08:01:30 +0400 Subject: [PATCH 9/9] Explain annotation --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 bbcb2a89a6a3d..b57e46cd38d35 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 @@ -2216,7 +2216,14 @@ private function parse_all_attributes(): void { } $attribute_start = $this->bytes_already_parsed; - /** @phpstan-var non-empty-string $attribute_name */ + /** + * Attribute name. + * + * The name is non-empty because whitespace and slashes are skipped, `>` ends parsing, + * and a leading `=` is included. PHPStan cannot infer this, so the type is annotated below. + * + * @var non-empty-string $attribute_name + */ $attribute_name = substr( $this->html, $attribute_start, $name_length ); $this->bytes_already_parsed += $name_length;