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..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
@@ -1009,33 +1003,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
@@ -1092,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 ) {
@@ -1150,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;
}
@@ -1473,28 +1447,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 +1644,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 +2149,202 @@ private function parse_next_tag(): bool {
}
/**
- * Parses the next attribute.
+ * Parses attributes exhaustively in a tag.
*
- * @since 6.2.0
+ * 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
*
- * @return bool Whether an attribute was found. Returns `false` upon reaching the end
- * of the tag (`>` or `/>`) or the end of the document.
+ * @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_next_attribute(): bool {
+ private function parse_all_attributes( bool $collect = false ): 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;
- }
+ 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;
+ }
- /**
- * 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;
+ /**
+ * 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;
}
- 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 );
+ /*
+ * 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;
- }
+ if ( $this->bytes_already_parsed + $name_length >= $doc_length ) {
+ $this->parser_state = self::STATE_INCOMPLETE_INPUT;
+ 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;
+ $attribute_start = $this->bytes_already_parsed;
+ $this->bytes_already_parsed += $name_length;
- return false;
- }
+ // 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;
+ }
+ }
- $this->skip_whitespace();
- if ( $this->bytes_already_parsed >= $doc_length ) {
- $this->parser_state = self::STATE_INCOMPLETE_INPUT;
+ $has_value = '=' === $this->html[ $this->bytes_already_parsed ];
+ if ( $has_value ) {
+ ++$this->bytes_already_parsed;
- return false;
- }
+ // 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 ];
+ }
- $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 ) {
+ switch ( $quote ) {
+ case "'":
+ case '"':
+ $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;
+ }
+
+ if ( $attribute_end >= $doc_length ) {
$this->parser_state = self::STATE_INCOMPLETE_INPUT;
+ break;
+ }
- return false;
+ if ( ! $collect ) {
+ continue;
}
- 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;
+ $attribute_name = substr( $this->html, $attribute_start, $name_length );
- 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;
+ /*
+ * > 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;
}
- } 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;
+ /*
+ * 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;
+ }
}
+ }
- if ( $this->is_closing_tag ) {
- return true;
+ /**
+ * 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;
}
- /*
- * > 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
- );
+ $this->attributes = array();
- return true;
- }
-
- /*
- * 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;
+ // Attributes on closing tags are ignored.
+ if ( $this->is_closing_tag ) {
+ return;
}
- return true;
+ $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;
}
/**
@@ -2419,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;
@@ -2445,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'] );
}
@@ -2884,6 +2877,7 @@ public function get_attribute( $name ) {
return $enqueued_value;
}
+ $this->index_attributes();
if ( ! isset( $this->attributes[ $comparable ] ) ) {
return null;
}
@@ -2966,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
@@ -4689,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.
@@ -4781,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 ] );