From 423dcb7a02074d243176ceb3d2662601db1b88ba Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 17:19:09 +0400 Subject: [PATCH 1/6] 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 d14b9f6161d9859f16d758695af4059204f72d07 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 18:47:35 +0400 Subject: [PATCH 2/6] 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 b8813a948265f1043d04af46ff8424f883b2fcc5 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 18:52:37 +0400 Subject: [PATCH 3/6] 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 24da95003895ef76f0e2b64d0374d9ea7eea7443 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 28 Oct 2026 19:28:40 +0400 Subject: [PATCH 4/6] 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 f3d1724c2d707b223815fda614c3cbe412f1f98e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 29 Oct 2026 08:16:54 +0400 Subject: [PATCH 5/6] 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 8651147b6c44f28038eecfab5393947c149bd325 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 29 Oct 2026 16:32:30 +0400 Subject: [PATCH 6/6] Index tag attributes when first needed The Tag Processor built an attribute index for every opening tag: a substr() and strtolower() of each name, a WP_HTML_Attribute_Token, and a map insert. Most tags are passed over without reading an attribute. parse_all_attributes() now only finds the end of the tag unless asked to collect. The index is null after each tag and index_attributes() builds it by scanning the tag's attributes a second time on the first read from get_attribute(), get_attribute_names_with_prefix(), set_attribute(), remove_attribute(), or a class name update. The scan of a special element's closing tag no longer writes the index, so the opener's index no longer needs saving and restoring around it. A variant of the previous commit that scans and never indexes measured 14.9% [14.7%, 15.1%] faster with tracing JIT and 11.5% [10.9%, 12.0%] without, Tag Processor next_token() over 32 documents, PHP 8.5.12. Tags whose attributes are read now scan them twice; that cost is unmeasured. --- .../html-api/class-wp-html-tag-processor.php | 78 +++++++++++++------ 1 file changed, 54 insertions(+), 24 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 c3ca48c0a986d..0272f063d8072 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 @@ -697,31 +697,25 @@ class WP_HTML_Tag_Processor { /** * Lazily-built index of attributes found within an HTML tag, keyed by the attribute name. * - * Example: + * This is `null` until the index is built for the current tag. * - * // Supposing the parser is working through this content - * // and stops after recognizing the `id` attribute. - * //
- * // ^ parsing will continue from this point. - * $this->attributes = array( - * 'id' => new WP_HTML_Attribute_Token( 'id', 9, 6, 5, 11, false ) - * ); + * Example: * - * // When picking up parsing again, or when asking to find the - * // `class` attribute we will continue and add to this array. + * //
* $this->attributes = array( * 'id' => new WP_HTML_Attribute_Token( 'id', 9, 6, 5, 11, false ), * 'class' => new WP_HTML_Attribute_Token( 'class', 23, 7, 17, 13, false ) * ); * - * // Note that only the `class` attribute value is stored in the index. - * // That's because it is the only value used by this class at the moment. - * * @since 6.2.0 - * @var WP_HTML_Attribute_Token[] - * @phpstan-var array + * @since 7.3.0 Built when first needed instead of when the tag is parsed. + * + * @see WP_HTML_Tag_Processor::index_attributes() + * + * @var WP_HTML_Attribute_Token[]|null + * @phpstan-var array|null */ - private $attributes = array(); + private $attributes = null; /** * Tracks spans of duplicate attributes on a given tag, used for removing @@ -1076,8 +1070,6 @@ private function base_class_next_token(): bool { $tag_name_length = $this->tag_name_length; $tag_ends_at = $this->token_starts_at + $this->token_length; $has_self_closing_flag = $this->has_self_closing_flag; - $attributes = $this->attributes; - $duplicate_attributes = $this->duplicate_attributes; // Find the closing tag if necessary. switch ( $tag_name ) { @@ -1134,8 +1126,6 @@ private function base_class_next_token(): bool { $this->tag_name_starts_at = $tag_name_starts_at; $this->tag_name_length = $tag_name_length; $this->has_self_closing_flag = $has_self_closing_flag; - $this->attributes = $attributes; - $this->duplicate_attributes = $duplicate_attributes; return true; } @@ -2167,8 +2157,13 @@ private function parse_next_tag(): bool { * * @since 7.3.0 * @ignore + * + * @see WP_HTML_Tag_Processor::index_attributes() + * + * @param bool $collect Optional. Whether to add the attributes to the attribute index. + * Otherwise, only find the end of the tag. Default false. */ - private function parse_all_attributes(): void { + private function parse_all_attributes( bool $collect = false ): void { $doc_length = strlen( $this->html ); while ( true ) { @@ -2216,7 +2211,6 @@ private function parse_all_attributes(): void { } $attribute_start = $this->bytes_already_parsed; - $attribute_name = substr( $this->html, $attribute_start, $name_length ); $this->bytes_already_parsed += $name_length; // There is no whitespace to skip when `=` follows the name. @@ -2271,10 +2265,12 @@ private function parse_all_attributes(): void { break; } - if ( $this->is_closing_tag ) { + if ( ! $collect ) { continue; } + $attribute_name = substr( $this->html, $attribute_start, $name_length ); + /* * > There must never be two or more attributes on * > the same start tag whose names are an ASCII @@ -2322,6 +2318,35 @@ private function parse_all_attributes(): void { } } + /** + * Builds the attribute index for the current tag if it has not been built. + * + * Tags are scanned without indexing their attributes. The index is built + * by parsing the tag's attributes again the first time it is needed. + * + * @since 7.3.0 + * @ignore + * + * @phpstan-assert array $this->attributes + */ + private function index_attributes(): void { + if ( null !== $this->attributes ) { + return; + } + + $this->attributes = array(); + + // Attributes on closing tags are ignored. + if ( $this->is_closing_tag ) { + return; + } + + $was_at = $this->bytes_already_parsed; + $this->bytes_already_parsed = $this->tag_name_starts_at + $this->tag_name_length; + $this->parse_all_attributes( true ); + $this->bytes_already_parsed = $was_at; + } + /** * Move the internal cursor past any immediate successive whitespace. * @@ -2386,7 +2411,7 @@ private function after_tag(): void { $this->text_starts_at = 0; $this->text_length = 0; $this->is_closing_tag = null; - $this->attributes = array(); + $this->attributes = null; $this->comment_type = null; $this->text_node_classification = self::TEXT_IS_GENERIC; $this->duplicate_attributes = null; @@ -2412,6 +2437,7 @@ private function class_name_updates_to_attributes_updates(): void { $existing_class = ''; } + $this->index_attributes(); if ( false === $existing_class && isset( $this->attributes['class'] ) ) { $existing_class = $this->get_decoded_attribute_value( $this->attributes['class'] ); } @@ -2851,6 +2877,7 @@ public function get_attribute( $name ) { return $enqueued_value; } + $this->index_attributes(); if ( ! isset( $this->attributes[ $comparable ] ) ) { return null; } @@ -2933,6 +2960,7 @@ public function get_attribute_names_with_prefix( $prefix ): ?array { } $comparable = strtolower( $prefix ); + $this->index_attributes(); /* * For the `class` attribute, ensure that enqueued class changes from @@ -4656,6 +4684,7 @@ public function set_attribute( $name, $value ): bool { */ $comparable_name = strtolower( $name ); + $this->index_attributes(); if ( isset( $this->attributes[ $comparable_name ] ) ) { /* * Update an existing attribute. @@ -4748,6 +4777,7 @@ public function remove_attribute( $name ): bool { * after calling `set_attribute()` for the same attribute * and when that attribute wasn't originally present. */ + $this->index_attributes(); if ( ! isset( $this->attributes[ $name ] ) ) { if ( isset( $this->lexical_updates[ $name ] ) ) { unset( $this->lexical_updates[ $name ] );