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..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
@@ -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;
- }
-
- if ( '>' === $html[ $at ] ) {
- $this->bytes_already_parsed = $at + 1;
- return true;
- }
+ $this->parse_all_attributes();
- 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,175 @@ private function parse_next_tag(): bool {
}
/**
- * Parses the next attribute.
+ * Parses attributes exhaustively in a tag.
*
- * @since 6.2.0
- * @ignore
+ * 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.
*
- * @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;
- }
-
- $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;
- return false;
- }
-
- $this->skip_whitespace();
- if ( $this->bytes_already_parsed >= $doc_length ) {
- $this->parser_state = self::STATE_INCOMPLETE_INPUT;
+ /**
+ * 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 );
- $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 ) {
+ if ( $this->bytes_already_parsed + $name_length >= $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;
- break;
+ $attribute_start = $this->bytes_already_parsed;
+ /**
+ * 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;
- 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 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;
+ }
}
- } 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;
+ $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 ];
+ }
- if ( $this->is_closing_tag ) {
- return true;
- }
+ 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;
+ }
- /*
- * > 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 ( $attribute_end >= $doc_length ) {
+ $this->parser_state = self::STATE_INCOMPLETE_INPUT;
+ break;
+ }
- return true;
- }
+ if ( $this->is_closing_tag ) {
+ 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;
- }
+ /*
+ * > 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;
+ }
- 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;
+ }
+ }
}
/**